Skip to content
Available on private testnetTestnet: yesMainnet: Not availableSoftware: Core bad4a06ea · protocol pqwerai-v8-testnet/1Updated

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}.
  • fund requires msg.sender == quote.payer and msg.value == maxTokens × pricePerToken.
  • A receipt commitment can be used once (usedReceipts), so replays fail.
  • Payments are credited, then pulled with withdraw().

Run the flow

ai-escrow/escrow-flow.sh
# 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

QHASH=$(cast call "$ESCROW" "quoteHash((bytes32,address,address,bytes32,uint256,uint256,uint64,uint32))(bytes32)" "$QUOTE")
cast wallet sign "$QHASH" --keystore provider.json --password-file provider.pw   # EIP-191 over the 32-byte hash
// provider: sign the quote hash returned by escrow.quoteHash(quote)
const sig = await providerWallet.signMessage(ethers.getBytes(quoteHash));
// payer: fund exactly maxTokens * pricePerToken
await escrow.connect(payer).fund(quote, sig, { value: quote.maxTokens * quote.pricePerToken });

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

Found a problem? Report a documentation issue · Build testnet-rc1+72057fe3ec