Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions examples/pay-on-proof/.env.example
Original file line number Diff line number Diff line change
@@ -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=
135 changes: 135 additions & 0 deletions examples/pay-on-proof/README.md
Original file line number Diff line number Diff line change
@@ -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:<authorization id>`).

## 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).
68 changes: 68 additions & 0 deletions examples/pay-on-proof/ai.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<string> {
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<Step["kind"], (s: any) => 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<string> {
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);
}
Loading
Loading