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
| Variable | Needed by | Notes |
|---|---|---|
SIGNER_BACKEND | every tool | local (default), kms, turnkey, privy or cdp |
SIGNER_ENV_FILE | every tool | Optional. 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_KEYS | every tool | Local backend only. Comma-separated agentId:0xkey. Validated at boot |
AGENT_PRIVATE_KEY | every tool | Local 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_WALLETS | every tool | kms, 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_KEY | every tool | turnkey 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_KEY | every tool | privy only. Your app's credentials, and the signer's authorization key (wallet-auth:…) |
CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET | every tool | cdp only. An Ed25519 Secret API key (a PEM key is refused) and your Wallet Secret. CDP_API_BASE_URL is optional |
BASE_RPC_URL | the five transaction tools, sign_agent_wallet_set, and the six on-chain merchant tools | Must point at the chain the tool will use |
CHAIN_ID | the same tools | Defaults to Base Sepolia when unset, so set it. Overridden per call by chain_id where the tool accepts one |
REVIEW_REGISTRY_ADDRESS | publish_escrow_review, update_review_content_onchain, the seven sign_* tools | Optional override; resolved from the ProtocolAddressRegistry when unset |
COMMISSION_ESCROW_ADDRESS | publish_escrow_review, bind_escrow, trigger_settle_fallback | Optional override; resolved from the ProtocolAddressRegistry when unset |
REFERRER_AGENT_MCP_URL | pay_for_product with inline votes | Points at the opensouk-agent MCP endpoint |
MERCHANT_REGISTRY_ADDRESS | activate_merchant, grant_merchant_role, revoke_merchant_role | Optional override; resolved from the ProtocolAddressRegistry when unset |
PRODUCT_REGISTRY_ADDRESS | add_product, set_product_commission, set_product_active | Optional override; resolved from the ProtocolAddressRegistry when unset |
AGENT_REGISTRY_ADDRESS | sign_agent_wallet_set | Optional override; resolved from the ProtocolAddressRegistry when unset |
REFERRER_API_URL | activate_merchant, add_product, set_merchant_profile, check_product_readiness, get_merchant_stats, publish_escrow_review, update_review_content_onchain | The API's base URL |
PROTOCOL_ADDRESS_REGISTRY | the transaction tools and the seven sign_* tools | Overrides the built-in registry address for the chain. Only Base Sepolia has one built in, so set it on Base mainnet |
ATTESTATION_ORIGIN | pay_for_product | The 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_TX | pay_for_product | Optional. USDC base units. Refuses a live price above it and makes max_amount required on every call |
MERCHANT_ALLOWLIST | pay_for_product | Optional. 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.
-
Sign up at app.turnkey.com. You are your organization's root user. Copy the organization ID from the user menu.
-
Create a wallet with one Ethereum account. Copy its address exactly as shown, capital letters included: Turnkey compares addresses case-sensitively.
-
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 -
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 -
In
~/.config/opensouk/signer.env, replaceAGENT_PRIVATE_KEYwith: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
-
Sign up at dashboard.privy.io and create an app. Copy the App ID and App secret from Configuration > App settings > Basics.
-
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. -
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 -
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.jsonIt prints the wallet's address and an
AGENT_WALLETS=…line. -
In
~/.config/opensouk/signer.env, replaceAGENT_PRIVATE_KEYwith: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.
-
In KMS > Customer managed keys, choose Create key: Asymmetric, Sign and verify, key spec ECC_SECG_P256K1. Copy the key's ARN.
-
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>" } ] } -
In
~/.config/opensouk/signer.env, replaceAGENT_PRIVATE_KEYwith: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.
-
Set up a CDP Server Wallet with one EVM account and a Wallet Secret — see CDP's Server Wallet docs.
-
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.
-
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 -
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.
-
In
~/.config/opensouk/signer.env, replaceAGENT_PRIVATE_KEYwith: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
- Restart your agent so the signer reloads. It refuses to start with
AGENT_PRIVATE_KEYorAGENT_KEYSstill set. - Bind the wallet to your identity. Ask your agent to call
sign_agent_wallet_setwith your agent id. Within five minutes, send thesetAgentWallet(agent_id, new_wallet, deadline, signature)transaction with the values it returns toidentity_registry, from your owner wallet. From then on the wallet receives the identity's payouts. - Fund the wallet with ETH on Base for gas, and USDC if your agent buys.
- Check it works:
npx -y @opensouk/signer@0.1.3 < /dev/nullprintsopensouk-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
| Field | Notes |
|---|---|
signature | 0x-prefixed 65-byte hex. Pass it as agent_sig in the caller's step 2 |
Errors, all seven
| Message | Cause |
|---|---|
no key for agent N — not in AGENT_KEYS and no AGENT_PRIVATE_KEY fallback | No 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 hex | A 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 votesHash | A bytes32 message field is malformed |
compute digest: cashbackOfCommissionBps out of uint16 range: N | Rate 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.
sign_telegram_link_commitment
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
| Name | Type | Notes |
|---|---|---|
txHash | string | 0x-prefixed 32-byte charge transaction hash. Becomes the commitment's paymentRef |
attributionToken | string | The base64url signed token, hashed into the commitment |
buyerAgentId | string | Decimal. Also selects the signing key |
chainId | integer | Required and non-zero. The Base chain id, for the EIP-712 domain |
splitRouterAddr | string | Required 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
| Field | Notes |
|---|---|
signature | 0x-prefixed 65-byte hex |
Errors
| Message | Cause |
|---|---|
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 txHash | Not 0x-prefixed 32-byte hex |
invalid attributionToken | Not 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
| Name | Type | Notes |
|---|---|---|
agent_id | string | Decimal. Must be listed in AGENT_KEYS or AGENT_WALLETS itself: the fallback key is never used |
Returns
| Field | Notes |
|---|---|
signature | 0x-prefixed 65-byte hex, the signature argument of setAgentWallet |
new_wallet | This signer's wallet for agent_id |
owner | ownerOf(agent_id), the only sender setAgentWallet accepts |
deadline | Unix seconds: chain time plus 300, the latest the contract accepts |
identity_registry | The contract to send it to |
next | The 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
| Message | Cause |
|---|---|
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 set | Missing 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 N | Already 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
| Name | Type | Notes |
|---|---|---|
agent_id | string | Decimal, optional. Omit to buy agent-less |
ref_link | string | Required. The full ref link URL, including its /r/<token> path |
submit_votes | array | Optional. 1 to 3 {review_id, reasoning}, cast after settlement |
max_amount | string | Optional. USDC base units. The tool refuses if the merchant's live price exceeds it |
idempotency_key | string | Optional. 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
| Field | Notes |
|---|---|
escrow_id | Decimal string. Pass it to publish_escrow_review |
tx_hash | The settlement transaction |
next_steps | An advisory reminder to vote on the reviews that shaped the purchase |
votes_recorded | Set when inline votes were submitted successfully |
votes_error | Set 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
| Message | Cause |
|---|---|
submit_votes: at most 3 votes, got N | Validated before paying |
submit_votes: reasoning required for review_id N | Blank reasoning, validated before paying |
submit_votes: reasoning exceeds 280 chars for review_id N | Validated 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 set | Missing 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 B | Chain mismatch against CHAIN_ID |
attribution token is for chain A, but this signer is configured for chain B | The decoded token disagrees with CHAIN_ID |
fetch attestation key | The attestation key could not be fetched to verify the token |
live price N exceeds approved max_amount M | Refused before signing anything |
phase 1: expected 402, got N: … | The merchant did not answer with payment requirements |
payment requirements: no accepted payment methods | The 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 receipt | Settled, 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
| Name | Type | Notes |
|---|---|---|
agent_id | string | Decimal. Also selects the signing key |
merchant_id | string | Decimal. Cross-checked against the escrow |
product_id | string | 0x-prefixed bytes32. Cross-checked against the escrow |
content_json | string | The raw JSON string that will be hashed |
escrow_id | string | Decimal. 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
| Field | Notes |
|---|---|
review_id | Decimal string. The on-chain id. Pass it to register_purchase_review |
tx_hash | The publication transaction |
Errors
| Message | Cause |
|---|---|
no key for agent N — not in AGENT_KEYS and no AGENT_PRIVATE_KEY fallback | No key for agent_id |
REVIEW_REGISTRY_ADDRESS env var not set / COMMISSION_ESCROW_ADDRESS env var not set | Missing configuration |
invalid agent_id / invalid merchant_id / invalid escrow_id | Not a decimal string |
invalid product_id: must be 0x-prefixed 32-byte hex | Malformed 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 sent | The backend could not be reached or answered with an error. Nothing was sent |
merchant_id mismatch: supplied A but escrow E has merchantId B | Your 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 receipt | Mined, 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
| Name | Type | Notes |
|---|---|---|
agent_id | string | Decimal. Also selects the signing key |
escrow_id | string | Decimal. From pay_for_product or list_escrows |
chain_id | string | Optional decimal string. Overrides CHAIN_ID |
Returns
| Field | Notes |
|---|---|
tx_hash | The bind transaction |
escrow_id, agent_id | Echoed |
bound_by | The 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
| Message | Cause |
|---|---|
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 fallback | No key for agent_id |
COMMISSION_ESCROW_ADDRESS env var not set | Missing 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 bind | AlreadySettled |
bind would revert: no escrow found for that escrow_id | EscrowNotFound |
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
| Name | Type | Notes |
|---|---|---|
agent_id | string | Decimal. Selects the signing key; it does not have to be a party to the escrow |
escrow_id | string | Decimal |
chain_id | string | Optional decimal string. Overrides CHAIN_ID |
Returns
| Field | Notes |
|---|---|
tx_hash | The settle transaction |
escrow_id | Echoed |
settled_by | The signer address |
held_back_to_treasury | Decimal string, read back from the settle event. Empty if the event was absent |
cashback_to_reviewer | Decimal 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
| Message | Cause |
|---|---|
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 fallback | No key for agent_id |
COMMISSION_ESCROW_ADDRESS env var not set | Missing configuration |
escrow N is already settled — the oracle got there first, nothing is stuck | The 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 unpaused | EnforcedPause |
triggerSettleFallback would revert: no escrow found for that escrow_id | EscrowNotFound |
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 stuck | AlreadySettled |
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
| Name | Type | Notes |
|---|---|---|
review_id | string | Decimal. The on-chain id, not the internal one |
content_json | string | The full replacement content |
chain_id | string | Optional 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
| Field | Notes |
|---|---|
tx_hash | The edit transaction |
review_id | Echoed |
content_hash | 0x-prefixed. What the contract now stores |
agent_id | The on-chain reviewer id whose key signed |
edited_at | Unix seconds, from the edit event. Absent if the event was not found |
next_steps | The 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
| Message | Cause |
|---|---|
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 JSON | Preflight, 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 sent | The 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 editable | Wrong 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 update | No transaction was sent. If your earlier run succeeded, the on-chain half is done: call update_review_content |
ReviewNotFound | No review at that on-chain id |
NotEditablePath | The contract's own version of the wrong-proof-type refusal |
CallerNotAgentOwner | The signer key is neither the reviewer agent's owner nor its registered agent wallet |
EditWindowClosed | The contract's own version of the closed-window refusal |
Next steps
- The eight commitments — the eight type strings, byte for byte
- Review tools — the
opensouk-agenthalf of publishing and editing - Payment flow — what the fallback pays, and to whom