diff --git a/.gitignore b/.gitignore index c4a893e36..03b3ed20d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ graphify-out/ # Scoped registry credentials for the private @sun-sdk packages; local-only until the SDK # is published to public npm (see ts/docs/superpowers/specs, D6). ts/.npmrc + +# Codex +.codex/ + +# MCP +.mcp.json \ No newline at end of file diff --git a/ts/.gitignore b/ts/.gitignore index 427ac3700..5f3868829 100644 --- a/ts/.gitignore +++ b/ts/.gitignore @@ -8,4 +8,6 @@ docs/superpowers docs/qa docs/adr CONTEXT.md -.boundary \ No newline at end of file +.boundary +audit-claude/ +.codegraph/ diff --git a/ts/docs/commands/x402/pay.md b/ts/docs/commands/x402/pay.md index 7cc12c530..15436a877 100644 --- a/ts/docs/commands/x402/pay.md +++ b/ts/docs/commands/x402/pay.md @@ -16,7 +16,7 @@ wallet-cli x402 pay [--method ] [--header "Name: value"]... [--body Sends the request. If the endpoint answers with a successful status (2xx), that response is returned as-is and nothing is paid; any other status except `402` fails with `provider_error`, carrying `httpStatus` and `phase: "request"`. If it answers `402 Payment Required`, `pay` reads the payment routes it offers, picks one that matches the selected `--network` and your filters, signs a payment authorization with the active account (or `--account`), and sends the request again with it. The endpoint's facilitator settles the payment on chain. -**Nothing is signed until a route matches.** Routes on other networks are ignored; `--token`, `--asset` and `--scheme` narrow the choice further, and `--max-amount` (whole tokens) or `--max-raw-amount` (smallest units) rules out a route priced above it. If no route matches, the command fails with `no_matching_requirement`; if the matching route is priced over the limit, with `amount_exceeds_limit`. Both are raised before signing, with `paymentStatus: "not_sent"`, and no password is asked for — with or without `--dry-run`. Without a limit of your own, a built-in ceiling of **$1 per payment** applies to the known stablecoins; a route priced above it also fails with `amount_exceeds_limit`, and passing `--max-amount` or `--max-raw-amount` replaces that ceiling with yours. +**Nothing is signed until a route matches.** Routes on other networks are ignored; `--token`, `--asset` and `--scheme` narrow the choice further, and `--max-amount` (whole tokens) or `--max-raw-amount` (smallest units) rules out a route priced above it. If no route matches, the command fails with `no_matching_requirement`; if the matching route is priced over the limit, with `amount_exceeds_limit`. Both are raised before signing, with `paymentStatus: "not_sent"`, and no password is asked for — with or without `--dry-run`. A raw amount has no unit, so `--max-raw-amount` requires `--token` or `--asset` to name the token it counts (`invalid_option` otherwise). Without a limit of your own, only the known stablecoins are accepted, under a built-in ceiling of **$1 per payment**; a route priced above it also fails with `amount_exceeds_limit`. A limit of your own replaces that ceiling and the stablecoin restriction, but other tokens are still refused: `--max-amount` needs a token whose decimals are known (or `--asset` with `--decimals`), and `--max-raw-amount` pays only the token you pinned. **Two payment schemes:** @@ -52,7 +52,7 @@ Requires an account, even for an endpoint that turns out to be free. The master | `--body ` | Request body; excludes `--body-file` | | `--body-file ` | Read the request body from a file, or from stdin with `-`; excludes `--body` | | `--max-amount ` | Refuse a route priced above this, in whole tokens; excludes `--max-raw-amount` | -| `--max-raw-amount ` | The same limit in smallest units | +| `--max-raw-amount ` | The same limit in smallest units; requires `--token` or `--asset`, since a raw amount has no unit | | `--token ` | Accept only routes paying in this token | | `--asset
` | Accept only routes paying in this token contract | | `--decimals ` | Decimals of the `--asset` token; requires `--asset` | @@ -114,6 +114,8 @@ URL https://x402-gateway.bankofai.io/providers/dia-price-tron/v1/quotat Status 200 Settled Yes Delivered Yes +Amount 0.000001 (1 base units) +Asset USDT From TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6 Transaction 9b41c7e2d05f83a6e1c4b8d27f9a03e5c6d8b1f4a2e7c9d0b3f5a8e1c6d2b7f4 Output btc-quote.json @@ -124,10 +126,10 @@ printf '%s' "$PW" | wallet-cli x402 pay https://x402-gateway.bankofai.io/provide ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"x402.pay","data":{"url":"https://x402-gateway.bankofai.io/providers/dia-price-tron/v1/quotation/BTC","status":200,"delivered":true,"settled":true,"payer":{"address":"TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"},"paymentResponse":{"success":true,"transaction":"9b41c7e2d05f83a6e1c4b8d27f9a03e5c6d8b1f4a2e7c9d0b3f5a8e1c6d2b7f4","network":"tron:0x2b6653dc","payer":"0xe2e1a54926527fbb4e4420de4c6bab82beaee24d"},"output":{"path":"btc-quote.json","bytes":214}},"meta":{"durationMs":6412,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"x402.pay","data":{"url":"https://x402-gateway.bankofai.io/providers/dia-price-tron/v1/quotation/BTC","status":200,"delivered":true,"settled":true,"payer":{"address":"TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"},"paymentResponse":{"success":true,"transaction":"9b41c7e2d05f83a6e1c4b8d27f9a03e5c6d8b1f4a2e7c9d0b3f5a8e1c6d2b7f4","network":"tron:0x2b6653dc","payer":"0xe2e1a54926527fbb4e4420de4c6bab82beaee24d"},"output":{"path":"btc-quote.json","bytes":214},"payment":{"asset":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","token":"USDT","decimals":6,"amount":"0.000001","rawAmount":"1"}},"meta":{"durationMs":6412,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} ``` -`Settled Yes` means the payment went through on chain; `Delivered Yes` means the paid response arrived and was written to `btc-quote.json`. `From` is the paying account and `Transaction` the payment's transaction ID (JSON: `data.payer.address` and `data.paymentResponse.transaction`). The facilitator submits the payment transaction and pays its energy, so the payment itself costs the account only the quoted price. This account already had a Permit2 allowance; on a first payment without one, see the Permit2 approve note above. +`Settled Yes` means the payment went through on chain; `Delivered Yes` means the paid response arrived and was written to `btc-quote.json`. `Amount` and `Asset` are what was paid (JSON: `data.payment`). `From` is the paying account and `Transaction` the payment's transaction ID (JSON: `data.payer.address` and `data.paymentResponse.transaction`). The facilitator submits the payment transaction and pays its energy, so the payment itself costs the account only the quoted price. This account already had a Permit2 allowance; on a first payment without one, see the Permit2 approve note above. ## Output @@ -138,6 +140,7 @@ printf '%s' "$PW" | wallet-cli x402 pay https://x402-gateway.bankofai.io/provide | `delivered` | boolean | Whether the final response was successful (2xx) | | `settled` | boolean | Whether a valid settlement receipt for the selected network came back | | `payer` | object | `{address}` of the paying account; present when a payment was signed | +| `payment` | object | What the signed payment pays, present when one was created: `asset` (token contract), `token` (symbol, when the token is registered), `decimals` and `amount` (whole tokens, when the decimals are known), and `rawAmount` (smallest units) | | `paymentResponse` | object | The facilitator's settlement receipt, when the endpoint sent one: `success`, `transaction` (the payment's transaction ID), `network`, and `payer` — both as the facilitator writes them, e.g. `tron:0xcd8690dc` and a hex address | | `approval` | object | TRON only, when the payment needed a one-time Permit2 approve: `{txId, token, spender, allowance: "unlimited", feeLimitSun, status}`. `status` is `confirmed` (broadcast and mined before the payment was signed) or `exported` (signed into the payment package for the endpoint to sponsor). The same object appears in `error.details.approval` when anything after the approve fails | | `response` | any | The response body — parsed JSON, or text; absent with `--out` | @@ -146,7 +149,7 @@ printf '%s' "$PW" | wallet-cli x402 pay https://x402-gateway.bankofai.io/provide ## Exit status -`0` success, including a free response · `1` execution failure (`no_matching_requirement`, `amount_exceeds_limit` — both before signing, `paymentStatus: "not_sent"`; `gasfree_insufficient_balance` / `gasfree_not_activated`, `permit2_allowance_required` / `approval_reset_required`, `fee_cap_exceeded`, `payer_mismatch`, `invalid_settlement`, `invalid_x402_response`, `response_too_large`, `provider_rate_limited` — HTTP 429; `provider_error` — any other failed request or payment, with `error.details.phase` and `httpStatus`; `missing_wallet_address`; `auth_required` — a payment needs signing and no `--password-stdin` was given; `auth_failed`, `timeout`) · `2` usage error (`output_exists` — the `--out` file exists; `gasfree_credentials_missing`; `invalid_option` — a binary response without `--out`, two exclusive options, `--decimals` without `--asset`, or `--wait` / `--wait-timeout`, which this command does not take because the facilitator settles the payment; `invalid_amount` — a limit that is not a positive amount; `invalid_value` — e.g. a malformed `--header`, an unsupported `--method`, or a `--gasfree-relay` URL that is not HTTPS). +`0` success, including a free response · `1` execution failure (`no_matching_requirement`, `amount_exceeds_limit` — both before signing, `paymentStatus: "not_sent"`; `gasfree_insufficient_balance` / `gasfree_not_activated`, `permit2_allowance_required` / `approval_reset_required`, `fee_cap_exceeded`, `payer_mismatch`, `invalid_settlement`, `invalid_x402_response`, `response_too_large`, `provider_rate_limited` — HTTP 429; `provider_error` — any other failed request or payment, with `error.details.phase` and `httpStatus`; `missing_wallet_address`; `auth_required` — a payment needs signing and no `--password-stdin` was given; `auth_failed`, `timeout`) · `2` usage error (`output_exists` — the `--out` file exists; `gasfree_credentials_missing`; `invalid_option` — a binary response without `--out`, two exclusive options, `--decimals` without `--asset`, `--max-raw-amount` without `--token` or `--asset`, or `--wait` / `--wait-timeout`, which this command does not take because the facilitator settles the payment; `invalid_amount` — a limit that is not a positive amount; `invalid_value` — e.g. a malformed `--header`, an unsupported `--method`, or a `--gasfree-relay` URL that is not HTTPS). ## See also diff --git a/ts/src/adapters/inbound/cli/commands/r4-validation.test.ts b/ts/src/adapters/inbound/cli/commands/r4-validation.test.ts index 2b1b7027a..8d0281ba4 100644 --- a/ts/src/adapters/inbound/cli/commands/r4-validation.test.ts +++ b/ts/src/adapters/inbound/cli/commands/r4-validation.test.ts @@ -12,9 +12,9 @@ describe("R4 payment input contracts", () => { it.each([ [{ maxAmount: "0" }, "invalid_amount"], [{ maxAmount: "1e3" }, "invalid_amount"], - [{ maxRawAmount: "0" }, "invalid_amount"], - [{ maxRawAmount: (1n << 256n).toString() }, "invalid_amount"], - [{ maxAmount: "1", maxRawAmount: "1" }, "invalid_option"], + [{ maxRawAmount: "0", token: "USDT" }, "invalid_amount"], + [{ maxRawAmount: (1n << 256n).toString(), token: "USDT" }, "invalid_amount"], + [{ maxAmount: "1", maxRawAmount: "1", token: "USDT" }, "invalid_option"], [{ body: "", bodyFile: "body.json" }, "invalid_option"], [{ maxGasfreeFee: "1", maxGasfreeFeeRaw: "1" }, "invalid_option"], ])("rejects %j with %s", (input, code) => { @@ -22,6 +22,24 @@ describe("R4 payment input contracts", () => { expect.objectContaining({ code }), ); }); + it("rejects a raw payment limit that names no token, before anything is requested", () => { + expect(() => + parse("pay", { url: "https://example.test", maxRawAmount: "1000000000000000000" }), + ).toThrow( + expect.objectContaining({ + code: "invalid_option", + message: expect.stringContaining("--max-raw-amount: requires --token or --asset"), + }), + ); + }); + it.each([{ token: "USDT" }, { asset: "0x55d398326f99059fF775485246999027B3197955" }])( + "accepts a raw payment limit pinned by %j", + (pin) => { + expect( + parse("pay", { url: "https://example.test", maxRawAmount: "1000000", ...pin }), + ).toMatchObject({ maxRawAmount: "1000000", ...pin }); + }, + ); it.each(["1e3", "1.5", "-1"])("rejects port literal %s", (port) => { expect(() => parse("serve", { payTo: "x", port })).toThrow(); }); diff --git a/ts/src/adapters/inbound/cli/commands/x402.test.ts b/ts/src/adapters/inbound/cli/commands/x402.test.ts index 402405160..5e6ede3a9 100644 --- a/ts/src/adapters/inbound/cli/commands/x402.test.ts +++ b/ts/src/adapters/inbound/cli/commands/x402.test.ts @@ -53,6 +53,7 @@ describe("x402 command surface", () => { header: [], maxAmount: "1", maxRawAmount: "1", + token: "USDT", }).success, ).toBe(false); }); diff --git a/ts/src/adapters/inbound/cli/commands/x402.ts b/ts/src/adapters/inbound/cli/commands/x402.ts index 345a54677..6cf4c0226 100644 --- a/ts/src/adapters/inbound/cli/commands/x402.ts +++ b/ts/src/adapters/inbound/cli/commands/x402.ts @@ -34,7 +34,9 @@ const payFields = z.object({ decimals: integerLiteral(0, 18).optional().describe("decimals for an explicit asset"), scheme: z.enum(["exact", "exact_gasfree"]).optional(), maxAmount: paymentAmount.optional().describe("maximum payment in whole tokens"), - maxRawAmount: rawPaymentAmount.optional().describe("maximum payment in smallest units"), + maxRawAmount: rawPaymentAmount + .optional() + .describe("maximum payment in smallest units; requires --token or --asset"), dryRun: z.boolean().default(false).describe("inspect the payment challenge without signing"), gasfreeRelay: z .string() @@ -60,6 +62,16 @@ const payInput = payFields.superRefine((value, context) => { message: "cannot be combined with --max-raw-amount", }); } + // A raw limit has no unit: unpinned, it would cap whatever token the seller names. + if (value.maxRawAmount !== undefined && !value.token && !value.asset) { + context.addIssue({ + code: "custom", + params: { errorCode: "invalid_option" }, + path: ["maxRawAmount"], + message: + "requires --token or --asset; a raw limit has no unit, so pin it to one token or use --max-amount", + }); + } if (value.body !== undefined && value.bodyFile !== undefined) { context.addIssue({ code: "custom", diff --git a/ts/src/adapters/inbound/cli/render/x402.test.ts b/ts/src/adapters/inbound/cli/render/x402.test.ts index ff975a206..ac16d216a 100644 --- a/ts/src/adapters/inbound/cli/render/x402.test.ts +++ b/ts/src/adapters/inbound/cli/render/x402.test.ts @@ -72,6 +72,28 @@ it("separates response text while output-file receipts omit body", () => { "--- response ---", ); }); +it("shows the paid asset and amount after a payment", () => { + const rendered = paymentText({ + status: 200, + settled: true, + payment: { + asset: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + token: "USDT", + decimals: 6, + amount: "0.01", + rawAmount: "10000", + }, + }); + expect(rendered).toMatch(/^Amount\s+0\.01 \(10000 base units\)$/m); + expect(rendered).toMatch(/^Asset\s+USDT$/m); + const unregistered = paymentText({ + status: 200, + payment: { asset: "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c", rawAmount: "5" }, + }); + expect(unregistered).toMatch(/^Amount\s+5 base units$/m); + expect(unregistered).toMatch(/^Asset\s+0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c$/m); + expect(paymentText({ status: 200 })).not.toMatch(/Amount|Asset/); +}); it("wraps a wide-character summary with aligned continuation lines", () => { const original = Object.getOwnPropertyDescriptor(process.stdout, "columns"); Object.defineProperty(process.stdout, "columns", { value: 32, configurable: true }); diff --git a/ts/src/adapters/inbound/cli/render/x402.ts b/ts/src/adapters/inbound/cli/render/x402.ts index 1935ac30e..2e06a9d83 100644 --- a/ts/src/adapters/inbound/cli/render/x402.ts +++ b/ts/src/adapters/inbound/cli/render/x402.ts @@ -104,12 +104,16 @@ export function providerEndpointsText(value: unknown): string { ); } export function paymentText(value: unknown): string { - const p = asObj(value); + const p = asObj(value), + paid = asObj(p.payment); + const raw = typeof paid.rawAmount === "string" ? `${text(paid.rawAmount)} base units` : ""; const details = query([ ["URL", text(p.url)], ["Status", text(p.status)], ["Settled", p.settled === true ? "Yes" : "No"], ["Delivered", p.delivered === true ? "Yes" : "No"], + ["Amount", typeof paid.amount === "string" && raw ? `${text(paid.amount)} (${raw})` : raw], + ["Asset", text(paid.token ?? paid.asset)], ["From", text(asObj(p.payer).address)], ["Transaction", text(asObj(p.paymentResponse).transaction)], ["Approval", text(asObj(p.approval).txId)], diff --git a/ts/src/adapters/outbound/x402/nile-compatibility.test.ts b/ts/src/adapters/outbound/x402/nile-compatibility.test.ts index 0384494f4..df2ca7dc0 100644 --- a/ts/src/adapters/outbound/x402/nile-compatibility.test.ts +++ b/ts/src/adapters/outbound/x402/nile-compatibility.test.ts @@ -165,6 +165,7 @@ it("uses the selected relay to construct and sign the final GasFree permit", asy scheme: "exact_gasfree", gasfreeRelay: "https://relay.example/nile", maxRawAmount: "10000", + asset, }, ), ).resolves.toMatchObject({ delivered: true }); diff --git a/ts/src/adapters/outbound/x402/payment-client.test.ts b/ts/src/adapters/outbound/x402/payment-client.test.ts index 494d6855d..613137c5a 100644 --- a/ts/src/adapters/outbound/x402/payment-client.test.ts +++ b/ts/src/adapters/outbound/x402/payment-client.test.ts @@ -594,7 +594,7 @@ it.each(["tron:3448148188", "tron:0xcd8690dc"])( ); it.each([ - [{ maxRawAmount: "99" }, "amount_exceeds_limit"], + [{ maxRawAmount: "99", token: "USDT" }, "amount_exceeds_limit"], [{ maxAmount: "0.000000000000000099" }, "amount_exceeds_limit"], [{ token: "USDD" }, "no_matching_requirement"], [{ scheme: "exact_gasfree" as const }, "no_matching_requirement"], diff --git a/ts/src/adapters/outbound/x402/payment-client.ts b/ts/src/adapters/outbound/x402/payment-client.ts index e10a6c6e7..9fd8c441e 100644 --- a/ts/src/adapters/outbound/x402/payment-client.ts +++ b/ts/src/adapters/outbound/x402/payment-client.ts @@ -43,6 +43,7 @@ import { toX402Wallet } from "./signer-bridge.js"; import { createPayerSigner } from "../../../application/services/x402/payer-signer.js"; import type { PayerSigner } from "../../../application/contracts/x402-payer.js"; import { toX402Network, sameX402Network } from "../../../domain/x402/network-id.js"; +import { fromBaseUnits } from "../../../domain/amounts/index.js"; type PaidFetchFactory = ( network: NetworkDescriptor, @@ -159,7 +160,13 @@ export class X402PaymentClient implements X402PaymentPort { out, toX402Network(network), ); - return authorization.approval ? { ...result, approval: authorization.approval } : result; + return { + ...result, + ...(authorization.requirement + ? { payment: paidAmount(network, authorization.requirement, input) } + : {}), + ...(authorization.approval ? { approval: authorization.approval } : {}), + }; } catch (error) { await out?.release(); const classified = authorization.signed @@ -439,6 +446,10 @@ export class X402PaymentClient implements X402PaymentPort { client.onPaymentCreationFailure(async ({ error }) => { creationError = sdkPaymentError(error, "create_payment"); }); + // Report the requirement the SDK actually built the payment for, not an earlier candidate. + client.onAfterPaymentCreation(async ({ selectedRequirements }) => { + authorization.requirement = selectedRequirements; + }); const boundedFetch = this.boundedFetch(scope, network, signer); let first: Response | undefined = initial; const fetchWithInitial: typeof fetch = (request, init) => { @@ -498,6 +509,27 @@ interface PaymentAuthorization { signed: boolean; /** The one-time Permit2 approve, once signed, and how far the SDK has taken it. */ approval?: SignedApproval & { status: "submitted" | "confirmed" | "exported" }; + /** The requirement the created payment pays. */ + requirement?: { asset: string; amount: string }; +} + +/** What the signed payment pays: the token (symbol when registered) and its amount. */ +function paidAmount( + network: NetworkDescriptor, + requirement: { asset: string; amount: string }, + input: X402PayInput, +) { + const known = metadata(network.id, requirement.asset); + // An explicit --decimals is only accepted beside --asset, which this requirement matched. + const decimals = known?.decimals ?? input.decimals; + return { + asset: requirement.asset, + ...(known ? { token: known.symbol } : {}), + ...(decimals === undefined + ? {} + : { decimals, amount: fromBaseUnits(requirement.amount, decimals) }), + rawAmount: requirement.amount, + }; } /** Never lose the approve's evidence to whatever failed after it. */ @@ -624,6 +656,13 @@ function selectMatching( network: NetworkDescriptor, input: X402PayInput, ): T[] { + // A raw limit has no unit; without a pinned token it would cap whatever asset the seller names. + if (input.maxRawAmount !== undefined && !input.asset && !input.token) { + throw new UsageError( + "invalid_option", + "--max-raw-amount requires --token or --asset; a raw limit has no unit, so pin it to one token or use --max-amount", + ); + } const expectedNetwork = network.family === "tron" ? `tron:0x${BigInt(network.chainId).toString(16)}` : network.id; const matching = requirements.filter((requirement) => { diff --git a/ts/src/adapters/outbound/x402/payment-error.ts b/ts/src/adapters/outbound/x402/payment-error.ts index 14d44a8b3..5696b1bb1 100644 --- a/ts/src/adapters/outbound/x402/payment-error.ts +++ b/ts/src/adapters/outbound/x402/payment-error.ts @@ -165,8 +165,8 @@ export function sdkPaymentError(error: unknown, phase?: PaymentPhase): CliError return new ExecutionError( overLimit ? "amount_exceeds_limit" : "no_matching_requirement", overLimit - ? "payment exceeds the built-in $1 per-payment ceiling; pass --max-amount or --max-raw-amount to authorize it" - : "no offered payment route uses an asset the built-in spend control allows; pass --max-amount or --max-raw-amount to set the ceiling yourself", + ? "payment exceeds the built-in $1 per-payment ceiling; pass --max-amount, or --max-raw-amount with --token or --asset, to authorize it" + : "no offered payment route uses an asset the built-in spend control allows; pass --max-amount, or --max-raw-amount with --token or --asset, to set the ceiling yourself", { ...(phase ? { phase } : {}), paymentStatus: "not_sent", retryPayment: false }, ); } diff --git a/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts b/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts index f2d0fc71f..5d1669340 100644 --- a/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts +++ b/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts @@ -86,6 +86,10 @@ describe("x402 SDK payer integration", () => { it("keeps the SDK on the requirement whose spend limit was checked", async () => { const { client, signTypedData } = fixture(false, true); - await client.pay(scope, network, { ...input, maxRawAmount: "10" }); + await client.pay(scope, network, { + ...input, + maxRawAmount: "10", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + }); expect(signTypedData.mock.calls[0]![0].message.value).toBe(1n); }); diff --git a/ts/src/adapters/outbound/x402/raw-limit-asset.test.ts b/ts/src/adapters/outbound/x402/raw-limit-asset.test.ts new file mode 100644 index 000000000..6e6ccea26 --- /dev/null +++ b/ts/src/adapters/outbound/x402/raw-limit-asset.test.ts @@ -0,0 +1,251 @@ +// A raw payment limit has no unit: it must be pinned to one token, or a seller can price the +// route in any asset (or the same asset at another precision) under a cap sized for a stablecoin. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Wallet } from "ethers"; +import { TronWeb, providers } from "tronweb"; +import { X402PaymentClient } from "./payment-client.js"; +import { evmSignStrategy } from "../chain/evm/signing-strategy.js"; +import { tronSignStrategy } from "../chain/tron/signing-strategy.js"; +import type { X402PayInput } from "../../../application/ports/x402-payment.js"; +import type { SignerResolver } from "../../../application/services/signer/index.js"; +import type { TransactionScope } from "../../../application/contracts/execution-scope.js"; +import type { NetworkDescriptor, TypedDataPayload } from "../../../domain/types/index.js"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const BSC_USDT = "0x55d398326f99059fF775485246999027B3197955"; // 18 decimals +const BSC_BTCB = "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c"; // 18 decimals, not registered +const TRON_USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; // 6 decimals +const ONE_E18 = "1000000000000000000"; + +const scope = { + activeAccount: "payer", + timeoutMs: 2000, + emit() {}, + warn() {}, +} as unknown as TransactionScope; + +interface Outcome { + result?: Record; + error?: { code?: string }; + typedData: TypedDataPayload[]; + txs: unknown[]; + requests: number; + paidRequests: number; +} + +function seller(requirement: Record, out: Outcome): typeof fetch { + const challenge = { + x402Version: 2, + resource: { url: "https://seller.example" }, + accepts: [{ maxTimeoutSeconds: 300, ...requirement }], + }; + return async (request, init) => { + out.requests++; + if (!new Request(request, init).headers.get("payment-signature")) + return new Response(null, { + status: 402, + headers: { "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64") }, + }); + out.paidRequests++; + return new Response("ok"); + }; +} + +async function payEvm(asset: string, input: Partial): Promise { + const wallet = Wallet.createRandom(); + // Permit2 already holds an allowance, so the only signature is the payment authorization. + vi.stubGlobal( + "fetch", + vi.fn(async (request, init) => { + const body = (await new Request(request, init).json()) as { method: string; id: number }; + const result = + body.method === "eth_call" + ? "0x" + "f".repeat(64) + : body.method === "eth_getTransactionCount" + ? "0x3" + : null; + return Response.json({ jsonrpc: "2.0", id: body.id, result }); + }), + ); + const out: Outcome = { typedData: [], txs: [], requests: 0, paidRequests: 0 }; + const signers = { + assertCanSign() {}, + prepare() {}, + resolve: () => ({ + address: wallet.address, + kind: "software", + sign: (tx: unknown) => { + out.txs.push(tx); + return evmSignStrategy.sign(wallet.privateKey, tx as never); + }, + signTypedData: (payload: TypedDataPayload) => { + out.typedData.push(payload); + return evmSignStrategy.signTypedData(wallet.privateKey, payload); + }, + }), + } as unknown as SignerResolver; + const fetcher = seller( + { + scheme: "exact", + network: "eip155:56", + amount: ONE_E18, + asset, + payTo: "0x2222222222222222222222222222222222222222", + extra: { assetTransferMethod: "permit2", name: "x", version: "1" }, + }, + out, + ); + try { + out.result = await new X402PaymentClient(signers, fetcher).pay( + scope, + { + family: "evm", + chainId: "56", + id: "eip155:56", + httpEndpoint: "https://rpc.example", + } as NetworkDescriptor, + { url: "https://seller.example", method: "GET", headers: [], ...input }, + ); + } catch (error) { + out.error = error as { code?: string }; + } + return out; +} + +async function payTron(amount: string, input: Partial): Promise { + const key = Wallet.createRandom().privateKey; + const address = TronWeb.address.fromPrivateKey(key.slice(2)) as string; + // Permit2 already holds an allowance, so no approve is built. + vi.spyOn(providers.HttpProvider.prototype, "request").mockImplementation(async (path) => { + if (path === "wallet/triggerconstantcontract") + return { result: { result: true }, constant_result: ["f".repeat(64)] }; + throw new Error(`unexpected RPC ${String(path)}`); + }); + const out: Outcome = { typedData: [], txs: [], requests: 0, paidRequests: 0 }; + const signers = { + assertCanSign() {}, + prepare() {}, + resolve: () => ({ + address, + kind: "software", + sign: async (tx: unknown) => { + out.txs.push(tx); + return tronSignStrategy.sign(key, tx as never); + }, + signTypedData: async (payload: TypedDataPayload) => { + out.typedData.push(payload); + return tronSignStrategy.signTypedData(key, payload); + }, + }), + } as unknown as SignerResolver; + const fetcher = seller( + { + scheme: "exact", + network: "tron:0x2b6653dc", + amount, + asset: TRON_USDT, + payTo: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + extra: { assetTransferMethod: "permit2" }, + }, + out, + ); + try { + out.result = await new X402PaymentClient(signers, fetcher).pay( + scope, + { + id: "tron:728126428", + chainId: "728126428", + family: "tron", + httpEndpoint: "http://127.0.0.1:1", + } as NetworkDescriptor, + { url: "https://seller.example", method: "GET", headers: [], ...input }, + ); + } catch (error) { + out.error = error as { code?: string }; + } + return out; +} + +function expectRefusedBeforeSigning(out: Outcome) { + expect(out.error).toMatchObject({ + code: "invalid_option", + details: { paymentStatus: "not_sent", retryPayment: false }, + }); + expect(out.typedData).toHaveLength(0); + expect(out.txs).toHaveLength(0); + expect(out.paidRequests).toBe(0); +} + +describe("x402 raw payment limit pinning", () => { + it("EVM: refuses an unpinned raw limit when the seller names an unregistered token", async () => { + const out = await payEvm(BSC_BTCB, { maxRawAmount: ONE_E18 }); + expectRefusedBeforeSigning(out); + }); + + it("EVM: refuses an unpinned raw limit in --dry-run as well", async () => { + const out = await payEvm(BSC_BTCB, { maxRawAmount: ONE_E18, dryRun: true }); + expect(out.error).toMatchObject({ code: "invalid_option" }); + expect(out.result).toBeUndefined(); + }); + + it("TRON: refuses a raw limit sized for USDD when the seller asks 1e12 raw USDT", async () => { + const out = await payTron("1000000000000", { maxRawAmount: ONE_E18 }); + expectRefusedBeforeSigning(out); + }); + + it.each([{ token: "USDT" }, { asset: BSC_USDT }])( + "EVM: pays an honest route with a raw limit pinned by %j and reports what it paid", + async (pin) => { + const out = await payEvm(BSC_USDT, { maxRawAmount: ONE_E18, ...pin }); + expect(out.error).toBeUndefined(); + expect(out.typedData).toHaveLength(1); + expect(out.paidRequests).toBe(1); + expect(out.result).toMatchObject({ + delivered: true, + payment: { + asset: BSC_USDT, + token: "USDT", + decimals: 18, + amount: "1", + rawAmount: ONE_E18, + }, + }); + }, + ); + + it.each([{ token: "USDT" }, { asset: TRON_USDT }])( + "TRON: pays an honest route with a raw limit pinned by %j and reports what it paid", + async (pin) => { + const out = await payTron("1000000", { maxRawAmount: "1000000", ...pin }); + expect(out.error).toBeUndefined(); + expect(out.typedData).toHaveLength(1); + expect(out.paidRequests).toBe(1); + expect(out.result).toMatchObject({ + delivered: true, + payment: { + asset: TRON_USDT, + token: "USDT", + decimals: 6, + amount: "1", + rawAmount: "1000000", + }, + }); + }, + ); + + it("still refuses a pinned token's route priced above the raw limit", async () => { + const out = await payTron("1000000000000", { maxRawAmount: "1000000", token: "USDT" }); + expect(out.error).toMatchObject({ code: "amount_exceeds_limit" }); + expect(out.typedData).toHaveLength(0); + }); + + it("reports an unregistered pinned asset by address and raw amount only", async () => { + const out = await payEvm(BSC_BTCB, { maxRawAmount: ONE_E18, asset: BSC_BTCB }); + expect(out.error).toBeUndefined(); + expect(out.result?.payment).toEqual({ asset: BSC_BTCB, rawAmount: ONE_E18 }); + }); +}); diff --git a/ts/src/adapters/outbound/x402/roundtrip.test.ts b/ts/src/adapters/outbound/x402/roundtrip.test.ts index c696addd5..4f0247998 100644 --- a/ts/src/adapters/outbound/x402/roundtrip.test.ts +++ b/ts/src/adapters/outbound/x402/roundtrip.test.ts @@ -129,3 +129,51 @@ it.each([ expect(signTypedData).toHaveBeenCalledOnce(); expect(calls).toEqual(["/verify", "/settle"]); }); + +it("pins a raw-amount roundtrip to the asset its own paywall serves", async () => { + const payer = "0x1111111111111111111111111111111111111111"; + const transaction = "0x" + "a".repeat(64); + const signTypedData = vi.fn(async (payload) => ({ + primaryType: payload.primaryType, + signature: "0x" + "a".repeat(130), + })); + const resolver = { + assertCanSign: vi.fn(), + resolve: () => ({ address: payer, kind: "software", signTypedData }), + } as unknown as SignerResolver; + const facilitator: typeof fetch = async (url) => + Response.json( + String(url).endsWith("/verify") + ? { isValid: true, payer } + : { success: true, transaction, network: "eip155:56", payer }, + ); + const service = new X402Service( + new X402PaymentClient(resolver), + {} as ProviderCatalogPort, + {} as never, + new X402HttpServer(facilitator), + ); + // No --token or --asset: the paywall's default token must still bound the raw limit. + const result = await service.roundtrip( + { activeAccount: "payer", timeoutMs: 2000, emit: vi.fn() } as never, + { id: "eip155:56", chainId: "56", family: "evm" } as never, + { + host: "127.0.0.1", + port: 0, + payTo: "0x2222222222222222222222222222222222222222", + rawAmount: "10000000000000000", + scheme: "exact", + facilitatorUrl: "https://facilitator.example", + }, + ); + expect(result.pay).toMatchObject({ + settled: true, + payment: { + asset: "0x55d398326f99059fF775485246999027B3197955", + token: "USDT", + amount: "0.01", + rawAmount: "10000000000000000", + }, + }); + expect(signTypedData).toHaveBeenCalledOnce(); +}); diff --git a/ts/src/application/use-cases/x402-service.ts b/ts/src/application/use-cases/x402-service.ts index 3e564802c..1066d1097 100644 --- a/ts/src/application/use-cases/x402-service.ts +++ b/ts/src/application/use-cases/x402-service.ts @@ -71,7 +71,10 @@ export class X402Service { token: input.token, scheme: input.scheme, expectedPayTo: input.payTo, - asset: input.asset, + // Pin the route to the asset this paywall serves; a raw limit has no unit of its own. + asset: + input.asset ?? + (typeof handle.details.asset === "string" ? handle.details.asset : undefined), decimals: input.decimals, exactAmount: input.rawAmount === undefined ? (input.amount ?? "0.0001") : undefined, maxAmount: input.rawAmount === undefined ? (input.amount ?? "0.0001") : undefined, diff --git a/ts/test/beta-command-surface.test.ts b/ts/test/beta-command-surface.test.ts index 66c6b4297..f812e7245 100644 --- a/ts/test/beta-command-surface.test.ts +++ b/ts/test/beta-command-surface.test.ts @@ -150,12 +150,30 @@ it.skipIf(!entry).each(["usage-summary", "usage-records", "recharge-orders"])( it.skipIf(!entry).each([ [["x402", "pay", "https://example.test", "--max-amount", "0"], "invalid_amount", 2], [ - ["x402", "pay", "https://example.test", "--max-raw-amount", (1n << 256n).toString()], + [ + "x402", + "pay", + "https://example.test", + "--max-raw-amount", + (1n << 256n).toString(), + "--token", + "USDT", + ], "invalid_amount", 2, ], [ - ["x402", "pay", "https://example.test", "--max-amount", "1", "--max-raw-amount", "1"], + [ + "x402", + "pay", + "https://example.test", + "--max-amount", + "1", + "--max-raw-amount", + "1", + "--token", + "USDT", + ], "invalid_option", 2, ],