Skip to content
OpenSouk

Signer tools

The fourteen signing and transaction tools

Fourteen of the twenty-three tools on opensouk-signer, the server that runs on your machine over stdio and holds the private key. The other nine act as a merchant and are on Merchant tools.

Eight of these only sign: they take an EIP-712 commitment, return a signature, and touch no network at all. One more, sign_agent_wallet_set, signs the consent that lets an identity's owner bind this wallet. Five act, each sending a transaction from your key and spending its gas — the escrow bind, the settle fallback, the on-chain review publish and edit, and pay_for_product, which drives a whole purchase and decodes the ref link locally rather than asking us to resolve it.

Open source at cmd/signer in the agent-mcp repository if you would rather build it yourself or verify the code.

Configuration

VariableNeeded byNotes
SIGNER_BACKENDevery toollocal (default), kms, turnkey, privy or cdp
SIGNER_ENV_FILEevery toolOptional. A file of KEY=value settings read at startup, by default ~/.config/opensouk/signer.env. A value the MCP client sets wins. Refused if other users can read it
AGENT_KEYSevery toolLocal backend only. Comma-separated agentId:0xkey. Validated at boot
AGENT_PRIVATE_KEYevery toolLocal backend only. Single-key fallback for any agent id with no AGENT_KEYS entry. Leave it unset once AGENT_KEYS lists your agents, unless you make agent-less purchases
AGENT_WALLETSevery toolkms, turnkey, privy and cdp. Comma-separated agentId:ref, * for the fallback. The ref is a KMS key id or ARN, a Turnkey wallet account address, a Privy wallet id, or a CDP wallet address
TURNKEY_ORG_ID, TURNKEY_API_PUBLIC_KEY, TURNKEY_API_PRIVATE_KEYevery toolturnkey only. Your organization, and the API key pair (hex P-256) of the user the signer acts as
PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_AUTHORIZATION_KEYevery toolprivy only. Your app's credentials, and the signer's authorization key (wallet-auth:…)
CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRETevery toolcdp only. An Ed25519 Secret API key (a PEM key is refused) and your Wallet Secret. CDP_API_BASE_URL is optional
BASE_RPC_URLthe five transaction tools, sign_agent_wallet_set, and the six on-chain merchant toolsMust point at the chain the tool will use
CHAIN_IDthe same toolsDefaults to Base Sepolia when unset, so set it. Overridden per call by chain_id where the tool accepts one
REVIEW_REGISTRY_ADDRESSpublish_escrow_review, update_review_content_onchain, the seven sign_* toolsOptional override; resolved from the ProtocolAddressRegistry when unset
COMMISSION_ESCROW_ADDRESSpublish_escrow_review, bind_escrow, trigger_settle_fallbackOptional override; resolved from the ProtocolAddressRegistry when unset
REFERRER_AGENT_MCP_URLpay_for_product with inline votesPoints at the opensouk-agent MCP endpoint
MERCHANT_REGISTRY_ADDRESSactivate_merchant, grant_merchant_role, revoke_merchant_roleOptional override; resolved from the ProtocolAddressRegistry when unset
PRODUCT_REGISTRY_ADDRESSadd_product, set_product_commission, set_product_activeOptional override; resolved from the ProtocolAddressRegistry when unset
AGENT_REGISTRY_ADDRESSsign_agent_wallet_setOptional override; resolved from the ProtocolAddressRegistry when unset
REFERRER_API_URLactivate_merchant, add_product, set_merchant_profile, check_product_readiness, get_merchant_stats, publish_escrow_review, update_review_content_onchainThe API's base URL
PROTOCOL_ADDRESS_REGISTRYthe transaction tools and the seven sign_* toolsOverrides the built-in registry address for the chain. Only Base Sepolia has one built in, so set it on Base mainnet
ATTESTATION_ORIGINpay_for_productThe API origin the attestation manifest and product lookups come from. Built in for Base mainnet and Base Sepolia; a local stack sets it to its own backend
MAX_PAYMENT_PER_TXpay_for_productOptional. USDC base units. Refuses a live price above it and makes max_amount required on every call
MERCHANT_ALLOWLISTpay_for_productOptional. Comma-separated merchant ids; any other merchant is refused

With the default local backend, at least one of AGENT_KEYS and AGENT_PRIVATE_KEY must be set or the process exits at boot. Key selection is by agent id: an exact AGENT_KEYS entry first, then the fallback. The fallback answers for any agent id, which is why a wrong-agent transaction is caught by the contract rather than by the key lookup.

Where your key lives

With local, the key sits in the signer's environment, so anything your agent can run on the same machine can read it.

On macOS, the secret settings (AGENT_PRIVATE_KEY, AGENT_KEYS and the Turnkey, Privy and CDP credentials) can be Keychain items instead, under the service opensouk-signer with the setting's name as the account: security add-generic-password -U -s opensouk-signer -a AGENT_PRIVATE_KEY -w. The signer reads them through the security command, so your agent can read them the same way.

With kms, the key is an AWS KMS key of type ECC_SECG_P256K1 and never enters the process: the signer asks KMS for each signature, and IAM decides who may ask. The key cannot be copied and access can be revoked, but KMS does not see what is signed: anything holding those AWS credentials can still have it sign anything.

At boot the signer prints the address each KMS key signs as. That address must own the agent identity. Credentials come from the standard AWS SDK chain, and the identity needs kms:GetPublicKey and kms:Sign. The signer refuses to start with AGENT_KEYS or AGENT_PRIVATE_KEY set alongside kms.

With turnkey, privy or cdp, the key stays with that provider, and the provider's policy also decides what is signed. The signer sends each EIP-712 message as typed data and each transaction whole, never a raw hash. The policy allows the eight commitments in their own domains, ReceiveWithAuthorization to the SplitRouter up to a ceiling, AgentWalletSet for the agent ids you name with --agent-id, and the ten contract functions the transaction tools call. Generate it with signer policy --provider turnkey, privy or cdp; applying it is covered in Set up a key provider.

You create the wallet in your own provider account; OpenSouk never holds a provider credential. The policy holds only if the signer cannot change it: on Turnkey the API key must belong to a non-root user, and on Privy the signer's key must be an additional signer, not the wallet's owner. On CDP the API key must have neither the Export nor the Manage scope, and the project policy must not accept anything. The signer refuses to start otherwise. signer privy-setup sets a Privy wallet up this way.

Under turnkey and cdp, the merchant tools that call the API (activate_merchant, add_product, update_product, set_merchant_profile, check_product_readiness and get_merchant_stats) are unavailable: they sign their requests with a personal message, which neither provider's policy can inspect. Under privy they work, and the policy signs a personal message only if it is binary, as theirs is. Do not make a Privy wallet a Safe owner or a signer for any contract that checks personal-message signatures.

A wallet bound to an agent identity is also where that agent's payouts go, so a provider wallet bound to an identity receives its earnings until the owner rebinds it.

Set up a key provider

Each provider's wallet becomes your identity's agent wallet; the identity stays with your owner wallet. OpenSouk never holds a provider credential. Pick one, follow its steps, then finish the setup.

The policy command needs the USDC address on Base, from Circle's list. --max-payment is the largest single payment in USDC base units (5000000 is 5 USDC), and --agent-id names the agents this wallet may be bound to.

Turnkey

On Turnkey the merchant tools that call the OpenSouk API are unavailable, and the free plan allows 25 signatures a month.

  1. Sign up at app.turnkey.com. You are your organization's root user. Copy the organization ID from the user menu.

  2. Create a wallet with one Ethereum account. Copy its address exactly as shown, capital letters included: Turnkey compares addresses case-sensitively.

  3. Create the signer's user. It must not be root, because a root user bypasses every policy. Generate its key pair, then add the user with an API key holding the public key:

    openssl ecparam -name prime256v1 -genkey -noout -out turnkey-api.pem && chmod 600 turnkey-api.pem
    # Public key: paste it into Turnkey
    openssl ec -in turnkey-api.pem -pubout -conv_form compressed -outform DER 2>/dev/null | tail -c 33 | xxd -p -c 33
    # Private key: goes into signer.env
    openssl ec -in turnkey-api.pem -outform DER 2>/dev/null | head -c 39 | tail -c 32 | xxd -p -c 32
  4. Generate the policy, then, as the root user, create one Turnkey policy for each entry in policies, with its effect, consensus and condition:

    CHAIN_ID=8453 BASE_RPC_URL=https://mainnet.base.org npx -y @opensouk/signer@0.1.3 policy \
      --provider turnkey --usdc <USDC address on Base> --max-payment 5000000 \
      --turnkey-user-id <user ID> --wallet <wallet address> --agent-id <your agent id> \
    > turnkey-policies.json
  5. In ~/.config/opensouk/signer.env, replace AGENT_PRIVATE_KEY with:

    SIGNER_BACKEND=turnkey
    AGENT_WALLETS=<your agent id>:<wallet address>
    TURNKEY_ORG_ID=<organization ID>
    TURNKEY_API_PUBLIC_KEY=<public key>
    TURNKEY_API_PRIVATE_KEY=<private key>
Privy
  1. Sign up at dashboard.privy.io and create an app. Copy the App ID and App secret from Configuration > App settings > Basics.

  2. In Wallets > Authorization keys, click New key twice: one key for the owner, one for the signer. Copy each private key (wallet-auth:…) and its ID. The owner key can change the wallet's policy, so keep it off your agent's machine once the wallet exists.

  3. Generate the policy:

    CHAIN_ID=8453 BASE_RPC_URL=https://mainnet.base.org npx -y @opensouk/signer@0.1.3 policy \
      --provider privy --usdc <USDC address on Base> --max-payment 5000000 \
      --agent-id <your agent id> > privy-policy.json
  4. Create the policy and the wallet, owned by the owner key, with the signer key added under the policy. Paste the App secret and then the owner key when prompted:

    read -rs PRIVY_APP_SECRET; read -rs PRIVY_OWNER_AUTHORIZATION_KEY
    PRIVY_APP_ID=<App ID> PRIVY_APP_SECRET="$PRIVY_APP_SECRET" \
    PRIVY_OWNER_AUTHORIZATION_KEY="$PRIVY_OWNER_AUTHORIZATION_KEY" \
      npx -y @opensouk/signer@0.1.3 privy-setup --owner-id <owner key ID> \
      --signer-id <signer key ID> --policy privy-policy.json

    It prints the wallet's address and an AGENT_WALLETS=… line.

  5. In ~/.config/opensouk/signer.env, replace AGENT_PRIVATE_KEY with:

    SIGNER_BACKEND=privy
    AGENT_WALLETS=<the line from step 4>
    PRIVY_APP_ID=<App ID>
    PRIVY_APP_SECRET=<App secret>
    PRIVY_AUTHORIZATION_KEY=<signer key>
AWS KMS

KMS does not check what it signs: anything holding the signer's AWS credentials can sign anything with the key.

  1. In KMS > Customer managed keys, choose Create key: Asymmetric, Sign and verify, key spec ECC_SECG_P256K1. Copy the key's ARN.

  2. Create an IAM user or role for the signer with only this policy, and set up its credentials, for example with aws configure --profile opensouk-signer:

    {
      "Version": "2012-10-17",
      "Statement": [
        { "Effect": "Allow", "Action": ["kms:GetPublicKey", "kms:Sign"], "Resource": "<key ARN>" }
      ]
    }
  3. In ~/.config/opensouk/signer.env, replace AGENT_PRIVATE_KEY with:

    SIGNER_BACKEND=kms
    AGENT_WALLETS=<your agent id>:<key ARN>
    AWS_PROFILE=opensouk-signer
    AWS_REGION=<the key's region>
Coinbase CDP

Needs signer v0.1.3 or later. CDP wallets are EOA server wallets only, and the merchant tools that call the OpenSouk API are unavailable.

  1. Set up a CDP Server Wallet with one EVM account and a Wallet Secret — see CDP's Server Wallet docs.

  2. Create a Secret API key of type Ed25519, with neither the Export nor the Manage scope, and an IP allowlist. Keep any key that can manage policies off your agent's machine.

  3. Generate the policy:

    CHAIN_ID=8453 BASE_RPC_URL=https://mainnet.base.org npx -y @opensouk/signer@0.1.3 policy \
      --provider cdp --usdc <USDC address on Base> --max-payment 5000000 \
      --agent-id <your agent id> > cdp-policy.json
  4. Under Server Wallet > Policies, create a policy with scope account, paste the JSON unchanged, and assign it to the account. Keep the project policy empty or reject-only, and do not trim the policy's reject rules: CDP allows anything no rule names.

  5. In ~/.config/opensouk/signer.env, replace AGENT_PRIVATE_KEY with:

    SIGNER_BACKEND=cdp
    AGENT_WALLETS=<your agent id>:<account address>
    CDP_API_KEY_ID=<API key ID>
    CDP_API_KEY_SECRET=<API key secret>
    CDP_WALLET_SECRET=<Wallet Secret>

At start the signer asks CDP's policy simulator whether five off-policy requests would be rejected, and lists your project policies. It refuses to start if anything would be accepted.

Finish the setup

  1. Restart your agent so the signer reloads. It refuses to start with AGENT_PRIVATE_KEY or AGENT_KEYS still set.
  2. Bind the wallet to your identity. Ask your agent to call sign_agent_wallet_set with your agent id. Within five minutes, send the setAgentWallet(agent_id, new_wallet, deadline, signature) transaction with the values it returns to identity_registry, from your owner wallet. From then on the wallet receives the identity's payouts.
  3. Fund the wallet with ETH on Base for gas, and USDC if your agent buys.
  4. Check it works: npx -y @opensouk/signer@0.1.3 < /dev/null prints opensouk-signer: agent <id> signs as 0x… (…) and exits.

The seven commitment-object signing tools

Seven of the eight signing tools take the same shape — one commitment object, passed exactly as the opensouk-agent step-1 response returned it, including its types and primaryType — and all seven return the same shape: a single signature, 0x-prefixed 65-byte hex, ready to pass back as agent_sig. sign_charge_commitment is the eighth and is the exception on both counts; it has its own section below.

Do not strip or reshape the object. The signer recomputes the digest from domain and message and ignores types/primaryType, but it must still accept them, and the key it signs with is selected from message.agentId.

Returns, all seven

FieldNotes
signature0x-prefixed 65-byte hex. Pass it as agent_sig in the caller's step 2

Errors, all seven

MessageCause
no key for agent N — not in AGENT_KEYS and no AGENT_PRIVATE_KEY fallbackNo key resolves for message.agentId
compute digest: invalid agentId: "…"A message field is not the decimal or hex form the type expects
compute digest: invalid expiry: "…"expiry is not a decimal string
compute digest: invalid nonce: must be 0x-prefixed 32-byte hexA 32-byte-nonce commitment given a decimal nonce
compute digest: invalid nonce: "…"A decimal-nonce commitment given hex
compute digest: invalid productId / invalid contentHash / invalid votesHashA bytes32 message field is malformed
compute digest: cashbackOfCommissionBps out of uint16 range: NRate above 65535
sign: …The key rejected the digest

It does not verify signatures. It refuses a commitment whose EIP-712 domain is not this signer's chain, the commitment's fixed name and version, and the contract the chain's ProtocolAddressRegistry names for it; everything else it signs as handed. The gate is on the receiving end — and it is the identity owner's key that gate wants on all seven, because the backend's step-2 check recovers the signer and compares it against ownerOf. A signature from the bound agent wallet is rejected even where the contract itself would have accepted it; see Identity and commitments.

The message shape differs per commitment, and so does the form of nonce. Each tool below shows the one it must receive. domain is elided in the examples — pass through whatever step 1 returned.

sign_review_commitment

Signs a ReviewCommitment. Call it between submit_admin_approved_review steps 1 and 2.

{ "commitment": { "primaryType": "ReviewCommitment", "types": { }, "domain": { },
  "message": { "agentId": "7", "merchantId": "3", "productId": "0x1f…",
               "contentHash": "0x9c…", "nonce": "834…", "expiry": "1785…" } } }

nonce is a decimal uint256 string here.

sign_vote_commitment

Signs a VoteCommitment. Call it between cast_review_votes steps 1 and 2.

{ "commitment": { "primaryType": "VoteCommitment", "types": { }, "domain": { },
  "message": { "agentId": "7", "escrowId": "88", "votesHash": "0x4b…",
               "nonce": "1290…", "expiry": "1785…" } } }

nonce is a decimal uint256 string here too. votesHash commits to the whole votes array, so it cannot change between the caller's two steps.

sign_cashback_rate_commitment

Signs a CashbackRateCommitment. Call it between set_default_cashback_rate steps 1 and 2.

{ "commitment": { "primaryType": "CashbackRateCommitment", "types": { }, "domain": { },
  "message": { "agentId": "7", "cashbackOfCommissionBps": "1500",
               "nonce": "0x6d…", "expiry": "1785…" } } }

From here down, nonce is 0x-prefixed 32-byte hex, not a decimal.

Signs a TelegramLinkCommitment. Call it between link_telegram steps 1 and 2.

{ "commitment": { "primaryType": "TelegramLinkCommitment", "types": { }, "domain": { },
  "message": { "agentId": "7", "nonce": "0x6d…", "expiry": "1785…" } } }

The caller burns this nonce on use, so a signature is good for exactly one deep link.

sign_intent_access_commitment

Signs an IntentAccessCommitment. Call it between get_pending_intents steps 1 and 2.

{ "commitment": { "primaryType": "IntentAccessCommitment", "types": { }, "domain": { },
  "message": { "agentId": "7", "nonce": "0x6d…", "expiry": "1785…" } } }

This is the one signature that authorises a read, and its nonce is not burned — treat it as a short-lived read token.

sign_intent_mark_commitment

Signs an IntentMarkCommitment. Call it between mark_intent_executed steps 1 and 2.

{ "commitment": { "primaryType": "IntentMarkCommitment", "types": { }, "domain": { },
  "message": { "agentId": "7", "intentId": "itn_9f3c…", "result": "escrow:88",
               "nonce": "0x6d…", "expiry": "1785…" } } }

intentId and result are both inside the signed struct, so this signature authorises exactly one intent recorded with exactly one result. result may be the empty string.

sign_intent_skip_commitment

Signs an IntentSkipCommitment. Call it between mark_intent_skipped steps 1 and 2.

{ "commitment": { "primaryType": "IntentSkipCommitment", "types": { }, "domain": { },
  "message": { "agentId": "7", "intentId": "itn_9f3c…",
               "reason": "live price above the confirmed cap",
               "nonce": "0x6d…", "expiry": "1785…" } } }

Same shape as the mark commitment with reason in place of result — and unlike result, the caller requires it to be non-empty.

sign_charge_commitment

Signs a ChargeCommitment with the buyer wallet that made the charge transfer and owns the buyer agent.

This one takes five explicit arguments rather than a commitment object, because the values come from the payment you just made rather than from a step-1 response. The resulting signature goes to POST /mpp/charge as buyerSignature, not back into an MCP tool.

{ "txHash": "0x…", "attributionToken": "eyJ…", "buyerAgentId": "7" }

…plus chainId (a number) and splitRouterAddr, both required and both rejected at zero.

Parameters

NameTypeNotes
txHashstring0x-prefixed 32-byte charge transaction hash. Becomes the commitment's paymentRef
attributionTokenstringThe base64url signed token, hashed into the commitment
buyerAgentIdstringDecimal. Also selects the signing key
chainIdintegerRequired and non-zero. The Base chain id, for the EIP-712 domain
splitRouterAddrstringRequired and non-zero. The Base SplitRouter address, the domain's verifying contract

The payer address in the commitment is derived from the key, not passed — the charge endpoint requires the recovered signer to equal the on-chain transfer sender.

Returns

FieldNotes
signature0x-prefixed 65-byte hex

Errors

MessageCause
chainId is required and must be nonzero…Zero or omitted. The message names the retired tempoChainId field explicitly
splitRouterAddr is required and must be a nonzero address…Empty or zero. The message names the retired splitRouterMppAddr field
invalid txHashNot 0x-prefixed 32-byte hex
invalid attributionTokenNot base64url
invalid buyerAgentId: "…"Not a decimal string

sign_agent_wallet_set

Signs the consent that lets an identity's owner bind this signer's wallet as the identity's agent wallet. Use it when the wallet, such as a KMS, Turnkey or Privy wallet, does not own the identity.

The owner then sends setAgentWallet(agent_id, new_wallet, deadline, signature) to the IdentityRegistry from the owner wallet, before deadline. This tool cannot send it. A bound wallet receives the identity's payouts.

{ "agent_id": "7" }

Parameters

NameTypeNotes
agent_idstringDecimal. Must be listed in AGENT_KEYS or AGENT_WALLETS itself: the fallback key is never used

Returns

FieldNotes
signature0x-prefixed 65-byte hex, the signature argument of setAgentWallet
new_walletThis signer's wallet for agent_id
ownerownerOf(agent_id), the only sender setAgentWallet accepts
deadlineUnix seconds: chain time plus 300, the latest the contract accepts
identity_registryThe contract to send it to
nextThe call the owner sends, in words

On Turnkey and Privy the policy must name the agent: generate it with signer policy --agent-id <id>.

Errors

MessageCause
invalid agent_id: "…" (want a decimal string)Non-decimal agent_id
no key is configured for agent N itself…Only the fallback key covers agent_id
BASE_RPC_URL env var not setMissing configuration
ownerOf(N) on IdentityRegistry 0x…: …No such identity on this chain
this signer's wallet 0x… owns agent N already…The wallet is the owner: nothing to bind
wallet 0x… is already the agent wallet of agent NAlready bound
sign: … policy_violation (Privy), sign: … HTTP 403 … (Turnkey)The policy does not name agent_id, or is v1

pay_for_product

Completes an x402 buyer payment through a ref link (or a query_registry purchase_link), and optionally casts the buyer's votes in the same call.

It decodes the self-contained ref link locally — verifying its Ed25519 signature against the protocol attestation key — to read the merchant endpoint, the settlement router and the chain straight out of the token. Then it probes the merchant for payment requirements, signs an ERC-3009 authorisation, submits the payment, and waits for the on-chain deposit event.

{ "agent_id": "7", "ref_link": "https://…/r/eyJ…", "max_amount": "50000000",
  "idempotency_key": "itn_9f3c…" }

Parameters

NameTypeNotes
agent_idstringDecimal, optional. Omit to buy agent-less
ref_linkstringRequired. The full ref link URL, including its /r/<token> path
submit_votesarrayOptional. 1 to 3 {review_id, reasoning}, cast after settlement
max_amountstringOptional. USDC base units. The tool refuses if the merchant's live price exceeds it
idempotency_keystringOptional. Derives the ERC-3009 nonce deterministically

max_amount and idempotency_key are the safety rails for an agent-runner executing a confirmed intent. Pass the intent's own approved amount as max_amount, or a price rise between confirmation and execution is authorised silently. idempotency_key makes the payment at-most-once on-chain: the nonce is derived from the payer and the key, so a retry of the same purchase reuses it and the second transfer reverts — pass the intent id.

Returns

FieldNotes
escrow_idDecimal string. Pass it to publish_escrow_review
tx_hashThe settlement transaction
next_stepsAn advisory reminder to vote on the reviews that shaped the purchase
votes_recordedSet when inline votes were submitted successfully
votes_errorSet when inline voting failed after a successful payment

A vote failure never voids the payment. If votes_error is set, the purchase stands and the escrow exists; retry the votes with cast_review_votes using the returned escrow_id.

Omitting agent_id buys agent-less: the buyer wallet is recorded on-chain for a later identity bind, cashback is held until then, and inline voting is refused. bind_escrow is how you claim it afterwards.

Errors

MessageCause
submit_votes: at most 3 votes, got NValidated before paying
submit_votes: reasoning required for review_id NBlank reasoning, validated before paying
submit_votes: reasoning exceeds 280 chars for review_id NValidated before paying
submit_votes requires an agent identity; an agent-less purchase (empty agent_id) cannot vote…Votes passed with no agent_id
BASE_RPC_URL env var not setMissing configuration
invalid ref_link / ref_link has no /r/<token> path: …Malformed link
ref_link is for chain A, but this signer is configured for chain BChain mismatch against CHAIN_ID
attribution token is for chain A, but this signer is configured for chain BThe decoded token disagrees with CHAIN_ID
fetch attestation keyThe attestation key could not be fetched to verify the token
live price N exceeds approved max_amount MRefused before signing anything
phase 1: expected 402, got N: …The merchant did not answer with payment requirements
payment requirements: no accepted payment methodsThe 402 offered nothing usable
phase 2: expected 200, got N: …The merchant rejected the payment
phase 2 response missing 'transaction' field: …The merchant's response had no transaction hash
tx reverted: 0x…The settlement transaction failed
EscrowDeposited event not found in receiptSettled, but no escrow was created

publish_escrow_review

Publishes a buyer review on-chain using an escrow purchase as proof.

Call it after a purchase is confirmed, then sync it with register_purchase_review. Unlike the other three contract-call tools it takes no chain_id argument — it reads CHAIN_ID and cannot be overridden per call.

{ "agent_id": "7", "merchant_id": "3", "product_id": "0x1f…",
  "content_json": "{\"schema_version\":\"x402-review/1\",\"core\":{…},\"open_review\":{…}}", "escrow_id": "88" }

Parameters

NameTypeNotes
agent_idstringDecimal. Also selects the signing key
merchant_idstringDecimal. Cross-checked against the escrow
product_idstring0x-prefixed bytes32. Cross-checked against the escrow
content_jsonstringThe raw JSON string that will be hashed
escrow_idstringDecimal. The escrow that proves the purchase

Before hashing, the tool sends content_json to /v1/review/validate as this agent, and sends nothing if the backend would refuse the review.

The content hash is computed from the merchant and product ids read off the escrow, not from the ones you passed — the contract stores the escrow's values, so a hash over anything else could never be reproduced by a verifier.

Returns

FieldNotes
review_idDecimal string. The on-chain id. Pass it to register_purchase_review
tx_hashThe publication transaction

Errors

MessageCause
no key for agent N — not in AGENT_KEYS and no AGENT_PRIVATE_KEY fallbackNo key for agent_id
REVIEW_REGISTRY_ADDRESS env var not set / COMMISSION_ESCROW_ADDRESS env var not setMissing configuration
invalid agent_id / invalid merchant_id / invalid escrow_idNot a decimal string
invalid product_id: must be 0x-prefixed 32-byte hexMalformed product_id
<rule error> — the backend would refuse this review, so nothing was signed or sent. …content_json fails the review checks. The rule error is the backend's, as in submit_admin_approved_review
could not check content_json against POST /v1/review/validate …, so nothing was sentThe backend could not be reached or answered with an error. Nothing was sent
merchant_id mismatch: supplied A but escrow E has merchantId BYour id disagrees with the escrow
product_id mismatch: supplied A but escrow E has productId 0x…Your id disagrees with the escrow
publishReviewWithEscrowProof tx: …The contract refused at estimate
tx reverted: 0x…The transaction failed on-chain
ReviewPublished event not found in receiptMined, but no review id to return

bind_escrow

Binds an agent-less purchase's escrow to your ERC-8004 agent identity.

It unlocks the deferred cashback and enables the deferred review. Two preconditions, both on the key: the signing key must be the exact wallet that paid the agent-less purchase, and it must own agent_id or be its agent wallet.

{ "agent_id": "7", "escrow_id": "88" }

Parameters

NameTypeNotes
agent_idstringDecimal. Also selects the signing key
escrow_idstringDecimal. From pay_for_product or list_escrows
chain_idstringOptional decimal string. Overrides CHAIN_ID

Returns

FieldNotes
tx_hashThe bind transaction
escrow_id, agent_idEchoed
bound_byThe signer address, which is what the contract records

Binding is one-shot. Afterwards the backend rebinds the purchase automatically, and cast_review_votes, publish_escrow_review and register_purchase_review all become available.

Errors

MessageCause
invalid escrow_id: "…" (want a decimal string)Non-decimal escrow_id, before anything is dialled
invalid agent_id: "…" (want a decimal string)Non-decimal agent_id
no key for agent N — not in AGENT_KEYS and no AGENT_PRIVATE_KEY fallbackNo key for agent_id
COMMISSION_ESCROW_ADDRESS env var not setMissing configuration
bind would revert: the signer key is not the wallet that paid for this escrow…NotBuyerWallet, decoded before gas is spent
bind would revert: the signer wallet is neither the owner of agent_id nor its agent wallet…NotAgentController
bind would revert: this escrow is already bound to an agent (bind is one-shot)AlreadyBound
bind would revert: this escrow is already settled — too late to bindAlreadySettled
bind would revert: no escrow found for that escrow_idEscrowNotFound
bind tx: … (check: is the signer key the wallet that paid this escrow, does it own agent_id or is it its agent wallet, and is the escrow still unbound?)A revert the table could not decode
bind tx reverted: 0x… (check: signer wallet paid this escrow? owns agent_id or is its agent wallet? escrow not already bound?)Estimated, sent, and failed on-chain

The five decoded refusals cost one estimate and no gas.

trigger_settle_fallback

Force-settles an escrow the oracle never settled.

Permissionless: any agent may call it, on any escrow, once 10 days have passed after that escrow's windowEnd, plus any pause time since deposit. Around day 70.

{ "agent_id": "7", "escrow_id": "88" }

Parameters

NameTypeNotes
agent_idstringDecimal. Selects the signing key; it does not have to be a party to the escrow
escrow_idstringDecimal
chain_idstringOptional decimal string. Overrides CHAIN_ID

Returns

FieldNotes
tx_hashThe settle transaction
escrow_idEchoed
settled_byThe signer address
held_back_to_treasuryDecimal string, read back from the settle event. Empty if the event was absent
cashback_to_reviewerDecimal string, same source

On checking eligibility first. get_escrow_status reports fallback_available. This tool reads windowEnd itself and refuses with the real date, so calling it and reading a refusal costs one RPC call and no gas.

Errors

MessageCause
invalid escrow_id: "…" (want a decimal string)Non-decimal escrow_id, before anything is dialled
invalid agent_id: "…" (want a decimal string)Non-decimal agent_id
no key for agent N — not in AGENT_KEYS and no AGENT_PRIVATE_KEY fallbackNo key for agent_id
COMMISSION_ESCROW_ADDRESS env var not setMissing configuration
escrow N is already settled — the oracle got there first, nothing is stuckThe preflight read, before any estimate
escrow N is not fallback-eligible yet: the public fallback opens at …The preflight, quoting the real date derived from the chain's own windowEnd
triggerSettleFallback would revert: CommissionEscrow is paused by the protocol admin — no settlement of any kind can run until it is unpausedEnforcedPause
triggerSettleFallback would revert: no escrow found for that escrow_idEscrowNotFound
triggerSettleFallback would revert: the public fallback is not open yet — it opens 10 days after the escrow's windowEnd, plus any protocol-pause time since deposit (day ~70 or later)TooEarly, the contract's own version of the preflight refusal
triggerSettleFallback would revert: this escrow is already settled — the oracle got there first, nothing is stuckAlreadySettled
triggerSettleFallback tx reverted: 0x…Estimated, sent, and failed on-chain

update_review_content_onchain

Edits a buyer review's content hash on-chain, inside its 7-day window.

First of two calls: this one updates only the hash, and you must then call update_review_content on opensouk-agent with the same review_id and the same content_json to sync the stored text and re-score alignment.

{ "review_id": "412", "content_json": "{\"schema_version\":\"x402-review/1\",\"core\":{…},\"open_review\":{…}}" }

Parameters

NameTypeNotes
review_idstringDecimal. The on-chain id, not the internal one
content_jsonstringThe full replacement content
chain_idstringOptional decimal string. Overrides CHAIN_ID

Do not pass agent, merchant or product ids: the tool reads the review from the chain, both to build the hash from the authoritative ids and to pick the signing key. A hash built over any other ids would be accepted by the contract and then permanently un-syncable, with the window running down.

Returns

FieldNotes
tx_hashThe edit transaction
review_idEchoed
content_hash0x-prefixed. What the contract now stores
agent_idThe on-chain reviewer id whose key signed
edited_atUnix seconds, from the edit event. Absent if the event was not found
next_stepsThe required follow-up call, spelled out

Before hashing, the tool sends the replacement to /v1/review/validate as the reviewer agent, and sends nothing if update_review_content would refuse it.

Errors

MessageCause
invalid review_id: "…" (want a decimal string — the ON-CHAIN review id)Malformed, or the internal id was passed
content_json is required / content_json is not valid JSONPreflight, before any transaction
<rule error> — the backend would refuse this review, so nothing was signed or sent. …The replacement fails the review checks
could not check content_json against POST /v1/review/validate …, so nothing was sentThe backend could not be reached or answered with an error
review N has proofType P — only a buyer review published with an escrow proof … is editableWrong proof type, caught in the preflight read
the 7-day edit window for review N closed at …Preflight, with the real date
content_json hashes to the value already stored on-chain for review N — nothing to updateNo transaction was sent. If your earlier run succeeded, the on-chain half is done: call update_review_content
ReviewNotFoundNo review at that on-chain id
NotEditablePathThe contract's own version of the wrong-proof-type refusal
CallerNotAgentOwnerThe signer key is neither the reviewer agent's owner nor its registered agent wallet
EditWindowClosedThe contract's own version of the closed-window refusal

Next steps