Build an AI payment application¶
This journey drives the real ServiceEscrow contract (revenue policy v3) through a complete paid job and a refund,
with three separate keys. It is the contract the PQWERAI AI market uses; on the testnet each market deploys its own
instance, so your application must be configured with the escrow address it trusts.
Prerequisites: env.sh, Foundry 1.8.5, solc 0.8.26 (SOLC=solc), three keystores (payer funded with ≥ 0.002 PQAI
plus gas, provider funded for gas, arbiter unfunded), and ServiceEscrow.sol (below).
stateDiagram-v2
[*] --> Funded: fund(quote, providerSig)
Funded --> Proposed: propose(receipt, tokens) [provider]
Proposed --> Accepted: accept [payer]
Proposed --> Disputed: dispute [payer or provider]
Accepted --> Disputed: dispute [payer, within challenge window]
Disputed --> Accepted: resolve(approvedCharge) [arbiter]
Accepted --> Settled: finalize [anyone, after window]
Funded --> Refunded: refundExpired [after service window]
Proposed --> Refunded: refundExpired
Disputed --> Refunded: refundExpired [arbiter silent]
The contract¶
ServiceEscrow.sol (MIT) — full source
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
/// @notice Disposable-testnet escrow. Payer/arbiter authorization is required;
/// provider signatures authenticate quotes, never independently prove inference.
/// Revenue policy v3 (2026-11-05), inference-only: each settled charge pays the deployment-configured
/// payees their basis points (rounded down) and the serving provider the exact remainder. There is no
/// founder share (the founder is paid only from block issuance, protocol/FOUNDER_ISSUANCE_V1.md) and no
/// training/model fund. The schedule itself is a deployment parameter pending economic approval
/// (economics/policy-decision-2026-11-05-v3.json); it is fixed for the life of a deployment and
/// committed in distributionHash. Jobs on deployed v1/v2 escrows keep their terms (contracts/ai/legacy).
contract ServiceEscrow {
uint8 public constant REVENUE_POLICY_VERSION = 3;
string public constant REVENUE_POLICY = "PQAI-SERVICE-REVENUE/3 inference-only provider=remainder payees=deployment-bps founder=0 training=0";
uint256 public constant MAX_PAYEES = 4;
uint256 public constant BPS_DENOMINATOR = 10000;
enum State { None, Funded, Proposed, Accepted, Disputed, Settled, Refunded }
struct Quote {
bytes32 job;
address payer;
address provider;
bytes32 model;
uint256 maxTokens;
uint256 pricePerToken;
uint64 expiresAt;
uint32 serviceBlocks;
}
struct Job {
address payer;
address provider;
uint256 budget;
uint256 pricePerToken;
uint256 maxTokens;
uint256 charge;
uint256 deadline;
bytes32 receipt;
State state;
}
bytes32 public constant DOMAIN = keccak256("PQAI-SERVICE-QUOTE/1");
uint256 private constant HALF_ORDER = 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0;
address public immutable arbiter;
uint256 public immutable challengeBlocks;
uint256 public immutable disputeBlocks;
address[] public payees; // non-provider revenue recipients, fixed at deployment
uint16[] public payeeBps; // their shares in basis points; the provider receives the remainder
bytes32 public immutable distributionHash;
mapping(bytes32 => Job) public jobs;
mapping(bytes32 => bool) public usedReceipts;
mapping(address => uint256) public credits;
bool private withdrawing;
event Funded(bytes32 indexed job, bytes32 quoteHash);
event Proposed(bytes32 indexed job, bytes32 receipt, uint256 charge);
event Changed(bytes32 indexed job, State state);
event Paid(address indexed recipient, uint256 amount);
constructor(address arbiter_, address[] memory payees_, uint16[] memory bps_, uint256 challenge_, uint256 dispute_) {
require(arbiter_ != address(0) && challenge_ > 0 && dispute_ > 0, "policy");
require(challenge_ <= 100000 && dispute_ <= 100000, "window");
require(payees_.length == bps_.length && payees_.length <= MAX_PAYEES, "distribution");
arbiter = arbiter_;
challengeBlocks = challenge_;
disputeBlocks = dispute_;
uint256 total;
for (uint256 i; i < payees_.length; i++) {
require(payees_[i] != address(0) && bps_[i] > 0, "payee");
for (uint256 k; k < i; k++) require(payees_[k] != payees_[i], "duplicate payee");
total += bps_[i];
}
// The serving provider always keeps a non-zero share of every basis point schedule.
require(total < BPS_DENOMINATOR, "provider share");
payees = payees_;
payeeBps = bps_;
distributionHash = keccak256(abi.encode(REVENUE_POLICY_VERSION, payees_, bps_));
}
function distribution() external view returns (address[] memory, uint16[] memory) {
return (payees, payeeBps);
}
function quoteHash(Quote calldata q) public view returns (bytes32) {
return keccak256(abi.encode(DOMAIN, block.chainid, address(this), q));
}
function fund(Quote calldata q, bytes calldata signature) public payable virtual {
require(q.job != bytes32(0) && jobs[q.job].state == State.None, "job reused");
require(msg.sender == q.payer && q.provider != address(0) && q.model != bytes32(0), "identity");
require(block.number <= q.expiresAt && q.serviceBlocks > 0 && q.serviceBlocks <= 100000, "expired");
require(q.maxTokens > 0 && q.pricePerToken > 0, "price");
require(msg.value == q.maxTokens * q.pricePerToken, "budget");
_verifyQuote(q, signature);
jobs[q.job] = Job(q.payer, q.provider, msg.value, q.pricePerToken, q.maxTokens,
0, block.number + q.serviceBlocks, bytes32(0), State.Funded);
emit Funded(q.job, quoteHash(q));
}
function _verifyQuote(Quote calldata q, bytes calldata signature) internal view {
require(signature.length == 65, "signature");
bytes32 r; bytes32 s; uint8 v;
assembly { r := calldataload(signature.offset) s := calldataload(add(signature.offset, 32)) v := byte(0, calldataload(add(signature.offset, 64))) }
require(uint256(s) <= HALF_ORDER && (v == 27 || v == 28), "noncanonical");
bytes32 h = quoteHash(q);
bytes32 signedHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", h));
require(ecrecover(signedHash, v, r, s) == q.provider, "untrusted quote");
}
function propose(bytes32 id, bytes32 receipt, uint256 tokens) public virtual {
Job storage j = jobs[id];
require(j.state == State.Funded && msg.sender == j.provider && block.number <= j.deadline, "state");
require(receipt != bytes32(0) && !usedReceipts[receipt] && tokens > 0 && tokens <= j.maxTokens, "usage");
usedReceipts[receipt] = true;
j.receipt = receipt;
j.charge = tokens * j.pricePerToken;
j.state = State.Proposed;
// Provider proposal cannot extend the payer's refund deadline indefinitely.
emit Proposed(id, receipt, j.charge);
}
function accept(bytes32 id) external {
Job storage j = jobs[id];
require(msg.sender == j.payer && j.state == State.Proposed && block.number <= j.deadline, "state");
j.state = State.Accepted;
j.deadline = block.number + challengeBlocks;
emit Changed(id, j.state);
}
function dispute(bytes32 id) external {
Job storage j = jobs[id];
require((msg.sender == j.payer && (j.state == State.Proposed || j.state == State.Accepted))
|| (msg.sender == j.provider && j.state == State.Proposed), "state");
require(block.number <= j.deadline, "late");
j.state = State.Disputed;
j.deadline = block.number + disputeBlocks;
emit Changed(id, j.state);
}
function resolve(bytes32 id, uint256 approvedCharge) external {
Job storage j = jobs[id];
require(msg.sender == arbiter && j.state == State.Disputed && block.number <= j.deadline, "arbiter");
require(approvedCharge <= j.charge, "charge");
j.charge = approvedCharge;
j.state = State.Accepted;
// This adjudicated decision is final under the explicitly trusted arbiter policy.
j.deadline = block.number;
emit Changed(id, j.state);
}
function finalize(bytes32 id) external {
Job storage j = jobs[id];
require(j.state == State.Accepted && block.number > j.deadline, "state");
j.state = State.Settled;
credits[j.payer] += j.budget - j.charge;
uint256 allocated;
uint256 n = payees.length;
for (uint256 i; i < n; i++) {
// floor(charge * bps / 10000) without intermediate overflow.
uint256 bps = payeeBps[i];
uint256 share = (j.charge / BPS_DENOMINATOR) * bps + (j.charge % BPS_DENOMINATOR) * bps / BPS_DENOMINATOR;
credits[payees[i]] += share;
allocated += share;
}
credits[_providerPayee(id)] += j.charge - allocated;
emit Changed(id, j.state);
}
function _providerPayee(bytes32 id) internal view virtual returns (address) {
return jobs[id].provider;
}
function refundExpired(bytes32 id) external {
Job storage j = jobs[id];
require(j.state == State.Funded || j.state == State.Proposed || j.state == State.Disputed, "state");
require(block.number > j.deadline, "not expired");
j.state = State.Refunded;
credits[j.payer] += j.budget;
emit Changed(id, j.state);
}
function withdraw() external {
require(!withdrawing, "reentrancy");
uint256 amount = credits[msg.sender];
require(amount > 0, "empty");
credits[msg.sender] = 0;
withdrawing = true;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "transfer");
withdrawing = false;
emit Paid(msg.sender, amount);
}
}
Key rules your application must respect:
- The quote hash is
keccak256(abi.encode(DOMAIN, chainid, escrow, quote)); the provider signs it as an EIP-191 personal message ("\x19Ethereum Signed Message:\n32" || hash), low-s,v∈ {27, 28}. fundrequiresmsg.sender == quote.payerandmsg.value == maxTokens × pricePerToken.- A receipt commitment can be used once (
usedReceipts), so replays fail. - Payments are credited, then pulled with
withdraw().
Run the flow¶
# End-to-end AI payment on ServiceEscrow (revenue policy v3): quote -> fund -> propose -> accept ->
# finalize -> withdraw, plus a timeout refund. Three roles, three keys: PAYER (the application user),
# PROVIDER (the AI provider's settlement key) and ARBITER (trusted dispute resolver). Set each pair of
# *_KEYSTORE / *_PASSWORD_FILE variables. Short windows (challenge 2, dispute 2 blocks) are for testing only.
P="--keystore $PAYER_KEYSTORE --password-file $PAYER_PASSWORD_FILE --rpc-url $RPC"
V="--keystore $PROVIDER_KEYSTORE --password-file $PROVIDER_PASSWORD_FILE --rpc-url $RPC"
PAYER=$(cast wallet address --keystore "$PAYER_KEYSTORE" --password-file "$PAYER_PASSWORD_FILE")
PROVIDER=$(cast wallet address --keystore "$PROVIDER_KEYSTORE" --password-file "$PROVIDER_PASSWORD_FILE")
ARBITER=$(cast wallet address --keystore "$ARBITER_KEYSTORE" --password-file "$ARBITER_PASSWORD_FILE")
# 1. Deploy: arbiter, no extra payees (testnet default: provider keeps 100%), challenge 2, dispute 2 blocks.
"$SOLC" --evm-version cancun --optimize --bin ServiceEscrow.sol -o out --overwrite >/dev/null
ARGS=$(cast abi-encode "constructor(address,address[],uint16[],uint256,uint256)" "$ARBITER" "[]" "[]" 2 2)
TX=$(cast send --async $P --create "0x$(cat out/ServiceEscrow.bin)${ARGS#0x}")
ESCROW=$(cast receipt "$TX" contractAddress --rpc-url "$RPC")
cast call "$ESCROW" "REVENUE_POLICY()(string)" --rpc-url "$RPC"
# 2. Provider signs a quote: job id, payer, provider, model id, max tokens, price per token (wei), expiry block, service window.
QT="(bytes32,address,address,bytes32,uint256,uint256,uint64,uint32)"
JOB=$(cast keccak "tutorial-job-$(date +%s)")
MODEL=$(cast keccak "model:example-gguf")
EXPIRES=$(( $(cast block-number --rpc-url "$RPC") + 200 ))
QUOTE="($JOB,$PAYER,$PROVIDER,$MODEL,1000,1000000000000,$EXPIRES,100)"
QHASH=$(cast call "$ESCROW" "quoteHash($QT)(bytes32)" "$QUOTE" --rpc-url "$RPC")
SIG=$(cast wallet sign "$QHASH" --keystore "$PROVIDER_KEYSTORE" --password-file "$PROVIDER_PASSWORD_FILE")
# 3. Payer funds exactly maxTokens * pricePerToken = 0.001 PQAI.
cast receipt "$(cast send "$ESCROW" "fund($QT,bytes)" "$QUOTE" "$SIG" --value 1000000000000000 --async $P)" status --rpc-url "$RPC"
# 4. Provider serves the request off-chain, then proposes usage: receipt commitment + 640 tokens.
RECEIPT=$(cast keccak "usage-record-of-$JOB")
cast receipt "$(cast send "$ESCROW" "propose(bytes32,bytes32,uint256)" "$JOB" "$RECEIPT" 640 --async $V)" status --rpc-url "$RPC"
# 5. Payer accepts (or calls dispute(bytes32) within the window), then anyone finalizes after the challenge window.
cast receipt "$(cast send "$ESCROW" "accept(bytes32)" "$JOB" --async $P)" status --rpc-url "$RPC"
until cast send "$ESCROW" "finalize(bytes32)" "$JOB" $P >/dev/null 2>&1; do sleep 5; done
cast call "$ESCROW" "jobs(bytes32)(address,address,uint256,uint256,uint256,uint256,uint256,bytes32,uint8)" "$JOB" --rpc-url "$RPC"
# 6. Pull payments: provider gets 640 * price, payer gets the unused budget back.
cast call "$ESCROW" "credits(address)(uint256)" "$PROVIDER" --rpc-url "$RPC"
cast call "$ESCROW" "credits(address)(uint256)" "$PAYER" --rpc-url "$RPC"
cast receipt "$(cast send "$ESCROW" "withdraw()" --async $V)" status --rpc-url "$RPC"
cast receipt "$(cast send "$ESCROW" "withdraw()" --async $P)" status --rpc-url "$RPC"
# 7. Refund path: a funded job with no accepted usage is refundable after its service window.
JOB2=$(cast keccak "tutorial-refund-$(date +%s)")
Q2="($JOB2,$PAYER,$PROVIDER,$MODEL,10,1000000000000,$EXPIRES,2)"
SIG2=$(cast wallet sign "$(cast call "$ESCROW" "quoteHash($QT)(bytes32)" "$Q2" --rpc-url "$RPC")" \
--keystore "$PROVIDER_KEYSTORE" --password-file "$PROVIDER_PASSWORD_FILE")
cast receipt "$(cast send "$ESCROW" "fund($QT,bytes)" "$Q2" "$SIG2" --value 10000000000000 --async $P)" status --rpc-url "$RPC"
until cast send "$ESCROW" "refundExpired(bytes32)" "$JOB2" $P >/dev/null 2>&1; do sleep 5; done
cast call "$ESCROW" "credits(address)(uint256)" "$PAYER" --rpc-url "$RPC"
Recorded output:
"PQAI-SERVICE-REVENUE/3 inference-only provider=remainder payees=deployment-bps founder=0 training=0"
true # fund
true # propose
true # accept
<job tuple: payer, provider, budget 1e15, price 1e12, maxTokens 1000, charge 6.4e14, deadline, receipt, state 5=Settled>
640000000000000 [6.4e14] # provider credit = 640 tokens × 1e12 wei
360000000000000 [3.6e14] # payer credit = unused budget
true # provider withdraw
true # payer withdraw
true # refund job funded
10000000000000 [1e13] # payer credit after refundExpired
Integrating from an application¶
Wait for BFT finality of the Settled transaction before treating a payment as irreversible
(finality). Index Funded, Proposed, Changed and Paid events, or read the
explorer's /api/v1/ai/events.
Errors (revert reasons)¶
| Reason | Meaning |
|---|---|
job reused |
Job id already funded |
identity |
Sender is not the quote's payer, or provider/model is zero |
expired |
Quote expired or service window outside 1..100000 blocks |
budget |
msg.value ≠ maxTokens × pricePerToken |
untrusted quote / noncanonical / signature |
Signature not by the provider, high-s, or wrong length |
usage |
Receipt reused, zero, or tokens > maxTokens |
state / late |
Call not allowed in the current state or after the deadline |
not expired |
refundExpired before the deadline |
empty |
Nothing to withdraw |
Sources at 72057fe3ec: contracts/ai/ServiceEscrow.sol · developers/examples/ai-escrow/escrow-flow.sh
Found a problem? Report a documentation issue · Build testnet-rc1+72057fe3ec