Four rails. One signed receipt.

How PaymentOracle verifies agentic payments across USDC, XRP, RLUSD and EURC

May 9, 2026 · ToolOracle · ~14 min read · x402 PaymentOracle Base XRPL ES256K

An autonomous agent paid us 1.272801 EURC last week. The agent received an ES256K-signed receipt back. The receipt is verifiable from any host on the internet, with the same Python snippet that verifies our USDC, XRP, and RLUSD receipts. One signing key. Four payment rails. Three blockchain architectures. Here is how PaymentOracle does it.

4
payment rails
1
signing key
3
chain architectures
ES256K
signature alg

What's in this post

  1. Why receipts matter for agentic payments
  2. The four rails
  3. Anatomy of a signed receipt
  4. Verifying a receipt from any host
  5. Integration: quote → pay → verify
  6. Three architectures, one schema
  7. Replay protection across rails
  8. What's next
  9. FAQ

Why receipts matter for agentic payments

Most agentic payments today produce no proof. The agent submits a transaction, the network confirms it, the agent assumes the call was paid. The receiving service waits for some confirmation depth and then either grants access or doesn't. If anyone — the agent's owner, an auditor, a billing reconciliation script — later wants to ask "was this call actually paid for?", the only answer is "look at the chain and trust our reconciliation logic."

That works at the scale of one human ordering one cup of coffee. It does not work at the scale of an autonomous agent making a thousand tool calls per minute, or two AI infrastructures settling commercial transactions with each other while their humans are asleep.

A signed payment receipt closes that gap. It is a small JSON object that says: this exact tool call, on this exact rail, at this exact time, was paid for by this exact transaction, and we — the verifying party — will sign that statement with a key whose public half is published. Any third party can re-fetch the on-chain transaction, recompute our reasoning, and verify the signature without trusting us at all.

That is what PaymentOracle issues. One signed receipt per verified payment, in a single schema, on a single key, regardless of which chain settled the underlying transfer.

The four rails

PaymentOracle currently verifies payments on four rails:

RailAssetChainTypeStatus
base-usdc-x402USDCBase mainnetERC-20live
xrpl-xrpXRPXRPL mainnetchain-nativelive
xrpl-rlusdRLUSDXRPL mainnetissued asset (IOU)beta
base-eurcEURCBase mainnetERC-20beta

The rails span three different verification architectures:

Each verifier reads only public RPC endpoints. PaymentOracle holds no private keys for the rails it verifies; it observes deliveries, it does not initiate them. Receipts are issued only after the chain has confirmed the transfer.

Anatomy of a signed receipt

Every PaymentOracle receipt carries the same shape. Here is the live EURC receipt from May 9, 2026 — the first verified EURC payment on the system:

// GET https://tooloracle.io/payments/receipt/r_f48be831573596ad3ebba1e04dc45311 { "receipt_id": "r_f48be831573596ad3ebba1e04dc45311", "schema": "oraclenet.payment.receipt.v1", "issued_at": "2026-05-09T08:01:07.183Z", "payment": { "rail": "base-eurc", "asset": "EURC", "asset_ref": "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42", "network": "base", "network_id": "8453", "amount_atomic": "1272801", "amount_human": "1.272801", "from": "0xe846373c1a92b167b4e9cd5d8e4d6b1db9e90ec7", "to": "0x4a4b1f45a00892542ac62562d1f2c62f579e4945", "tx_ref": "0x073e324d61743ef...", "block_or_ledger": "45762012", "block_timestamp": "2026-05-09T07:56:11.000Z", "verifier": "PaymentOracle/0.7.0-base-eurc" }, "receipt_hash": "sha256:1253350e48fdc91edc8c82ab14ab63bbbffe28b470b7c21e2d2264ae378f1632", "signature": { "alg": "ES256K", "kid": "tooloracle-paymentoracle-es256k-1", "sig": "<base64url>" } }

Three things to notice:

  1. The payment object describes the underlying transfer in chain-agnostic terms: rail, asset, addresses, transaction reference, and the verifier that signed off on it. Every rail uses the same field names. The IOU-specific rails (RLUSD today) add an optional issued_asset sub-object with issuer, currency_hex, and the canonical-form marker.
  2. The receipt_hash is sha256 over a canonical JSON of the receipt body (everything except the hash and signature themselves). This is what gets signed.
  3. The signature uses ES256K (ECDSA over secp256k1, sha256). The same algorithm Bitcoin and Ethereum use for transaction signatures. The same key signs all four rails — tooloracle-paymentoracle-es256k-1. The public half is published as a JWK at /.well-known/payment-oracle.json#receipts.public_jwk.
Why ES256K

secp256k1 is the curve everyone in crypto already trusts. Verification libraries exist in every language, in every browser, and in every hardware wallet. We did not want a receipt format that requires a special ceremony to verify.

Verifying a receipt from any host

The point of signing is that you don't have to take our word. Three steps in any language: fetch the JWK, recompute the hash, verify the signature.

# pip install cryptography import json, hashlib, base64, urllib.request from cryptography.hazmat.primitives.asymmetric import ec, utils from cryptography.hazmat.primitives import hashes from cryptography.hazmat.backends import default_backend def canon(o): return json.dumps(o, sort_keys=True, separators=(',', ':'), ensure_ascii=False) # 1. fetch JWK and receipt po = json.load(urllib.request.urlopen("https://tooloracle.io/.well-known/payment-oracle.json")) rcp = json.load(urllib.request.urlopen("https://tooloracle.io/payments/receipt/r_f48be831573596ad3ebba1e04dc45311")) jwk = po["receipts"]["public_jwk"] r = rcp["receipt"] # 2. recompute hash body = {k: v for k, v in r.items() if k not in ("receipt_hash", "signature")} assert "sha256:" + hashlib.sha256(canon(body).encode()).hexdigest() == r["receipt_hash"] # 3. verify ES256K signature sig = base64.urlsafe_b64decode(r["signature"]["sig"] + "==") x = int.from_bytes(base64.urlsafe_b64decode(jwk["x"] + "=="), "big") y = int.from_bytes(base64.urlsafe_b64decode(jwk["y"] + "=="), "big") pub = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256K1()).public_key(default_backend()) so = dict(r); so["signature"] = {"alg": r["signature"]["alg"], "kid": r["signature"]["kid"]} der = utils.encode_dss_signature(int.from_bytes(sig[:32], "big"), int.from_bytes(sig[32:], "big")) pub.verify(der, canon(so).encode(), ec.ECDSA(hashes.SHA256())) print("verified")

That snippet works against any of our four receipts. Swap the receipt id, change nothing else. The same canonicalisation rule, the same hashing rule, the same signature verification — regardless of whether the underlying payment was USDC on Base, XRP on the XRPL, RLUSD as an IOU, or EURC on Base.

Integration: quote → pay → verify

For an agent integrating PaymentOracle, the loop is three HTTP calls.

1. Quote

The agent tells PaymentOracle which rail it prefers and what tool call it wants to make. PaymentOracle replies with an intent_hash, the exact amount, the destination wallet for that chain, and an expiry.

curl -s https://tooloracle.io/payments/quote \ -H 'content-type: application/json' \ -d '{"product":"rank","tool":"keyword_research", "tool_args":{"keyword":"hello"}, "agent_did":"did:web:my-agent.example", "preferred_rails":["base-eurc"]}'

2. Pay on-chain

The agent's wallet sends the asset to the quoted destination. For Base rails this is an ERC-20 transfer of USDC or EURC. For XRPL native this is a Payment with the intent hash in a Memo. For RLUSD this is the same Payment shape, but with the issued-asset object as the amount. The agent does whatever its wallet does — PaymentOracle does not need to be in the path.

3. Verify and collect the receipt

The agent submits the on-chain transaction hash together with the intent hash. PaymentOracle reads the chain, confirms the delivery shape (right asset, right destination, right minimum amount, right time window), and writes a verification row. Then a second call exchanges that verification id for the signed receipt.

curl -s https://tooloracle.io/payments/verify \ -H 'content-type: application/json' \ -d '{"intent_hash":"sha256:...", "payload":{"tx_hash":"0x..."}}' curl -s https://tooloracle.io/payments/receipt \ -H 'content-type: application/json' \ -d '{"verification_id":"ver_..."}'

Receipt issuance is idempotent. Calling it twice with the same verification id returns the same receipt, byte-identical, including the signature. Receipts never change after they are signed.

Three architectures, one schema

The interesting design problem with multi-rail payments is not the verification of any single rail. EVM Transfer events, XRPL tx RPC calls, and SPL token balance changes are all well-documented. The interesting problem is keeping the receipt schema honest across rails so that downstream consumers can write a single verifier and be done.

Three concrete cases shaped the v1 schema:

USDC and EURC on Base. Same chain, same standard, same transfer event shape. The verifier is shared at the architecture level. Only the canonical token contract address differs (0x833589... for USDC, 0x60a3E3... for EURC). About 80% code reuse from the moment USDC was implemented.

XRP on XRPL. Different chain, different RPC, different finality model. Drops as the atomic unit. meta.delivered_amount is a string of drops, not an object. Memos are read from tx.Memos and decoded from hex to UTF-8 to match the intent hash. Block timestamps are returned in Ripple epoch and need a constant added to convert to Unix time.

RLUSD on XRPL. Same chain as XRP, completely different asset model. RLUSD is an issued asset that travels along trustlines between holders and the issuer. The verifier requires meta.delivered_amount to be an object, with the currency in canonical 160-bit hex form (no ASCII fallback) and the issuer address matching Ripple Labs exactly. The amount string must match a strict decimal regex — rejecting exponent notation, leading zeros, and excessive precision rather than silently truncating.

Hardening: tfPartialPayment

RLUSD payments can in principle be sent with the tfPartialPayment flag, which decouples the delivered amount from the requested amount. PaymentOracle reads only delivered_amount — never the optimistic tx.Amount — and persists the partial-payment flag in the verification row. The flag's status is exposed in the receipt's payment.partial_payment field so downstream consumers can refuse partial payments if they want to.

Despite the architectural distance, the public receipt looks the same. Same key, same signature algorithm, same canonicalisation, same JSON field names where they apply. The only rail-specific twist is the optional issued_asset sub-object that appears for RLUSD and would appear for any future IOU-style rail.

Replay protection across rails

One of the easier mistakes in multi-rail payment systems is letting the same on-chain transaction be claimed twice — once for one intent, once for another. PaymentOracle's defense is a single UNIQUE INDEX in the verifications table, scoped to verified status:

CREATE UNIQUE INDEX idx_verifications_tx_ref_verified ON verifications(tx_ref) WHERE status = 'verified';

The constraint is unconditional. A given transaction hash, lowercased, can be the basis of one and only one verified row across the entire system. If an agent tries to verify a USDC tx hash that was already used for a previous USDC payment, it gets tx_already_used. If it submits the same hash against a freshly-quoted EURC intent, same answer. If it tries to claim an XRPL tx that was already verified for someone else, same answer. There is no per-rail loophole.

This was tested in production while integrating EURC. A previously-used USDC transaction hash was submitted against a fresh EURC intent and was correctly rejected as tx_already_used — not no_matching_eurc_transfer, which would also have been correct but less informative. The replay-protection layer fires before the rail-specific verification logic runs.

What's next

Four rails is enough to start. Two stablecoins (USDC, EURC), one chain-native asset (XRP), one issued asset (RLUSD). Two chains, three architectures, one signing key, one schema.

The natural next addition is a fifth rail on Solana — a fourth architecture, since SPL tokens differ from both EVM and XRPL in how transfers are recorded and how recipient accounts are derived. But that work waits for distribution to catch up. The bottleneck for agentic payment infrastructure right now is not how many rails exist; it is how many agents and builders know that any of them work end-to-end.

If you build agents that need to pay for tool calls or sell access to your own tools for payment, PaymentOracle is live, all four rails are reachable today, and every receipt we have ever issued is publicly fetchable.

See PaymentOracle live

The landing page has the full rails table, the live receipt JSON, the verify snippet, and every endpoint with a curl example.

Open PaymentOracle Latest signed proof

FAQ

Is PaymentOracle a custodian?

No. PaymentOracle observes on-chain transfers to a single receiving wallet per chain and signs receipts for the deliveries it sees. It does not hold or move agent funds; the agent's wallet sends the asset directly. The XRPL receiving account exists only to receive payments; its signing seed is filesystem-protected and never read by the verifier process.

What happens if PaymentOracle goes down?

Already-issued receipts remain verifiable forever, because verification is offline. Anyone with a copy of the receipt and a copy of our published JWK can re-verify the signature without reaching us. The on-chain transaction also remains queryable independently. The only thing PaymentOracle being down would block is the issuance of new receipts.

Why ES256K and not Ed25519?

Both are reasonable. We picked ES256K because secp256k1 verification libraries already exist in every wallet, every browser, every chain SDK, and most server runtimes. The cost of a slightly more verbose signing flow is more than offset by the fact that anyone integrating crypto wallets already has the verification primitives sitting in their dependency tree.

Can I get a receipt for a payment that was sent before I had an intent?

No. Verification requires both the on-chain transaction hash and an intent hash that was previously quoted. The verifier checks that the chain's block timestamp falls inside the intent's validity window (with a five-minute grace on either side). This is what stops post-hoc claiming of unrelated transactions.

Are quote prices fixed?

For now, yes. One unit equals 0.01 of the rail's asset — 0.01 USDC, 0.01 EURC, the equivalent in drops or RLUSD. This is deliberately FX-independent. Production pricing tied to a foreign-exchange oracle is a separate workstream that does not affect the receipt schema.


Related reading