diff --git a/.gitignore b/.gitignore index 9abd93b..5076fc4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,8 @@ reviews/ # reference-agent private keys written by scripts/seed-reference-agents.ts seed-keys*.json + +# local secrets (pay-on-proof demo keys etc.) +.env +.env.* +!.env.example diff --git a/examples/README.md b/examples/README.md index aac05d1..eef9b6a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -99,6 +99,10 @@ INAM is a receipt layer that works on any payment rail. Every agent checkout tou npx tsx examples/payment-rails/demo.ts ``` +## `pay-on-proof/` + +A buyer agent holds the money (PayPal authorize, or a signed but unsubmitted USDC authorization over x402) and captures only when the seller's INAM receipt matches what was delivered and held; anything else voids the hold. Runnable demo with a UI, no keys needed: `npx tsx examples/pay-on-proof/server.ts`. Details in [`pay-on-proof/README.md`](./pay-on-proof/README.md). + ## `verify-receipt-offline.ts` Checks a receipt's content-addressed id, both Ed25519 signatures and (optionally) an output hash with no registry and no network, in about ten lines (SPEC.md §2, §4.2, §5.4). Walkthrough in [`docs/VERIFIER.md`](../docs/VERIFIER.md). `npx tsx examples/verify-receipt-offline.ts`; test: `tests/verifierExamples.test.ts`, which also runs it against a receipt finalized by an in-process registry. diff --git a/examples/pay-on-proof/.env.example b/examples/pay-on-proof/.env.example new file mode 100644 index 0000000..91c584b --- /dev/null +++ b/examples/pay-on-proof/.env.example @@ -0,0 +1,21 @@ +# Copy to .env in this folder (gitignored) and fill in what you have. Every rail without keys uses a stand-in. + +# Braintree sandbox: Settings > API > API Keys (https://sandbox.braintreegateway.com) +BRAINTREE_PUBLIC_KEY= +BRAINTREE_PRIVATE_KEY= +# Optional: the sandbox merchant account's currency (see Settings > Business > Merchant Accounts) +BRAINTREE_CURRENCY=USD +# BRAINTREE_MERCHANT_ACCOUNT_ID= +# BRAINTREE_NONCE=fake-paypal-one-time-nonce + +# PayPal sandbox: developer.paypal.com > Apps & Credentials > Sandbox +PAYPAL_CLIENT_ID= +PAYPAL_CLIENT_SECRET= + +# USDC over x402 on Base Sepolia (buyer wallet needs testnet USDC; never a mainnet key) +X402_BUYER_PRIVATE_KEY= +X402_SELLER_ADDRESS= + +# AI (optional): either one +GEMINI_API_KEY= +ANTHROPIC_API_KEY= diff --git a/examples/pay-on-proof/README.md b/examples/pay-on-proof/README.md new file mode 100644 index 0000000..393d428 --- /dev/null +++ b/examples/pay-on-proof/README.md @@ -0,0 +1,135 @@ +# Pay on proof + +An AI agent may move money only against a verified INAM receipt. PayPal or Braintree (fiat) or USDC via x402 (crypto), same receipt, same reputation. + +**Verified live on the Braintree sandbox (2026-11-10):** the honest run authorized 12.00 EUR and captured it against a finalized receipt; the swapped-delivery and overcharging runs authorized and then voided (MISMATCH); the unknown seller was refused before Braintree was called. A first run with the wrong currency configured authorized EUR against a USD job and was voided by the hold check (`HOLD_MISMATCH`), which is the guard doing its job. + +An agent that pays up front can be paid for nothing: the seller takes the money and delivers less, something else, or nothing. The usual fix, giving the agent payment credentials and hoping its judgment holds, puts those credentials in the model's context. This demo is for developers wiring agents to real payment rails and for owners who want a budget that an agent can spend but cannot lose. The owner approves a hold once. The money moves only when the seller's signed receipt matches what the buyer actually received and what the rail actually holds. Anything else releases the hold. The receipt is a signed, content-addressed INAM record, so it is portable: anyone can verify it offline, and it becomes part of the seller's reputation, whichever rail carried the money. + +``` + 1. checkTrust(seller) INAM: allow / escrow / deny + | deny -> stop. No payment rail is called. + v + 2. Hold PayPal: Orders v2, intent AUTHORIZE, owner approves once + | Braintree: authorizePaymentMethod (PayPal wallet test nonce) + | USDC: signed EIP-3009 authorization, kept, not submitted + v + 3. Seller delivers + signs INAM receipt naming the delivery hash and the hold + v + 4. Buyer acceptWork(receipt, { outputHash of what arrived, amount and currency held }) + match -> countersign, then capture (PayPal capture / Braintree captureTransaction / x402 /settle) + MISMATCH or NOT_COMPARED -> void (PayPal void / Braintree reverseTransaction / discard authorization) + The payer is never charged. +``` + +## Quick start + +Needs Node.js 22.13 or newer (the in-process registry uses the built-in `node:sqlite`). From the repo root, in any shell (bash, zsh, PowerShell, cmd): + +``` +npm install +npm install --prefix sdk-js +npx tsx examples/pay-on-proof/server.ts +``` + +The second install is needed because the demo imports `sdk-js/src`, which has its own dependencies. Port 8787 taken? Set `PORT` first: `PORT=8790 npx tsx examples/pay-on-proof/server.ts` (macOS/Linux), `$env:PORT=8790; npx tsx examples/pay-on-proof/server.ts` (PowerShell), or `set PORT=8790` then the same command (cmd). + +Open http://localhost:8787. With no keys set, PayPal, Braintree and USDC stand-ins answer with the documented response shapes and every order counts as approved, an in-process INAM registry is started in a temp directory, and the AI notes fall back to fixed text. Nothing touches the production registry or real money. + +Pick the rail with the PayPal / USDC (x402) / Braintree toggle above the scenario buttons (the API takes `POST /api/run?scenario=...&rail=paypal|usdc|braintree`). + +Keys go in `examples/pay-on-proof/.env` (gitignored; copy `.env.example`). The server loads it at start. + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `PAYPAL_CLIENT_ID` | unset | PayPal sandbox app client id. Set both PayPal vars to use the real sandbox instead of the stand-in. | +| `PAYPAL_CLIENT_SECRET` | unset | PayPal sandbox app secret. | +| `PAYPAL_BASE` | `https://api-m.sandbox.paypal.com` | PayPal API base. Leave it on sandbox. | +| `PUBLIC_URL` | `http://localhost:$PORT` | Base for PayPal's `return_url` / `cancel_url`. Must be reachable from the payer's browser. | +| `PORT` | `8787` | Demo server port. | +| `INAM_URL` | unset | Registry to use. Unset starts a throwaway in-process registry. | +| `GEMINI_API_KEY` / `ANTHROPIC_API_KEY` | unset | AI that writes the seller's work and explains each step. Gemini is used if both are set. `GEMINI_MODEL` / `CLAUDE_MODEL` override the model. | +| `X402_BUYER_PRIVATE_KEY` | unset | USDC rail: the buyer wallet's key, used to sign the EIP-3009 authorization. Testnet wallet only. | +| `X402_SELLER_ADDRESS` | unset | USDC rail: the seller's `payTo` address. | +| `X402_FACILITATOR_URL` | unset | USDC rail: the x402 facilitator that verifies and settles the authorization. | +| `BRAINTREE_PUBLIC_KEY` / `BRAINTREE_PRIVATE_KEY` | unset | Braintree sandbox API keys. Set both to use the real sandbox instead of the stand-in. | +| `BRAINTREE_CURRENCY` | `USD` | The sandbox merchant account's currency. A hold in another currency is voided (`HOLD_MISMATCH`). | +| `BRAINTREE_MERCHANT_ACCOUNT_ID` | unset | Merchant account to charge (picks the currency). Default: the account's default. | +| `BRAINTREE_NONCE` | `fake-paypal-one-time-nonce` | Sandbox test nonce for the payment method. The default simulates a PayPal wallet payment. | + +## Braintree sandbox credentials + +Braintree is a PayPal service, and its sandbox signup asks for no tax ID, so it is the PayPal-family sandbox open to developers in countries PayPal does not serve. + +1. Sign up at https://apply.braintreegateway.com/signup/sandbox and activate the account from the email. +2. Settings > API > API Keys: generate a key and copy the Public Key and Private Key into `.env`. +3. Settings > Business > Merchant Accounts: note the currency and set `BRAINTREE_CURRENCY` to it. +4. Start the server, pick Braintree, run a scenario. There is no payer redirect: the test nonce stands in for the PayPal wallet, so hold, capture and void run server-side. Transactions appear under Transactions in the Braintree sandbox dashboard. + +## PayPal sandbox credentials + +1. Sign in at https://developer.paypal.com. +2. Apps & Credentials, switch to Sandbox, Create App (type Merchant). Copy the Client ID and Secret into `PAYPAL_CLIENT_ID` and `PAYPAL_CLIENT_SECRET`. +3. Testing Tools > Sandbox Accounts: use the generated sandbox Personal account (or create one) to approve orders. Its email and password are under the account's details. +4. Start the server, run a scenario, click Approve in the timeline, log in as the sandbox Personal account and approve. PayPal sends you back to `/paypal/return`, and the demo authorizes, waits for delivery, then captures or voids. + +## Scenarios + +| Button | What the seller does | Result | What it proves | +|---|---|---|---| +| Honest seller | Delivers what it signs for | Captured | A matching receipt is the only path to capture. The countersigned receipt is finalized and counts toward the seller's reputation. | +| Swapped delivery | Delivers 40 characters, signs for the full text | `MISMATCH`, voided | The buyer compares the hash of what it received, not what the receipt says about itself. | +| Overcharging receipt | Receipt claims 120.00 on a 12.00 hold | `MISMATCH`, voided | The amount and currency are checked against what the rail holds, not what the seller claims. | +| Unknown seller | Has no INAM record | Refused at step 1 | A deny stops the job before any payment API is called. No hold is created. | + +The same four run on every rail. + +## Security model + +- The gates are plain code in `payOnProof.ts`. The AI writes the seller's summary and a one-sentence note per step. It cannot choose the outcome: capture is reachable only after `acceptWork` returns a countersigned receipt. +- Credentials never enter model context. PayPal and wallet keys are read from the environment by the server. Prompts carry the job and the step event only. +- The buyer passes `acceptWork` the hash of the bytes it received and the amount and currency the rail reported as held. It never passes values taken from the receipt. +- `NOT_COMPARED` voids, the same as `MISMATCH`. If the buyer has no expected hash, or the seller's draft has none, nothing was compared, and absence is not difference: a check that did not run must not count as a pass. `acceptWork` refuses to sign in that case (sdk-js 0.24.0). +- If the hold differs from the job (amount or currency), the delivery fails, or `acceptWork` throws, the hold is voided. +- INAM never moves or holds money. It records the rail's own reference in `settlement.paymentRef` (for PayPal, `paypal:`). + +## Tests + +``` +npx vitest run tests/payOnProof.test.ts +``` + +Runs the four scenarios on each rail against an in-process registry and the rail stand-ins, and asserts which rail endpoints were and were not called. + +## API calls used + +PayPal (`paypal.ts`): + +- `POST /v1/oauth2/token`: client-credentials token +- `POST /v2/checkout/orders`: create the order, `intent: "AUTHORIZE"` +- `POST /v2/checkout/orders/{id}/authorize`: place the hold after approval +- `POST /v2/payments/authorizations/{id}/capture`: capture against a verified receipt +- `POST /v2/payments/authorizations/{id}/void`: release the hold +- `GET /v2/checkout/orders/{id}`: read an order + +Writes send a `PayPal-Request-Id`, so retries are idempotent. + +USDC via x402: the buyer signs an EIP-3009 transfer authorization for the seller's address and keeps it. Capture sends it to the facilitator's settle endpoint. Void discards it; it is never submitted and lapses at its expiry. + +INAM (sdk-js): `checkTrust`, `registerAgent`, `submitWork` (seller drafts and signs), `acceptWork` (buyer compares and countersigns). + +## Limitations + +- The PayPal client was built against PayPal's documented Orders v2 and Payments v2 APIs and has run only against the stand-in in `paypal.ts`, which returns the documented response shapes. It has not been run against the live PayPal sandbox. A sandbox run needs `PAYPAL_CLIENT_ID` and `PAYPAL_CLIENT_SECRET` from a PayPal developer sandbox app. +- PayPal authorizations expire. A job that outlives the authorization would need reauthorization, which the demo does not do. +- The Braintree rail has run against the live Braintree sandbox (see the top of this file). Its payment method is a sandbox test nonce that simulates a PayPal wallet, not a real payer approval. +- The stand-in treats every order as approved. Approval, cancel and payer-side errors are only exercised with real sandbox credentials. +- State lives in memory. Restarting the server loses runs and the in-process registry. +- New sellers get `escrow` from `checkTrust`, not `allow`. Both go through the hold, so the demo never pays up front. +- The USDC rail needs a funded testnet wallet and a facilitator that supports the chosen network. + +## License + +Apache-2.0, like the rest of the repository. See [LICENSE](../../LICENSE). diff --git a/examples/pay-on-proof/ai.ts b/examples/pay-on-proof/ai.ts new file mode 100644 index 0000000..12c5adf --- /dev/null +++ b/examples/pay-on-proof/ai.ts @@ -0,0 +1,68 @@ +import type { Step, Job } from "./payOnProof.js"; + +/** The AI parts of the demo: the seller's actual work (a summary) and a one-line + * explanation of each money decision for the owner. Neither can move money: the + * gates in payOnProof.ts decide. Gemini if GEMINI_API_KEY, else Claude if + * ANTHROPIC_API_KEY, else plain-text fallbacks so the demo still runs. */ + +const GEMINI = process.env.GEMINI_API_KEY; +const ANTHROPIC = process.env.ANTHROPIC_API_KEY; +const GEMINI_MODEL = process.env.GEMINI_MODEL ?? "gemini-2.5-flash"; +const CLAUDE_MODEL = process.env.CLAUDE_MODEL ?? "claude-haiku-4-5"; + +export const aiName = () => (GEMINI ? GEMINI_MODEL : ANTHROPIC ? CLAUDE_MODEL : "none (fallback text)"); + +async function ask(prompt: string, maxTokens = 300): Promise { + if (GEMINI) { + const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`, { + method: "POST", + headers: { "content-type": "application/json", "x-goog-api-key": GEMINI }, + body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { maxOutputTokens: maxTokens } }), + }); + if (!res.ok) return null; + const j = (await res.json()) as any; + return j.candidates?.[0]?.content?.parts?.map((p: any) => p.text).join("").trim() || null; + } + if (ANTHROPIC) { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": ANTHROPIC, "anthropic-version": "2023-06-01" }, + body: JSON.stringify({ model: CLAUDE_MODEL, max_tokens: maxTokens, messages: [{ role: "user", content: prompt }] }), + }); + if (!res.ok) return null; + const j = (await res.json()) as any; + return j.content?.map((c: any) => c.text ?? "").join("").trim() || null; + } + return null; +} + +/** Seller's work. */ +export async function summarize(request: string): Promise { + return ( + (await ask(`${request}\nAnswer in at most 3 short sentences, plain text.`).catch(() => null)) ?? + "An agent that pays before delivery can be paid for nothing. Holding the payment until a signed receipt matches what was delivered means a bad delivery costs the payer nothing. The receipt then becomes portable proof of the seller's track record." + ); +} + +const FALLBACK: Record string> = { + trust: (s) => (s.decision === "deny" ? "This seller has no INAM record, so there is nothing to trust yet." : `The seller checks out (${s.decision}); payment will still wait for proof.`), + refused: () => "Refused before any payment call: no money was requested or held.", + hold_created: (s) => (s.rail === "x402" ? "The buyer signed a USDC payment and kept it; nothing is sent until the work checks out." : "PayPal order created. The owner approves once; the money is held, not moved."), + held: (s) => `${s.amount} ${s.currency} is on hold. The seller can see the money exists but cannot take it.`, + delivered: () => "Work delivered. Comparing what arrived with what the seller's receipt claims.", + verified: () => "The receipt matches the delivery, so the buyer countersigned it.", + captured: () => "Captured: the seller is paid against a verified receipt.", + rejected: (s) => `Refused to countersign (${s.code}): the receipt does not match what was delivered or held.`, + voided: () => "Hold released. The payer was never charged.", +}; + +/** One sentence for the owner about a step. */ +export async function explain(s: Step, job: Job): Promise { + const facts = JSON.stringify(s); + const ai = await ask( + `You explain a payment agent's decisions to a small-business owner in ONE short sentence (max 25 words), no jargon, no hype. ` + + `Job: ${job.capability} for ${job.amount} ${job.currency}. Event: ${facts}. Do not invent facts beyond the event.`, + 80, + ).catch(() => null); + return ai ?? FALLBACK[s.kind](s); +} diff --git a/examples/pay-on-proof/braintree.ts b/examples/pay-on-proof/braintree.ts new file mode 100644 index 0000000..b157890 --- /dev/null +++ b/examples/pay-on-proof/braintree.ts @@ -0,0 +1,94 @@ +import type { Job, Rail } from "./payOnProof.js"; + +/** Braintree (a PayPal service) as a pay-on-proof rail, over the GraphQL API. Sandbox by default. + * https://developer.paypal.com/braintree/graphql/guides/transactions/ + * + * hold = authorizePaymentMethod (money authorized, not settled) + * capture = captureTransaction (submitted for settlement) + * void = reverseTransaction (before settlement this is a void; the payer is never charged) + * + * The payment method is a sandbox test nonce; `fake-paypal-one-time-nonce` stands in for a + * PayPal wallet payment, so no browser and no PayPal account are needed. */ + +export interface BraintreeConfig { + publicKey: string; + privateKey: string; + merchantAccountId?: string; // picks the currency; default: the account's default merchant account + nonce?: string; // default fake-paypal-one-time-nonce + endpoint?: string; // default sandbox + fetchImpl?: typeof fetch; +} + +const TXN = "id status amount { value currencyCode }"; + +export class BraintreeRail implements Rail { + readonly name = "braintree"; + private f: typeof fetch; + constructor(private cfg: BraintreeConfig) { + this.f = cfg.fetchImpl ?? fetch; + } + + private async gql(query: string, variables: Record): Promise { + const res = await this.f(this.cfg.endpoint ?? "https://payments.sandbox.braintree-api.com/graphql", { + method: "POST", + headers: { authorization: `Basic ${btoa(`${this.cfg.publicKey}:${this.cfg.privateKey}`)}`, "braintree-version": "2019-01-01", "content-type": "application/json" }, + body: JSON.stringify({ query, variables }), + }); + const j = (await res.json().catch(() => ({}))) as { data?: any; errors?: { message: string }[] }; + if (!res.ok || j.errors?.length) throw new Error(`Braintree: HTTP ${res.status} ${j.errors?.map((e) => e.message).join("; ") ?? ""}`.trim()); + return j.data; + } + + async hold(job: Job) { + const d = await this.gql(`mutation($input: AuthorizePaymentMethodInput!) { authorizePaymentMethod(input: $input) { transaction { ${TXN} } } }`, { + input: { + paymentMethodId: this.cfg.nonce ?? "fake-paypal-one-time-nonce", + transaction: { amount: job.amount, orderId: job.jobId.slice(0, 255), ...(this.cfg.merchantAccountId ? { merchantAccountId: this.cfg.merchantAccountId } : {}) }, + }, + }); + return { holdRef: d.authorizePaymentMethod.transaction.id as string }; + } + + /** No payer redirect: the hold already exists. Reads it back so the amount checked is Braintree's, not ours. */ + async confirm(id: string) { + const d = await this.gql(`query($id: ID!) { node(id: $id) { ... on Transaction { ${TXN} } } }`, { id }); + const t = d.node; + if (t?.status !== "AUTHORIZED") throw new Error(`Braintree transaction ${id} is ${t?.status ?? "missing"}, not AUTHORIZED`); + return { ref: t.id as string, amount: t.amount.value as string, currency: t.amount.currencyCode as string }; + } + + async capture(id: string) { + const d = await this.gql(`mutation($input: CaptureTransactionInput!) { captureTransaction(input: $input) { transaction { id status } } }`, { input: { transactionId: id } }); + return { captureId: d.captureTransaction.transaction.id as string, status: d.captureTransaction.transaction.status as string }; + } + + async void(id: string) { + const d = await this.gql(`mutation($input: ReverseTransactionInput!) { reverseTransaction(input: $input) { reversal { ... on Transaction { id status } } } }`, { input: { transactionId: id } }); + return { status: (d.reverseTransaction.reversal?.status as string) ?? "VOIDED" }; + } +} + +/** Stand-in for the Braintree sandbox GraphQL API with the documented response shapes. */ +export function mockBraintree() { + const calls: string[] = []; + let n = 0; + const txns = new Map(); + const impl: typeof fetch = async (_input, init) => { + const { query, variables } = JSON.parse(String(init?.body)); + const op = /authorizePaymentMethod|captureTransaction|reverseTransaction|node\(/.exec(query)?.[0] ?? "unknown"; + calls.push(op); + const ok = (data: unknown) => Response.json({ data }); + if (op === "authorizePaymentMethod") { + const id = `dHJhbnNhY3Rpb25f${++n}`; + txns.set(id, { status: "AUTHORIZED", value: variables.input.transaction.amount }); + return ok({ authorizePaymentMethod: { transaction: { id, status: "AUTHORIZED", amount: { value: variables.input.transaction.amount, currencyCode: "USD" } } } }); + } + const t = txns.get(variables.input?.transactionId ?? variables.id); + if (!t) return Response.json({ errors: [{ message: "transaction not found" }] }); + if (op === "node(") return ok({ node: { id: variables.id, status: t.status, amount: { value: t.value, currencyCode: "USD" } } }); + if (t.status !== "AUTHORIZED") return Response.json({ errors: [{ message: `cannot ${op} a ${t.status} transaction` }] }); + t.status = op === "captureTransaction" ? "SUBMITTED_FOR_SETTLEMENT" : "VOIDED"; + return ok(op === "captureTransaction" ? { captureTransaction: { transaction: { id: variables.input.transactionId, status: t.status } } } : { reverseTransaction: { reversal: { id: variables.input.transactionId, status: t.status } } }); + }; + return { impl, calls, txns }; +} diff --git a/examples/pay-on-proof/env.ts b/examples/pay-on-proof/env.ts new file mode 100644 index 0000000..aad63e5 --- /dev/null +++ b/examples/pay-on-proof/env.ts @@ -0,0 +1,4 @@ +// Loads examples/pay-on-proof/.env (gitignored) before any other module reads process.env. +import { existsSync } from "node:fs"; +const file = new URL("./.env", import.meta.url); +if (existsSync(file)) process.loadEnvFile(file); diff --git a/examples/pay-on-proof/payOnProof.ts b/examples/pay-on-proof/payOnProof.ts new file mode 100644 index 0000000..286e11c --- /dev/null +++ b/examples/pay-on-proof/payOnProof.ts @@ -0,0 +1,129 @@ +import { checkTrust, sha256Hex, type InamClient, type ExecutionReceipt, type TrustPolicy, type TrustDecision } from "../../sdk-js/src/index.js"; + +/** Pay on proof: an agent may move money only against a verified INAM receipt. + * The rail is PayPal (fiat) or USDC over x402 (crypto); the receipt and the + * reputation it builds are the same on both. + * + * 1. checkTrust(seller) deny -> stop, no rail is ever called + * 2. rail.hold + confirm PayPal AUTHORIZE / a signed, unsubmitted x402 payload; nothing moves + * 3. seller delivers + drafts an INAM receipt naming the hold (settlement.paymentRef) + * 4. buyer.acceptWork(receipt, { outputHash of what was delivered, amount, currency }) + * match -> countersign, then rail.capture + * MISMATCH / + * NOT_COMPARED -> rail.void; the payer is never charged + * + * These gates are plain code. An AI agent can choose sellers, write the job and + * explain decisions, but it cannot change a gate: capture is only reachable + * through a countersigned receipt. */ + +export type Step = + | { kind: "trust"; decision: TrustDecision["decision"]; reasons: string[] } + | { kind: "refused"; reason: string } + | { kind: "hold_created"; rail: string; holdRef: string; approveUrl?: string } + | { kind: "held"; ref: string; amount: string; currency: string; expiresAt?: string } + | { kind: "delivered"; outputHash: string; receiptClaims: { outputHash?: string; amount?: string; currency?: string } } + | { kind: "verified"; receiptId: string; status: string } + | { kind: "captured"; captureId: string; status: string } + | { kind: "rejected"; code: string; reason: string } + | { kind: "voided"; status: string }; + +export interface Job { + jobId: string; + capability: string; + request: string; + amount: string; + currency: string; + seller: string; // seller's INAM ID (did:key) + payeeEmail?: string; +} + +export interface Delivery { + output: string; + receipt: ExecutionReceipt; // the seller's draft, signed by the seller +} + +/** A payment rail that can hold, then capture or release. `name` prefixes settlement.paymentRef. */ +export interface Rail { + name: string; + hold(job: Job, urls: { returnUrl: string; cancelUrl: string }): Promise<{ holdRef: string; approveUrl?: string }>; + confirm(holdRef: string): Promise<{ ref: string; amount: string; currency: string; expiresAt?: string }>; + capture(ref: string, note: string): Promise<{ captureId: string; status: string }>; + void(ref: string): Promise<{ status: string }>; +} + +export interface Ctx { + buyer: InamClient; + rail: Rail; + policy?: TrustPolicy; + returnUrl: string; + cancelUrl: string; + emit?: (s: Step) => void; +} + +/** Steps 1-2. Returns the hold (and approval link, if the rail has one), or null when the seller is refused before any rail call. */ +export async function openJob(job: Job, ctx: Ctx): Promise<{ holdRef: string; approveUrl?: string } | null> { + const emit = ctx.emit ?? (() => {}); + const trust = await checkTrust(job.seller, ctx.buyer, ctx.policy); + emit({ kind: "trust", decision: trust.decision, reasons: trust.reasons }); + if (trust.decision === "deny") { + emit({ kind: "refused", reason: trust.reasons.join("; ") }); + return null; + } + // allow and escrow both go through the hold: pay-on-proof never pays up front. + const hold = await ctx.rail.hold(job, { returnUrl: ctx.returnUrl, cancelUrl: ctx.cancelUrl }); + emit({ kind: "hold_created", rail: ctx.rail.name, ...hold }); + return hold; +} + +/** Steps 2-4, after the owner approved the hold. `deliver` is the seller doing the work. */ +export async function settleJob(job: Job, holdRef: string, deliver: (job: Job, paymentRef: string) => Promise, ctx: Ctx): Promise<"captured" | "voided"> { + const emit = ctx.emit ?? (() => {}); + const held = await ctx.rail.confirm(holdRef); + emit({ kind: "held", ...held }); + if (held.amount !== job.amount || held.currency !== job.currency) { + // The rail held something other than what the job says: never capture it. + emit({ kind: "rejected", code: "HOLD_MISMATCH", reason: `held ${held.amount} ${held.currency}, job is ${job.amount} ${job.currency}` }); + emit({ kind: "voided", ...(await ctx.rail.void(held.ref)) }); + return "voided"; + } + + const paymentRef = `${ctx.rail.name}:${held.ref}`; + let d: Delivery; + try { + d = await deliver(job, paymentRef); + } catch (e) { + emit({ kind: "rejected", code: "NOT_DELIVERED", reason: (e as Error).message }); + emit({ kind: "voided", ...(await ctx.rail.void(held.ref)) }); + return "voided"; + } + const outputHash = `sha256:${sha256Hex(d.output)}`; + emit({ kind: "delivered", outputHash, receiptClaims: { outputHash: d.receipt.result?.outputHash, amount: d.receipt.settlement?.amount, currency: d.receipt.settlement?.currency } }); + + let receipt: ExecutionReceipt; + try { + // What we received and what we hold, never what the receipt says about itself. + receipt = await ctx.buyer.acceptWork(d.receipt, { outputHash, jobId: job.jobId, amount: job.amount, currency: job.currency }); + } catch (e) { + const err = e as Error & { code?: string }; + emit({ kind: "rejected", code: err.code ?? "REFUSED", reason: err.message }); + emit({ kind: "voided", ...(await ctx.rail.void(held.ref)) }); + return "voided"; + } + emit({ kind: "verified", receiptId: receipt.receiptId, status: receipt.status }); + const cap = await ctx.rail.capture(held.ref, `Paid on verified INAM receipt ${receipt.receiptId}`); + emit({ kind: "captured", ...cap }); + return "captured"; +} + +/** Seller side: do the work, then draft the receipt naming the hold. */ +export async function sellerDeliver(seller: InamClient, buyerDid: string, job: Job, paymentRef: string, output: string, claim: { output?: string; amount?: string } = {}): Promise { + const now = new Date().toISOString(); + const receipt = await seller.submitWork(buyerDid, { + jobId: job.jobId, + task: { capability: job.capability, specHash: `sha256:${sha256Hex(job.request)}`, createdAt: now }, + result: { outputHash: `sha256:${sha256Hex(claim.output ?? output)}`, completedAt: now }, + settlement: { paymentRef, amount: claim.amount ?? job.amount, currency: job.currency }, + verification: { method: "payer_confirmation", outcome: "success" }, + }); + return { output, receipt }; +} diff --git a/examples/pay-on-proof/paypal.ts b/examples/pay-on-proof/paypal.ts new file mode 100644 index 0000000..fae349f --- /dev/null +++ b/examples/pay-on-proof/paypal.ts @@ -0,0 +1,166 @@ +/** Minimal PayPal REST client for pay-on-proof: Orders v2 with intent AUTHORIZE, + * then capture or void the authorization. Sandbox by default. + * https://developer.paypal.com/docs/api/orders/v2/ and /docs/api/payments/v2/ + * + * `fetchImpl` is injectable so tests run against a stand-in (mockPayPal below). */ + +import type { Job, Rail } from "./payOnProof.js"; + +export interface PayPalConfig { + clientId: string; + clientSecret: string; + base?: string; // default sandbox + fetchImpl?: typeof fetch; +} + +export interface HoldRequest { + jobId: string; + description: string; + amount: string; // "12.00" + currency: string; // "USD" + payeeEmail?: string; // seller's PayPal account; default: the app's own account + returnUrl: string; + cancelUrl: string; +} + +export class PayPal { + private base: string; + private f: typeof fetch; + private token?: { value: string; until: number }; + constructor(private cfg: PayPalConfig) { + this.base = cfg.base ?? "https://api-m.sandbox.paypal.com"; + this.f = cfg.fetchImpl ?? fetch; + } + + private async auth(): Promise { + if (this.token && this.token.until > Date.now()) return this.token.value; + const res = await this.f(`${this.base}/v1/oauth2/token`, { + method: "POST", + headers: { authorization: `Basic ${btoa(`${this.cfg.clientId}:${this.cfg.clientSecret}`)}`, "content-type": "application/x-www-form-urlencoded" }, + body: "grant_type=client_credentials", + }); + if (!res.ok) throw new Error(`PayPal auth failed: HTTP ${res.status}`); + const j = (await res.json()) as { access_token: string; expires_in: number }; + this.token = { value: j.access_token, until: Date.now() + (j.expires_in - 60) * 1000 }; + return j.access_token; + } + + private async call(method: string, path: string, body?: unknown, requestId?: string): Promise { + const res = await this.f(`${this.base}${path}`, { + method, + headers: { + authorization: `Bearer ${await this.auth()}`, + "content-type": "application/json", + prefer: "return=representation", + ...(requestId ? { "paypal-request-id": requestId } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`PayPal ${method} ${path}: HTTP ${res.status} ${text.slice(0, 300)}`); + return text ? JSON.parse(text) : {}; + } + + /** Creates an AUTHORIZE order. The payer approves it at `approveUrl`; nothing is held until `authorize`. */ + async createHold(r: HoldRequest): Promise<{ orderId: string; approveUrl: string }> { + const o = await this.call( + "POST", + "/v2/checkout/orders", + { + intent: "AUTHORIZE", + purchase_units: [ + { + reference_id: r.jobId.slice(0, 256), + custom_id: r.jobId.slice(0, 127), + description: r.description.slice(0, 127), + amount: { currency_code: r.currency, value: r.amount }, + ...(r.payeeEmail ? { payee: { email_address: r.payeeEmail } } : {}), + }, + ], + payment_source: { paypal: { experience_context: { return_url: r.returnUrl, cancel_url: r.cancelUrl, user_action: "CONTINUE", shipping_preference: "NO_SHIPPING" } } }, + }, + `hold-${r.jobId}`, + ); + const approveUrl = (o.links as { rel: string; href: string }[]).find((l) => l.rel === "payer-action" || l.rel === "approve")?.href; + if (!approveUrl) throw new Error(`PayPal order ${o.id} has no approval link`); + return { orderId: o.id, approveUrl }; + } + + /** After payer approval: places the hold. Returns the authorization id (the money is held, not moved). */ + async authorize(orderId: string): Promise<{ authorizationId: string; amount: string; currency: string; expiresAt?: string }> { + const o = await this.call("POST", `/v2/checkout/orders/${encodeURIComponent(orderId)}/authorize`, {}, `auth-${orderId}`); + const a = o.purchase_units?.[0]?.payments?.authorizations?.[0]; + if (!a?.id) throw new Error(`PayPal order ${orderId}: no authorization in response`); + return { authorizationId: a.id, amount: a.amount.value, currency: a.amount.currency_code, expiresAt: a.expiration_time }; + } + + /** Releases the held money to the payee. */ + async capture(authorizationId: string, note: string): Promise<{ captureId: string; status: string }> { + const c = await this.call("POST", `/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/capture`, { final_capture: true, note_to_payer: note.slice(0, 255) }, `cap-${authorizationId}`); + return { captureId: c.id, status: c.status }; + } + + /** Cancels the hold; the payer is never charged. */ + async void(authorizationId: string): Promise<{ status: string }> { + const v = await this.call("POST", `/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/void`, undefined, `void-${authorizationId}`); + return { status: v.status ?? "VOIDED" }; + } + + async getOrder(orderId: string): Promise { + return this.call("GET", `/v2/checkout/orders/${encodeURIComponent(orderId)}`); + } +} + +/** PayPal as a pay-on-proof rail: hold = AUTHORIZE order, confirm = authorize, then capture or void. */ +export class PayPalRail implements Rail { + readonly name = "paypal"; + constructor(private pp: PayPal) {} + async hold(job: Job, urls: { returnUrl: string; cancelUrl: string }) { + const h = await this.pp.createHold({ jobId: job.jobId, description: `${job.capability}: ${job.request}`, amount: job.amount, currency: job.currency, payeeEmail: job.payeeEmail, ...urls }); + return { holdRef: h.orderId, approveUrl: h.approveUrl }; + } + async confirm(orderId: string) { + const a = await this.pp.authorize(orderId); + return { ref: a.authorizationId, amount: a.amount, currency: a.currency, expiresAt: a.expiresAt }; + } + capture(ref: string, note: string) { return this.pp.capture(ref, note); } + void(ref: string) { return this.pp.void(ref); } +} + +/** Stand-in for the sandbox with the documented response shapes, for tests and offline demos. + * Every created order counts as approved. `calls` records what was called. */ +export function mockPayPal() { + const calls: string[] = []; + let n = 0; + const holds = new Map(); + const impl: typeof fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const p = url.pathname; + const body = init?.body && typeof init.body === "string" && init.body.startsWith("{") ? JSON.parse(init.body) : undefined; + calls.push(`${init?.method ?? "GET"} ${p}`); + const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json" } }); + if (p === "/v1/oauth2/token") return json({ access_token: "mock-token", expires_in: 32400 }); + if (p === "/v2/checkout/orders") { + const id = `MOCKORDER${++n}`; + const amt = body.purchase_units[0].amount; + holds.set(id, { amount: amt.value, currency: amt.currency_code, state: "CREATED" }); + return json({ id, status: "PAYER_ACTION_REQUIRED", links: [{ rel: "payer-action", href: `https://www.sandbox.paypal.com/checkoutnow?token=${id}` }] }, 201); + } + let m = p.match(/^\/v2\/checkout\/orders\/([^/]+)\/authorize$/); + if (m) { + const h = holds.get(m[1])!; + const authId = `MOCKAUTH${m[1].slice(9)}`; + holds.set(authId, { ...h, state: "CREATED" }); + return json({ id: m[1], status: "COMPLETED", purchase_units: [{ payments: { authorizations: [{ id: authId, status: "CREATED", amount: { value: h.amount, currency_code: h.currency }, expiration_time: "2026-12-10T00:00:00Z" }] } }] }, 201); + } + m = p.match(/^\/v2\/payments\/authorizations\/([^/]+)\/(capture|void)$/); + if (m) { + const h = holds.get(m[1]); + if (!h || h.state !== "CREATED") return json({ name: "UNPROCESSABLE_ENTITY", details: [{ issue: "AUTHORIZATION_ALREADY_PROCESSED" }] }, 422); + h.state = m[2] === "capture" ? "CAPTURED" : "VOIDED"; + return m[2] === "capture" ? json({ id: `MOCKCAP${m[1].slice(8)}`, status: "COMPLETED" }, 201) : json({ id: m[1], status: "VOIDED" }); + } + return json({ name: "RESOURCE_NOT_FOUND" }, 404); + }; + return { impl, calls, holds }; +} diff --git a/examples/pay-on-proof/server.ts b/examples/pay-on-proof/server.ts new file mode 100644 index 0000000..946c88a --- /dev/null +++ b/examples/pay-on-proof/server.ts @@ -0,0 +1,163 @@ +import "./env.js"; // first: later modules read process.env at import +import http from "node:http"; +import { readFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { InamClient, generateKeypair, sha256Hex, generateSecp256k1Keypair } from "../../sdk-js/src/index.js"; +import { PayPal, PayPalRail, mockPayPal } from "./paypal.js"; +import { X402Rail, addressOf, mockFacilitator } from "./x402rail.js"; +import { BraintreeRail, mockBraintree } from "./braintree.js"; +import { openJob, settleJob, sellerDeliver, type Job, type Rail, type Step } from "./payOnProof.js"; +import { explain, summarize, aiName } from "./ai.js"; + +/** Pay-on-proof demo server. + * + * npx tsx examples/pay-on-proof/server.ts -> http://localhost:8787 + * + * PAYPAL_CLIENT_ID / PAYPAL_CLIENT_SECRET: PayPal sandbox app; without them a stand-in answers + * with the documented shapes and every order counts as approved. + * X402_BUYER_PRIVATE_KEY / X402_SELLER_ADDRESS: USDC on Base Sepolia via an x402 facilitator + * (X402_FACILITATOR_URL, default https://x402.org/facilitator); without them a stand-in facilitator + * and throwaway wallets. X402_AMOUNT: USDC per job (default 12.00). + * INAM_URL: registry to use; default a throwaway in-process one (demo agents never touch prod). + * GEMINI_API_KEY or ANTHROPIC_API_KEY: the AI that writes the seller's work and explains each decision. */ + +const PORT = Number(process.env.PORT ?? 8787); +const PUBLIC_URL = process.env.PUBLIC_URL ?? `http://localhost:${PORT}`; +const here = path.dirname(fileURLToPath(import.meta.url)); + +async function registryUrl(): Promise { + if (process.env.INAM_URL) return process.env.INAM_URL; + process.env.INAM_DATA_DIR = mkdtempSync(path.join(tmpdir(), "inam-paypal-demo-")); + const { createServer } = await import("../../src/server.js"); + const s = createServer().listen(0); + await new Promise((r) => s.once("listening", r)); + return `http://localhost:${(s.address() as { port: number }).port}`; +} + +const live = !!(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_CLIENT_SECRET); +const mock = mockPayPal(); +const paypal = live + ? new PayPal({ clientId: process.env.PAYPAL_CLIENT_ID!, clientSecret: process.env.PAYPAL_CLIENT_SECRET!, base: process.env.PAYPAL_BASE }) + : new PayPal({ clientId: "mock", clientSecret: "mock", fetchImpl: mock.impl }); + +const liveUsdc = !!(process.env.X402_BUYER_PRIVATE_KEY && process.env.X402_SELLER_ADDRESS); +const usdc = liveUsdc + ? new X402Rail({ buyerPrivateKey: process.env.X402_BUYER_PRIVATE_KEY!, payTo: process.env.X402_SELLER_ADDRESS! }) + : new X402Rail({ buyerPrivateKey: Buffer.from(generateSecp256k1Keypair().privateKey).toString("hex"), payTo: addressOf(generateSecp256k1Keypair().privateKey), fetchImpl: mockFacilitator().impl }); +const liveBraintree = !!(process.env.BRAINTREE_PUBLIC_KEY && process.env.BRAINTREE_PRIVATE_KEY); +const braintree = liveBraintree + ? new BraintreeRail({ publicKey: process.env.BRAINTREE_PUBLIC_KEY!, privateKey: process.env.BRAINTREE_PRIVATE_KEY!, merchantAccountId: process.env.BRAINTREE_MERCHANT_ACCOUNT_ID, nonce: process.env.BRAINTREE_NONCE, endpoint: process.env.BRAINTREE_ENDPOINT }) + : new BraintreeRail({ publicKey: "mock", privateKey: "mock", fetchImpl: mockBraintree().impl }); +type RailName = "paypal" | "usdc" | "braintree"; +const rails: Record = { paypal: new PayPalRail(paypal), usdc, braintree }; + +type Scenario = "honest" | "swap" | "overclaim" | "unknown"; +interface Run { id: string; scenario: Scenario; rail: RailName; job: Job; holdRef?: string; result?: string; steps: (Step & { at: string; note?: string })[] } +const runs = new Map(); +const byHold = new Map(); +const clients = new Set(); +const push = (r: Run) => { + const data = `data: ${JSON.stringify(r)}\n\n`; + for (const c of clients) c.write(data); +}; + +const inamUrl = await registryUrl(); +const buyer = new InamClient(inamUrl, generateKeypair()); +await buyer.registerAgent(["text.summary.consumer"], { name: "Buyer agent (pay-on-proof demo)", demo: true }); +const seller = new InamClient(inamUrl, generateKeypair()); +await seller.registerAgent(["text.summary"], { name: "Summary agent (pay-on-proof demo)", demo: true }); + +const REQUEST = "Summarize for a merchant: why should an AI agent's payment wait for a verified delivery receipt?"; +const ctxFor = (r: Run) => ({ + buyer, + rail: rails[r.rail], + returnUrl: `${PUBLIC_URL}/paypal/return`, + cancelUrl: `${PUBLIC_URL}/paypal/cancel`, + emit: (s: Step) => { + r.steps.push({ ...s, at: new Date().toISOString() }); + push(r); + // The AI explains; it never decides. Notes arrive after the step, best effort. + explain(s, r.job).then((note) => { + if (!note) return; + const st = r.steps.find((x) => x.kind === s.kind && !x.note); + if (st) { st.note = note; push(r); } + }).catch(() => {}); + }, +}); + +async function settle(r: Run) { + const deliver = async (job: Job, ref: string) => { + const output = await summarize(job.request); + if (r.scenario === "swap") return sellerDeliver(seller, buyer.did, job, ref, output.slice(0, 40), { output }); // delivers less than it signs for + if (r.scenario === "overclaim") return sellerDeliver(seller, buyer.did, job, ref, output, { amount: "120.00" }); + return sellerDeliver(seller, buyer.did, job, ref, output); + }; + try { + r.result = await settleJob(r.job, r.holdRef!, deliver, ctxFor(r)); + } catch (e) { + r.result = `error: ${(e as Error).message}`; + } + push(r); +} + +async function start(scenario: Scenario, rail: RailName): Promise { + const jobId = `job-${Date.now().toString(36)}-${sha256Hex(Math.random().toString()).slice(0, 6)}`; + const target = scenario === "unknown" ? new InamClient(inamUrl, generateKeypair()).did : seller.did; + const money = + rail === "usdc" ? { amount: process.env.X402_AMOUNT ?? "12.00", currency: "USDC" } + : rail === "braintree" ? { amount: "12.00", currency: process.env.BRAINTREE_CURRENCY ?? "USD" } // the sandbox merchant account's currency + : { amount: "12.00", currency: "USD" }; + const r: Run = { id: jobId, scenario, rail, job: { jobId, capability: "text.summary", request: REQUEST, ...money, seller: target }, steps: [] }; + runs.set(jobId, r); + push(r); + const hold = await openJob(r.job, ctxFor(r)).catch((e) => { r.result = `error: ${(e as Error).message}`; push(r); return null; }); + if (!hold) { r.result ??= "refused"; push(r); return r; } + r.holdRef = hold.holdRef; + byHold.set(hold.holdRef, jobId); + if (!hold.approveUrl || !live) void settle(r); // x402 needs no approval; PayPal stand-in implies it + return r; +} + +const send = (res: http.ServerResponse, code: number, body: string, type = "application/json") => { + res.writeHead(code, { "content-type": type, "cache-control": "no-store" }); + res.end(body); +}; + +http + .createServer(async (req, res) => { + const url = new URL(req.url ?? "/", PUBLIC_URL); + try { + if (req.method === "GET" && url.pathname === "/") return send(res, 200, readFileSync(path.join(here, "ui.html"), "utf8"), "text/html; charset=utf-8"); + if (req.method === "GET" && url.pathname === "/api/state") + return send(res, 200, JSON.stringify({ live, liveUsdc, liveBraintree, usdcBuyer: usdc.buyer, ai: aiName(), registry: process.env.INAM_URL ?? "in-process (local)", buyer: buyer.did, seller: seller.did, runs: [...runs.values()] })); + if (req.method === "GET" && url.pathname === "/events") { + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" }); + res.write(": hi\n\n"); + clients.add(res); + req.on("close", () => clients.delete(res)); + return; + } + if (req.method === "POST" && url.pathname === "/api/run") { + const s = url.searchParams.get("scenario") as Scenario; + const rail = (url.searchParams.get("rail") ?? "paypal") as RailName; + if (!["honest", "swap", "overclaim", "unknown"].includes(s)) return send(res, 400, JSON.stringify({ error: "scenario must be honest, swap, overclaim or unknown" })); + if (!["paypal", "usdc", "braintree"].includes(rail)) return send(res, 400, JSON.stringify({ error: "rail must be paypal, usdc or braintree" })); + const r = await start(s, rail); + return send(res, 200, JSON.stringify(r)); + } + // PayPal sends the payer back here after approval (?token=). + if (req.method === "GET" && (url.pathname === "/paypal/return" || url.pathname === "/paypal/cancel")) { + const r = runs.get(byHold.get(url.searchParams.get("token") ?? "") ?? ""); + if (r && url.pathname === "/paypal/return" && !r.result) void settle(r); + if (r && url.pathname === "/paypal/cancel") { r.result = "cancelled by payer"; push(r); } + res.writeHead(302, { location: `/#${r?.id ?? ""}` }); + return res.end(); + } + send(res, 404, JSON.stringify({ error: "not found" })); + } catch (e) { + send(res, 500, JSON.stringify({ error: (e as Error).message })); + } + }) + .listen(PORT, () => console.log(`pay-on-proof demo: ${PUBLIC_URL} (PayPal ${live ? "sandbox" : "stand-in"}, USDC ${liveUsdc ? `Base Sepolia from ${usdc.buyer}` : "stand-in"}, AI ${aiName()}, registry ${inamUrl})`)); diff --git a/examples/pay-on-proof/ui.html b/examples/pay-on-proof/ui.html new file mode 100644 index 0000000..cf9bfbd --- /dev/null +++ b/examples/pay-on-proof/ui.html @@ -0,0 +1,197 @@ + + + + + +Pay on Proof · INAM × PayPal / USDC + + + + + +
+
+ INAM × PayPal / USDC · pay on proof +

An AI agent can pay, in PayPal or USDC, only against a verified receipt.

+

The payment is held first: a PayPal authorization, or a signed USDC transfer (x402) the buyer keeps unsent. The buyer agent captures only when the seller's signed INAM receipt matches what was actually delivered and held; anything else releases the hold and the owner is never charged. Same receipt, same reputation on either rail.

+
+
+ +
+
Check the seller. INAM identity and track record before any payment call. Unknown or revoked: refused.
+
Hold, don't pay. PayPal: an AUTHORIZE order the owner approves. USDC: an EIP-3009 authorization signed and kept. Nothing moves.
+
Deliver with a receipt. The seller signs an INAM receipt naming the delivery hash and the hold.
+
Capture on proof. Match: countersign, then capture (PayPal) or settle (x402). Mismatch: void or discard. Code decides, the AI explains.
+
+ +
+

Run a scenario

+
+
+ + + + +
+
+ +
+
+

Latest run

+
  1. Pick a scenario above.
+
+
+

Ledger

+
+
+
+ +
Open source (Apache-2.0): examples/pay-on-proof · Receipts verify offline with the INAM SDK · PayPal and Braintree sandboxes, Base Sepolia only.
+
+ + + diff --git a/examples/pay-on-proof/x402rail.ts b/examples/pay-on-proof/x402rail.ts new file mode 100644 index 0000000..e05b6de --- /dev/null +++ b/examples/pay-on-proof/x402rail.ts @@ -0,0 +1,175 @@ +// @noble/curves is an sdk-js dependency, not a root one; import it from there rather than add a dependency. +import { secp256k1 } from "../../sdk-js/node_modules/@noble/curves/secp256k1.js"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { randomBytes } from "node:crypto"; +import { ethAddressFromUncompressedPublicKey } from "../../sdk-js/src/crypto/secp256k1.js"; +import { sha256Hex } from "../../sdk-js/src/index.js"; +import type { Job, Rail } from "./payOnProof.js"; + +/** USDC over x402 ("exact" scheme, EIP-3009 transferWithAuthorization) as a pay-on-proof rail. + * + * hold the buyer signs a transferWithAuthorization for the job amount to `payTo` and keeps it. + * Nothing is submitted, so nothing moves. + * capture POST the payload to the facilitator's /verify, then /settle. The facilitator submits it on-chain. + * void discard the payload. It is never settled and expires at validBefore. + * + * The hold ref is `:`, so settlement.paymentRef is `x402:eip155:84532:0x`. + * The receipt is countersigned before settle, so it cannot name the tx hash; the nonce is on-chain + * too (authorizationState(from, nonce), AuthorizationUsed event). The tx hash is the capture id. + * https://github.com/coinbase/x402/blob/main/specs/schemes/exact/scheme_exact_evm.md */ + +export const BASE_SEPOLIA = { + network: "eip155:84532", + chainId: 84532, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + name: "USDC", // the token's EIP-712 domain name/version on Base Sepolia (Base mainnet is "USD Coin") + version: "2", + decimals: 6, +}; +export type X402Network = typeof BASE_SEPOLIA; + +export interface Authorization { from: string; to: string; value: string; validAfter: string; validBefore: string; nonce: string } + +const utf8 = (s: string) => new TextEncoder().encode(s); +const word = (v: bigint) => Buffer.from(v.toString(16).padStart(64, "0"), "hex"); +const hex = (b: Uint8Array) => Buffer.from(b).toString("hex"); +const DOMAIN_TYPE = keccak_256(utf8("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")); +const TRANSFER_TYPE = keccak_256(utf8("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")); + +export const domainSeparator = (n: X402Network) => + keccak_256(Buffer.concat([DOMAIN_TYPE, keccak_256(utf8(n.name)), keccak_256(utf8(n.version)), word(BigInt(n.chainId)), word(BigInt(n.asset))])); + +/** EIP-712 digest of a transferWithAuthorization. */ +export function transferDigest(n: X402Network, a: Authorization): Uint8Array { + const struct = keccak_256(Buffer.concat([TRANSFER_TYPE, word(BigInt(a.from)), word(BigInt(a.to)), word(BigInt(a.value)), word(BigInt(a.validAfter)), word(BigInt(a.validBefore)), word(BigInt(a.nonce))])); + return keccak_256(Buffer.concat([Uint8Array.of(0x19, 0x01), domainSeparator(n), struct])); +} + +export const addressOf = (privateKey: Uint8Array) => ethAddressFromUncompressedPublicKey(secp256k1.getPublicKey(privateKey, false)); + +function toAtomic(amount: string, decimals: number): bigint { + const [i, f = ""] = amount.split("."); + if (!/^\d+$/.test(i) || !/^\d*$/.test(f) || f.length > decimals) throw new Error(`bad amount ${amount}`); + return BigInt(i + f.padEnd(decimals, "0")); +} +function fromAtomic(v: string, decimals: number): string { + const s = v.padStart(decimals + 1, "0"); + return `${s.slice(0, -decimals)}.${s.slice(-decimals).replace(/0+$/, "").padEnd(2, "0")}`; +} + +export interface X402RailConfig { + buyerPrivateKey: string; // 0x-hex secp256k1 key; kept in a private field, never logged + payTo: string; // seller's address + facilitatorUrl?: string; // default X402_FACILITATOR_URL or https://x402.org/facilitator + net?: X402Network; + holdSeconds?: number; // validBefore = now + holdSeconds; default 24h + fetchImpl?: typeof fetch; +} + +interface Held { requirements: Record; payload: { x402Version: 2; accepted: Record; payload: { signature: string; authorization: Authorization } }; expiresAt: string } + +export class X402Rail implements Rail { + readonly name = "x402"; + readonly buyer: string; + #key: Uint8Array; + #held = new Map(); + private net: X402Network; + private url: string; + private f: typeof fetch; + constructor(private cfg: X402RailConfig) { + this.#key = Buffer.from(cfg.buyerPrivateKey.replace(/^0x/, ""), "hex"); + this.buyer = addressOf(this.#key); + this.net = cfg.net ?? BASE_SEPOLIA; + this.url = (cfg.facilitatorUrl ?? process.env.X402_FACILITATOR_URL ?? "https://x402.org/facilitator").replace(/\/$/, ""); + this.f = cfg.fetchImpl ?? fetch; + } + + async hold(job: Job) { + if (job.currency !== "USDC") throw new Error(`x402 rail pays USDC, job is in ${job.currency}`); + const n = this.net; + const holdSeconds = this.cfg.holdSeconds ?? 86400; + const validBefore = Math.floor(Date.now() / 1000) + holdSeconds; + const authorization: Authorization = { + from: this.buyer, + to: this.cfg.payTo, + value: toAtomic(job.amount, n.decimals).toString(), + validAfter: "0", + validBefore: String(validBefore), + nonce: `0x${hex(randomBytes(32))}`, + }; + const sig = secp256k1.sign(transferDigest(n, authorization), this.#key, { prehash: false, format: "recovered" }); // recid || r || s + const signature = `0x${hex(sig.slice(1))}${(27 + sig[0]).toString(16)}`; + const requirements = { scheme: "exact", network: n.network, amount: authorization.value, asset: n.asset, payTo: this.cfg.payTo, maxTimeoutSeconds: holdSeconds, extra: { name: n.name, version: n.version } }; + const holdRef = `${n.network}:${authorization.nonce}`; + this.#held.set(holdRef, { requirements, payload: { x402Version: 2, accepted: requirements, payload: { signature, authorization } }, expiresAt: new Date(validBefore * 1000).toISOString() }); + return { holdRef }; + } + + /** The signed payload is the hold; read back what it authorizes. */ + async confirm(holdRef: string) { + const h = this.get(holdRef); + return { ref: holdRef, amount: fromAtomic(h.payload.payload.authorization.value, this.net.decimals), currency: "USDC", expiresAt: h.expiresAt }; + } + + async capture(ref: string, _note: string) { + const h = this.get(ref); + const body = { x402Version: 2, paymentPayload: h.payload, paymentRequirements: h.requirements }; + const v = await this.post("/verify", body); + if (!v.isValid) throw new Error(`x402 verify failed: ${v.invalidReason ?? "invalid"}`); + const s = await this.post("/settle", body); + if (!s.success) throw new Error(`x402 settle failed: ${s.errorReason ?? "unknown"}`); + this.#held.delete(ref); + return { captureId: s.transaction as string, status: "SETTLED" }; + } + + async void(ref: string) { + const h = this.get(ref); + this.#held.delete(ref); + return { status: `DISCARDED (expires ${h.expiresAt})` }; + } + + private get(ref: string): Held { + const h = this.#held.get(ref); + if (!h) throw new Error(`no x402 hold ${ref}`); + return h; + } + + private async post(path: string, body: unknown): Promise { + const res = await this.f(`${this.url}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + const text = await res.text(); + if (!res.ok) throw new Error(`x402 facilitator ${path}: HTTP ${res.status} ${text.slice(0, 300)}`); + return JSON.parse(text); + } +} + +/** Stand-in facilitator with the documented /verify and /settle shapes, for tests and offline demos. + * Checks the signature, recipient, amount, expiry and nonce reuse; settle returns a fake tx hash. */ +export function mockFacilitator(net: X402Network = BASE_SEPOLIA) { + const calls: string[] = []; + const used = new Set(); + const impl: typeof fetch = async (input, init) => { + const p = new URL(input instanceof Request ? input.url : String(input)).pathname; + calls.push(`${init?.method ?? "GET"} ${p}`); + const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json" } }); + const { paymentPayload, paymentRequirements: r } = JSON.parse(String(init?.body)); + const { signature, authorization: a } = paymentPayload.payload as { signature: string; authorization: Authorization }; + const sig = Buffer.from(signature.slice(2), "hex"); + const recovered = secp256k1.Signature.fromBytes(Buffer.concat([Uint8Array.of(sig[64] - 27), sig.subarray(0, 64)]), "recovered"); + const signer = ethAddressFromUncompressedPublicKey(recovered.recoverPublicKey(transferDigest(net, a)).toBytes(false)); + const invalid = + signer !== a.from.toLowerCase() ? "invalid_exact_evm_payload_signature" + : a.to.toLowerCase() !== String(r.payTo).toLowerCase() ? "invalid_exact_evm_payload_recipient_mismatch" + : BigInt(a.value) < BigInt(r.amount) ? "invalid_exact_evm_payload_authorization_value" + : Number(a.validBefore) < Date.now() / 1000 ? "invalid_exact_evm_payload_authorization_valid_before" + : used.has(a.nonce) ? "invalid_exact_evm_payload_authorization_nonce_used" + : undefined; + if (p.endsWith("/verify")) return json(invalid ? { isValid: false, invalidReason: invalid, payer: a.from } : { isValid: true, payer: a.from }); + if (p.endsWith("/settle")) { + if (invalid) return json({ success: false, errorReason: invalid, transaction: "", network: r.network, payer: a.from }); + used.add(a.nonce); + return json({ success: true, transaction: `0x${sha256Hex(a.nonce)}`, network: r.network, payer: a.from }); + } + return json({ error: "not found" }, 404); + }; + return { impl, calls }; +} diff --git a/tests/payOnProof.test.ts b/tests/payOnProof.test.ts new file mode 100644 index 0000000..01066e3 --- /dev/null +++ b/tests/payOnProof.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { AddressInfo } from "node:net"; +import type http from "node:http"; +import { createServer } from "../src/server.js"; +import { InamClient, generateKeypair, generateSecp256k1Keypair } from "../sdk-js/src/index.js"; +import { PayPal, PayPalRail, mockPayPal } from "../examples/pay-on-proof/paypal.js"; +import { X402Rail, BASE_SEPOLIA, addressOf, domainSeparator, mockFacilitator } from "../examples/pay-on-proof/x402rail.js"; +import { openJob, settleJob, sellerDeliver, type Job, type Rail, type Step } from "../examples/pay-on-proof/payOnProof.js"; +import { BraintreeRail, mockBraintree } from "../examples/pay-on-proof/braintree.js"; + +// examples/pay-on-proof: money moves only against a countersigned INAM receipt, on either rail. +const RAILS = { + paypal: { currency: "USD", refPrefix: "paypal:", capture: "/capture" }, + usdc: { currency: "USDC", refPrefix: "x402:eip155:84532:0x", capture: "/settle" }, + braintree: { currency: "USD", refPrefix: "braintree:", capture: "captureTransaction" }, +} as const; + +let server: http.Server; +let url: string; +let buyer: InamClient; +let seller: InamClient; // one seller on both rails: one reputation +beforeAll(async () => { + server = createServer().listen(0); + await new Promise((r) => server.once("listening", r)); + url = `http://localhost:${(server.address() as AddressInfo).port}`; + buyer = new InamClient(url, generateKeypair()); + await buyer.registerAgent(["research.consumer"], { name: "buyer", demo: true }); + seller = new InamClient(url, generateKeypair()); + await seller.registerAgent(["text.summary"], { name: "seller", demo: true }); +}); +afterAll(() => server.close()); + +describe.each(Object.keys(RAILS) as (keyof typeof RAILS)[])("pay-on-proof on %s", (railName) => { + const R = RAILS[railName]; + const setup = () => { + let calls: string[]; + let rail: Rail; + if (railName === "paypal") { + const mock = mockPayPal(); + calls = mock.calls; + rail = new PayPalRail(new PayPal({ clientId: "id", clientSecret: "secret", fetchImpl: mock.impl })); + } else if (railName === "braintree") { + const mock = mockBraintree(); + calls = mock.calls; + rail = new BraintreeRail({ publicKey: "pub", privateKey: "priv", fetchImpl: mock.impl }); + } else { + const mock = mockFacilitator(); + calls = mock.calls; + const key = Buffer.from(generateSecp256k1Keypair().privateKey).toString("hex"); + rail = new X402Rail({ buyerPrivateKey: key, payTo: addressOf(generateSecp256k1Keypair().privateKey), fetchImpl: mock.impl }); + } + const steps: Step[] = []; + const ctx = { buyer, rail, returnUrl: "http://localhost/return", cancelUrl: "http://localhost/cancel", emit: (s: Step) => steps.push(s) }; + const job = (n: string): Job => ({ jobId: `job-${n}-${Math.random().toString(36).slice(2)}`, capability: "text.summary", request: "summarize the INAM spec", amount: "12.00", currency: R.currency, seller: seller.did }); + return { calls, ctx, steps, job }; + }; + + it("honest delivery: hold, verified receipt, then capture", async () => { + const { calls, ctx, steps, job } = setup(); + const j = job("honest"); + const hold = await openJob(j, ctx); + const out = await settleJob(j, hold!.holdRef, (jj, ref) => sellerDeliver(seller, buyer.did, jj, ref, "INAM is a reputation layer."), ctx); + expect(out).toBe("captured"); + expect(steps.map((s) => s.kind)).toEqual(["trust", "hold_created", "held", "delivered", "verified", "captured"]); + const verified = steps.find((s) => s.kind === "verified") as Extract; + expect(verified.status).toBe("finalized"); + // The countersigned receipt names the rail's hold. + const receipt = await buyer.getReceipt(verified.receiptId); + expect(receipt.settlement?.paymentRef?.startsWith(R.refPrefix)).toBe(true); + expect(calls.filter((c) => c.endsWith(R.capture))).toHaveLength(1); + expect(calls.some((c) => c.endsWith("/void"))).toBe(false); + if (railName === "usdc") expect(calls).toEqual(["POST /facilitator/verify", "POST /facilitator/settle"]); + }); + + it("delivery that differs from the receipt: MISMATCH, hold released, nothing captured", async () => { + const { calls, ctx, steps, job } = setup(); + const j = job("swap"); + const hold = await openJob(j, ctx); + // Seller delivers one text but signs a receipt for another. + const out = await settleJob(j, hold!.holdRef, (jj, ref) => sellerDeliver(seller, buyer.did, jj, ref, "low effort", { output: "the full report" }), ctx); + expect(out).toBe("voided"); + expect(steps.find((s) => s.kind === "rejected")).toMatchObject({ code: "MISMATCH" }); + expect(calls.some((c) => c.endsWith(R.capture))).toBe(false); + if (railName === "usdc") { + expect(calls).toEqual([]); // the signed payload never left the buyer + expect((steps.find((s) => s.kind === "voided") as Extract).status).toMatch(/^DISCARDED \(expires /); + } + }); + + it("receipt claiming more money than held: MISMATCH, released", async () => { + const { calls, ctx, steps, job } = setup(); + const j = job("overclaim"); + const hold = await openJob(j, ctx); + const out = await settleJob(j, hold!.holdRef, (jj, ref) => sellerDeliver(seller, buyer.did, jj, ref, "summary", { amount: "120.00" }), ctx); + expect(out).toBe("voided"); + expect((steps.find((s) => s.kind === "rejected") as Extract).reason).toContain("settlement.amount"); + expect(calls.some((c) => c.endsWith(R.capture))).toBe(false); + }); + + it("seller unknown to INAM: refused before the rail is called", async () => { + const { calls, ctx, steps, job } = setup(); + const j = { ...job("unknown"), seller: new InamClient(url, generateKeypair()).did }; + expect(await openJob(j, ctx)).toBeNull(); + expect(steps.map((s) => s.kind)).toEqual(["trust", "refused"]); + expect(calls).toEqual([]); + }); +}); + +it("one reputation across rails: the seller's honest jobs on every rail (PayPal, USDC via x402, Braintree) all count", async () => { + const rep = await buyer.getReputation(seller.did); + expect(rep.components.finalizedReceipts).toBe(Object.keys(RAILS).length); +}); + +it("x402 EIP-712 domain matches Base Sepolia USDC's on-chain DOMAIN_SEPARATOR()", () => { + // Read once via eth_call on sepolia.base.org; pins the name/version/chainId/contract encoding. + expect(Buffer.from(domainSeparator(BASE_SEPOLIA)).toString("hex")).toBe("71f17a3b2ff373b803d70a5a07c046c1a2bc8e89c09ef722fcb047abe94c9818"); +});