diff --git a/ts/docs/commands/x402/pay.md b/ts/docs/commands/x402/pay.md index e208598ce..56842b0df 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`. +**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. **Two payment schemes:** @@ -54,7 +54,7 @@ Requires an account, even for an endpoint that turns out to be free. The master | `--gasfree-relay ` | Source of GasFree account data for `exact_gasfree` (default `official`); a URL must be HTTPS with no credentials, query, or fragment | | `--max-gasfree-fee ` | Highest GasFree fee to authorize, in whole tokens; excludes `--max-gasfree-fee-raw` | | `--max-gasfree-fee-raw ` | The same cap in smallest units | -| `--out ` | Write the response body to a new file instead of `data.response`; an existing file is never overwritten | +| `--out ` | Write the response body to a new file instead of `data.response`; an existing file is refused (`output_exists`) before any request is sent, so it is never overwritten and never paid for | | `--dry-run` | Read the challenge and report the selected route, without signing | | `--password-stdin` | Master password from stdin | @@ -133,6 +133,7 @@ printf '%s' "$PW" | wallet-cli x402 pay https://x402-gateway.bankofai.io/provide | `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 | | `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` | | `output` | object | With `--out`: `{path, bytes}` written | | `dryRun` / `paymentRequired` / `selected` | — | With `--dry-run` on a `402`: `true`, `true`, and the route that would be paid (`scheme`, `network`, `amount` in smallest units, `asset`, `payTo`, `maxTimeoutSeconds`, `extra`) | diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index d052a64ea..35f679a4f 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -283,6 +283,7 @@ A failed payment carries extra fields in `error.details` so a script can tell wh | `settled` / `delivered` | Whether the payment settled and the resource arrived | | `retryPayment` | `false` means do **not** pay again to recover. It overrides the code's generic `retry` value | | `candidateTxHash` / `candidateNetwork` | A transaction that may be the payment. Evidence for reconciliation, not proof of payment | +| `approval` | TRON: the one-time Permit2 approve that was signed before the failure — `{txId, token, spender, allowance, feeLimitSun, status}` with `status` `submitted`, `confirmed` or `exported`. It is not the payment; do not report it as one. A `paymentStatus: "not_sent"` beside it means the allowance exists on chain but no payment authorization was produced | **Treat `paymentStatus: "unknown"` as possibly paid.** For example, an `exact` payment from an account with no token balance currently fails as `provider_error` with `phase: "create_payment"` and `paymentStatus: "unknown"`, even though nothing was sent. diff --git a/ts/package.json b/ts/package.json index 54426d25e..5c3a10b6d 100644 --- a/ts/package.json +++ b/ts/package.json @@ -13,7 +13,7 @@ "docs/guide", "docs/machine-interface.md", "docs/troubleshooting.md", - "docs/development/erc8004-sdk-integration.md", + "docs/troubleshooting", "README.md", "LICENSE" ], diff --git a/ts/scripts/verify-package.mjs b/ts/scripts/verify-package.mjs index bc5cf3677..37b4afb09 100644 --- a/ts/scripts/verify-package.mjs +++ b/ts/scripts/verify-package.mjs @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join, posix, resolve } from "node:path"; import assert from "node:assert/strict"; const root = resolve(import.meta.dirname, ".."); @@ -17,16 +17,28 @@ try { // Never validate a stale dist left by an earlier build. call(npm, ["run", "build"]); const [packed] = JSON.parse(call(npm, ["pack", "--json", "--pack-destination", temp])); - const forbidden = packed.files.filter( - ({ path }) => - path.startsWith("docs/development/") && - path !== "docs/development/erc8004-sdk-integration.md", - ); + const forbidden = packed.files.filter(({ path }) => path.startsWith("docs/development/")); assert.equal(forbidden.length, 0, "internal development reports must not be packaged"); assert( packed.files.some(({ path }) => path === "dist/index.js"), "missing CLI entry", ); + // A packaged document must not point at a document the package left out: every relative + // Markdown link in a packaged .md that stays inside the package must resolve to a packaged + // file. Links that climb out of the package (README -> the monorepo) are not its to satisfy. + const packedPaths = new Set(packed.files.map(({ path }) => path)); + const dangling = []; + for (const path of packedPaths) { + if (!path.endsWith(".md")) continue; + const text = readFileSync(join(root, path), "utf8"); + for (const [, target] of text.matchAll(/\]\(([^)#?\s]+\.md)(?:#[^)]*)?\)/g)) { + if (/^[a-z]+:/i.test(target)) continue; + const resolved = posix.normalize(posix.join(posix.dirname(path), target)); + if (resolved.startsWith("../")) continue; + if (!packedPaths.has(resolved)) dangling.push(`${path} -> ${target}`); + } + } + assert.deepEqual(dangling, [], "packaged docs link to files the package does not contain"); call(npm, ["init", "-y"], temp); call(npm, ["install", join(temp, packed.filename), "--no-audit", "--no-fund"], temp); const entry = join(temp, "node_modules/@tron-walletcli/wallet-cli/dist/index.js"); diff --git a/ts/src/adapters/inbound/cli/commands/x402.test.ts b/ts/src/adapters/inbound/cli/commands/x402.test.ts index 17d128757..4b9acb66e 100644 --- a/ts/src/adapters/inbound/cli/commands/x402.test.ts +++ b/ts/src/adapters/inbound/cli/commands/x402.test.ts @@ -97,3 +97,60 @@ it("does not advertise unsupported provider type or facilitator waiting", () => for (const command of ["pay", "roundtrip"]) expect(registry.resolveNeutral(["x402", command])!.supportsWait).toBe(false); }); + +/** + * `--body-file -` reads the same fd 0 a `--*-stdin` secret is bound to. Refusing only + * `--password-stdin` let `--api-key-stdin` (or any other secret) be read as the request body and + * posted to the endpoint. Every secret bound to stdin must refuse the body, before anything is read. + */ +it.each(["password", "apiKey", "tx", "message"])( + "refuses --body-file - when --%s-stdin also claims stdin, without reading or sending", + async (kind) => { + const registry = new CommandRegistry(); + const svc = service(); + registerX402Commands(registry, svc); + const pay = registry.resolveNeutral(["x402", "pay"])!; + const readStdinOnce = vi.fn(() => "secret"); + const ctx = { + secrets: { has: (k: string) => k === kind }, + streams: { readStdinOnce }, + }; + await expect( + pay.run(ctx as never, { id: "eip155:56" } as never, { + url: "https://example.test", + method: "POST", + header: [], + bodyFile: "-", + }), + ).rejects.toMatchObject({ code: "invalid_option" }); + expect(readStdinOnce).not.toHaveBeenCalled(); + expect(svc.pay).not.toHaveBeenCalled(); + }, +); + +/** + * Catalog warnings come from a remote document and go to the terminal through the diagnostic + * channel, which — unlike the result renderer — does not strip terminal control sequences. + */ +it("strips terminal control sequences from remote catalog warnings before warning", async () => { + const registry = new CommandRegistry(); + const svc = service(); + const ESC = String.fromCharCode(27); + const BEL = String.fromCharCode(7); + (svc.providerList as ReturnType).mockResolvedValue({ + providers: [], + warnings: [`stale ${ESC}[2J${ESC}[H${BEL}catalog`, "plain"], + }); + registerX402Commands(registry, svc); + const list = registry.resolveNeutral(["x402", "provider-list"])!; + const warn = vi.fn(); + await list.run({ warn, emit: vi.fn() } as never, undefined, { limit: 20, offset: 0 }); + expect(warn).toHaveBeenCalledTimes(2); + for (const [message] of warn.mock.calls) { + expect(message).not.toContain(ESC); + expect(message).not.toContain(BEL); + } + expect(warn).toHaveBeenCalledWith("plain"); + expect(warn.mock.calls[0]![0]).toContain("stale"); + expect(warn.mock.calls[0]![0]).toContain("catalog"); +}); diff --git a/ts/src/adapters/inbound/cli/commands/x402.ts b/ts/src/adapters/inbound/cli/commands/x402.ts index 6709c06b1..cb182fef6 100644 --- a/ts/src/adapters/inbound/cli/commands/x402.ts +++ b/ts/src/adapters/inbound/cli/commands/x402.ts @@ -9,7 +9,8 @@ import { } from "../render/x402.js"; import { paymentAmount, rawPaymentAmount, integerLiteral } from "../schemas/payment-values.js"; import { z } from "zod"; -import type { CommandDefinition } from "../contracts/index.js"; +import { SECRET_KINDS, type CommandDefinition } from "../contracts/index.js"; +import { sanitizeText } from "../render/scalars.js"; import type { CommandRegistry } from "../registry/index.js"; import type { X402Service } from "../../../../application/use-cases/x402-service.js"; import { readFile } from "node:fs/promises"; @@ -331,10 +332,13 @@ async function requestBody( ): Promise { if (!path) return inline; if (path === "-") { - if (ctx.secrets.has("password")) { + // Every `---stdin` secret is bound to the same fd 0; reading it as the body would post + // the secret to the endpoint. Refuse before anything is read, whichever secret claims stdin. + const claimed = SECRET_KINDS.find((kind) => ctx.secrets.has(kind)); + if (claimed !== undefined) { throw new UsageError( "invalid_option", - "--body-file - cannot share stdin with --password-stdin", + `--body-file - cannot share stdin with --${claimed.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}-stdin`, ); } return checkedBody(ctx.streams.readStdinOnce(), "stdin"); @@ -363,7 +367,9 @@ async function providerResult( ) { ctx.emit({ type: "activity", message: "Loading provider catalog data…" }); const { warnings, ...data } = await pending; + // Remote text on the diagnostic channel bypasses the result renderer's sanitizing; do it here. if (Array.isArray(warnings)) - for (const warning of warnings) if (typeof warning === "string") ctx.warn(warning); + for (const warning of warnings) + if (typeof warning === "string") ctx.warn(sanitizeText(warning)); return data; } diff --git a/ts/src/adapters/inbound/cli/contracts/runtime.ts b/ts/src/adapters/inbound/cli/contracts/runtime.ts index 619798e8e..4076337b5 100644 --- a/ts/src/adapters/inbound/cli/contracts/runtime.ts +++ b/ts/src/adapters/inbound/cli/contracts/runtime.ts @@ -16,7 +16,15 @@ export interface StreamManager { warnings(): WarningItem[]; } -export type SecretKind = "password" | "privateKey" | "mnemonic" | "tx" | "message" | "apiKey"; +export const SECRET_KINDS = [ + "password", + "privateKey", + "mnemonic", + "tx", + "message", + "apiKey", +] as const; +export type SecretKind = (typeof SECRET_KINDS)[number]; export interface SecretResolver { masterPassword(): string; /** whether a master-password source exists, WITHOUT consuming stdin. */ diff --git a/ts/src/adapters/inbound/cli/render/x402.test.ts b/ts/src/adapters/inbound/cli/render/x402.test.ts index 85cbe9a70..ff975a206 100644 --- a/ts/src/adapters/inbound/cli/render/x402.test.ts +++ b/ts/src/adapters/inbound/cli/render/x402.test.ts @@ -140,3 +140,15 @@ it("sanitizes payment fields and shows daemon management details", () => { expect(rendered).not.toContain("\x1b"); expect(rendered).not.toContain("\nforged"); }); +// The one-time Permit2 approve is a separate on-chain transaction the payer should be able to +// find from the default text, not only from JSON. +it("shows the approval transaction when a payment carried one", () => { + const rendered = paymentText({ + status: 200, + settled: true, + approval: { txId: "ab".repeat(32), token: "TXYZ", spender: "TYQu", status: "confirmed" }, + }); + expect(rendered).toContain("Approval"); + expect(rendered).toContain("ab".repeat(32)); + expect(paymentText({ status: 200 })).not.toContain("Approval"); +}); diff --git a/ts/src/adapters/inbound/cli/render/x402.ts b/ts/src/adapters/inbound/cli/render/x402.ts index fb5eefbf7..1935ac30e 100644 --- a/ts/src/adapters/inbound/cli/render/x402.ts +++ b/ts/src/adapters/inbound/cli/render/x402.ts @@ -112,6 +112,7 @@ export function paymentText(value: unknown): string { ["Delivered", p.delivered === true ? "Yes" : "No"], ["From", text(asObj(p.payer).address)], ["Transaction", text(asObj(p.paymentResponse).transaction)], + ["Approval", text(asObj(p.approval).txId)], ["Output", text(asObj(p.output).path)], ]); if (p.dryRun === true) diff --git a/ts/src/adapters/outbound/chain/tron/tron-responses.test.ts b/ts/src/adapters/outbound/chain/tron/tron-responses.test.ts index ac4ebbbda..335fc4202 100644 --- a/ts/src/adapters/outbound/chain/tron/tron-responses.test.ts +++ b/ts/src/adapters/outbound/chain/tron/tron-responses.test.ts @@ -72,3 +72,10 @@ describe("parseTronTx", () => { expect(parseTronTx("boom").ret).toBeUndefined(); }); }); + +// A numeric receipt.result must not be coerced away into "no result": a present-but-unexpected +// value is evidence the call did not report SUCCESS, and dropping it turned it into a success. +it("parseTronTxInfo keeps a numeric receipt.result as a string", () => { + const info = parseTronTxInfo({ blockNumber: 1, receipt: { result: 17 } }); + expect(info.receipt?.result).toBe("17"); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron-responses.ts b/ts/src/adapters/outbound/chain/tron/tron-responses.ts index 401fe7562..167444a5b 100644 --- a/ts/src/adapters/outbound/chain/tron/tron-responses.ts +++ b/ts/src/adapters/outbound/chain/tron/tron-responses.ts @@ -31,7 +31,8 @@ const TronTxInfoSchema = objectish( fee: optNum, receipt: z .looseObject({ - result: optStr, + // present-but-numeric must survive as a string: dropping it would read as "no result". + result: z.union([z.string(), z.number()]).transform(String).optional().catch(undefined), energy_usage_total: optNum, energy_fee: optNum, net_usage: optNum, diff --git a/ts/src/adapters/outbound/x402/allowance.test.ts b/ts/src/adapters/outbound/x402/allowance.test.ts index 2913b0060..5fca79f9b 100644 --- a/ts/src/adapters/outbound/x402/allowance.test.ts +++ b/ts/src/adapters/outbound/x402/allowance.test.ts @@ -1,9 +1,10 @@ -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Wallet, Interface } from "ethers"; import { TronWeb, providers, utils } from "tronweb"; import { X402PaymentClient } from "./payment-client.js"; import { tronSignStrategy } from "../chain/tron/signing-strategy.js"; import type { TypedDataPayload } from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; afterEach(() => vi.restoreAllMocks()); @@ -131,7 +132,7 @@ it.each(["sufficient", "auto", "sponsored", "reverted"])( fetcher, ); const payment = client.pay( - { activeAccount: "payer", timeoutMs: 1000, emit() {} } as never, + { activeAccount: "payer", timeoutMs: 1000, emit() {}, warn() {} } as never, { id: "tron:3448148188", chainId: "3448148188", @@ -165,3 +166,405 @@ it.each(["sufficient", "auto", "sponsored", "reverted"])( expect(rpc).toHaveBeenCalled(); }, ); + +/** + * The approve above is built by the RPC, not locally. A compromised RPC can answer the SDK's + * approve request with a different, self-consistent transaction (owner-only types need nothing + * but our address): txID, raw_data_hex and raw_data all agree, so the integrity check passes + * and — before this guard — the wallet signed and the SDK broadcast (auto) or exported + * (sponsored) it. The bridge must refuse before the signer sees it. + */ +it.each([ + ["auto", "WithdrawBalanceContract"], + ["sponsored", "WithdrawBalanceContract"], + ["auto", "CancelAllUnfreezeV2Contract"], +])("refuses an RPC-substituted %s approve (%s) before signing", async (mode, type) => { + const key = Wallet.createRandom().privateKey; + const address = TronWeb.address.fromPrivateKey(key.slice(2)) as string; + const calls: string[] = []; + const sign = vi.fn(async (tx: unknown) => { + calls.push("approve-sign"); + return tronSignStrategy.sign(key, tx); + }); + const signTypedData = vi.fn(async (payload: TypedDataPayload) => { + calls.push("payment-sign"); + return tronSignStrategy.signTypedData(key, payload); + }); + vi.spyOn(providers.HttpProvider.prototype, "request").mockImplementation(async (path, data) => { + const input = data as Record; + if (path === "wallet/triggerconstantcontract") + return { result: { result: true }, constant_result: ["0".repeat(64)] }; + if (path === "wallet/triggersmartcontract") { + const shell = { + visible: false, + raw_data: { + contract: [ + { + type, + parameter: { + type_url: `type.googleapis.com/protocol.${type}`, + value: { owner_address: input.owner_address }, + }, + }, + ], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: Date.now(), + expiration: Date.now() + 60000, + fee_limit: 100000000, + }, + }; + const pb = utils.transaction.txJsonToPb(shell as never); + return { + result: { result: true }, + transaction: { + ...shell, + txID: utils.transaction.txPbToTxID(pb).replace(/^0x/, ""), + raw_data_hex: utils.transaction.txPbToRawDataHex(pb).toLowerCase(), + }, + }; + } + if (path === "wallet/broadcasttransaction") { + calls.push("approve-broadcast"); + return { result: true, txid: input.txID }; + } + throw new Error(`Unexpected RPC ${path}`); + }); + const extension = "trc20ApprovalResourceSponsoring"; + const challenge = { + x402Version: 2, + resource: { url: "https://payment.example" }, + accepts: [ + { + network: "tron:0xcd8690dc", + scheme: "exact", + amount: "1000000", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }, + ], + ...(mode === "sponsored" ? { extensions: { [extension]: { info: { version: "1" } } } } : {}), + }; + const fetcher = vi.fn(async (request, init) => { + const req = new Request(request, init); + if (!req.headers.get("payment-signature")) + return Response.json(challenge, { + status: 402, + headers: { "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64") }, + }); + calls.push("payment-send"); + return new Response("ok"); + }); + const client = new X402PaymentClient( + { + assertCanSign() {}, + resolve: () => ({ kind: "software", address, sign, signTypedData }), + } as never, + fetcher, + ); + const error = await client + .pay( + { activeAccount: "payer", timeoutMs: 1000, emit() {}, warn() {} } as never, + { + id: "tron:3448148188", + chainId: "3448148188", + family: "tron", + httpEndpoint: "http://127.0.0.1:1", + } as never, + { url: "https://payment.example", method: "GET", headers: [], token: "USDT", maxAmount: "1" }, + ) + .catch((e) => e); + expect(error).toMatchObject({ + code: "signed_payload_mismatch", + details: { retryPayment: false }, + }); + // The sponsored flow has the SDK sign the payment authorization before it builds the approve; + // that signature never leaves the process. The auto flow fails before any signature at all. + if (mode === "auto") { + expect(error).toMatchObject({ details: { paymentStatus: "not_sent" } }); + expect(calls).toEqual([]); + } else { + expect(calls).toEqual(["payment-sign"]); + } + expect(sign).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledOnce(); +}); + +/** + * The auto approve is a real, irreversible side effect (an unlimited allowance, broadcast and + * confirmed by the SDK) that happens BEFORE the payment authorization is signed. When anything + * after it fails — a device rejection, an expired deadline, a settlement error — the caller must + * still get that approval's evidence, and must not be told the payment is "unknown" when no + * payment authorization was ever produced. + */ +describe("x402 TRON auto approve keeps its evidence", () => { + const PERMIT2_NILE = "TYQuuhGbEMxF7nZxUHV3uHJxAVVAegNU9h"; + const USDT_NILE = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"; + function harness(mode: "auto" | "sponsored") { + const key = Wallet.createRandom().privateKey; + const address = TronWeb.address.fromPrivateKey(key.slice(2)) as string; + const broadcast: string[] = []; + const rpc = vi + .spyOn(providers.HttpProvider.prototype, "request") + .mockImplementation(async (path, data) => { + const input = data as Record; + if (path === "wallet/triggerconstantcontract") + return { result: { result: true }, constant_result: ["0".repeat(64)] }; + if (path === "wallet/triggersmartcontract") { + const shell = { + visible: false, + raw_data: { + contract: [ + { + type: "TriggerSmartContract", + parameter: { + type_url: "type.googleapis.com/protocol.TriggerSmartContract", + value: { + owner_address: input.owner_address, + contract_address: input.contract_address, + data: `095ea7b3${input.parameter}`, + call_value: 0, + }, + }, + }, + ], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: Date.now(), + expiration: Date.now() + 60000, + fee_limit: 100000000, + }, + }; + const pb = utils.transaction.txJsonToPb(shell as never); + return { + result: { result: true }, + transaction: { + ...shell, + txID: utils.transaction.txPbToTxID(pb).replace(/^0x/, ""), + raw_data_hex: utils.transaction.txPbToRawDataHex(pb).toLowerCase(), + }, + }; + } + if (path === "wallet/broadcasttransaction") { + broadcast.push(input.txID); + return { result: true, txid: input.txID }; + } + if (path === "wallet/gettransactioninfobyid") + return { blockNumber: 1, receipt: { result: "SUCCESS" } }; + throw new Error(`Unexpected RPC ${path}`); + }); + const extension = "trc20ApprovalResourceSponsoring"; + const challenge = { + x402Version: 2, + resource: { url: "https://payment.example" }, + accepts: [ + { + network: "tron:0xcd8690dc", + scheme: "exact", + amount: "1000000", + asset: USDT_NILE, + payTo: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }, + ], + ...(mode === "sponsored" ? { extensions: { [extension]: { info: { version: "1" } } } } : {}), + }; + return { key, address, broadcast, rpc, challenge }; + } + const net = { + id: "tron:3448148188", + chainId: "3448148188", + family: "tron", + httpEndpoint: "http://127.0.0.1:1", + } as never; + const input = { + url: "https://payment.example", + method: "GET", + headers: [] as string[], + token: "USDT", + maxAmount: "1", + }; + + it("reports the confirmed approval and a not-sent payment when the payment signature is refused", async () => { + const { key, address, broadcast, challenge } = harness("auto"); + const warnings: string[] = []; + const sign = vi.fn(async (tx: unknown) => tronSignStrategy.sign(key, tx)); + const signTypedData = vi.fn(async () => { + throw new ChainError("signing_rejected", "declined on device"); + }); + const fetcher = vi.fn(async () => + Response.json(challenge, { + status: 402, + headers: { "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64") }, + }), + ); + const client = new X402PaymentClient( + { + assertCanSign() {}, + resolve: () => ({ kind: "software", address, sign, signTypedData }), + } as never, + fetcher, + ); + const error = await client + .pay( + { + activeAccount: "payer", + timeoutMs: 1000, + emit() {}, + warn: (m: string) => warnings.push(m), + } as never, + net, + input, + ) + .catch((e) => e); + expect(broadcast).toHaveLength(1); + expect(error).toMatchObject({ + code: "signing_rejected", + details: { + paymentStatus: "not_sent", + retryPayment: false, + approval: { + txId: broadcast[0], + token: USDT_NILE, + spender: PERMIT2_NILE, + allowance: "unlimited", + status: "confirmed", + }, + }, + }); + expect(warnings.some((w) => w.includes(broadcast[0]!))).toBe(true); + }); + + it("includes the approval in a successful payment result", async () => { + const { key, address, broadcast, challenge } = harness("auto"); + const sign = vi.fn(async (tx: unknown) => tronSignStrategy.sign(key, tx)); + const signTypedData = vi.fn(async (p: TypedDataPayload) => + tronSignStrategy.signTypedData(key, p), + ); + const fetcher = vi.fn(async (request, init) => { + const req = new Request(request, init); + if (!req.headers.get("payment-signature")) + return Response.json(challenge, { + status: 402, + headers: { + "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }, + }); + return new Response("ok"); + }); + const client = new X402PaymentClient( + { + assertCanSign() {}, + resolve: () => ({ kind: "software", address, sign, signTypedData }), + } as never, + fetcher, + ); + const result = await client.pay( + { activeAccount: "payer", timeoutMs: 1000, emit() {}, warn() {} } as never, + net, + input, + ); + expect(broadcast).toHaveLength(1); + expect(result).toMatchObject({ + delivered: true, + approval: { + txId: broadcast[0], + token: USDT_NILE, + spender: PERMIT2_NILE, + status: "confirmed", + }, + }); + }); + + it("marks a sponsored approval as exported when the payment request then fails", async () => { + const { key, address, broadcast, challenge } = harness("sponsored"); + const sign = vi.fn(async (tx: unknown) => tronSignStrategy.sign(key, tx)); + const signTypedData = vi.fn(async (p: TypedDataPayload) => + tronSignStrategy.signTypedData(key, p), + ); + const fetcher = vi.fn(async (request, init) => { + const req = new Request(request, init); + if (!req.headers.get("payment-signature")) + return Response.json(challenge, { + status: 402, + headers: { + "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }, + }); + return new Response("boom", { status: 500 }); + }); + const client = new X402PaymentClient( + { + assertCanSign() {}, + resolve: () => ({ kind: "software", address, sign, signTypedData }), + } as never, + fetcher, + ); + const error = await client + .pay({ activeAccount: "payer", timeoutMs: 1000, emit() {}, warn() {} } as never, net, input) + .catch((e) => e); + expect(broadcast).toHaveLength(0); + expect(error).toMatchObject({ + details: { approval: { token: USDT_NILE, spender: PERMIT2_NILE, status: "exported" } }, + }); + expect(error.details.approval.txId).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +it("reports the SDK's default $1 ceiling as amount_exceeds_limit before anything is signed", async () => { + const key = Wallet.createRandom().privateKey; + const address = TronWeb.address.fromPrivateKey(key.slice(2)) as string; + const rpc = vi.spyOn(providers.HttpProvider.prototype, "request"); + const sign = vi.fn(); + const signTypedData = vi.fn(); + const challenge = { + x402Version: 2, + resource: { url: "https://payment.example" }, + accepts: [ + { + network: "tron:0xcd8690dc", + scheme: "exact", + amount: "2000000", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }, + ], + }; + const fetcher = vi.fn(async () => + Response.json(challenge, { + status: 402, + headers: { "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64") }, + }), + ); + const client = new X402PaymentClient( + { + assertCanSign() {}, + resolve: () => ({ kind: "software", address, sign, signTypedData }), + } as never, + fetcher, + ); + await expect( + client.pay( + { activeAccount: "payer", timeoutMs: 1000, emit() {}, warn() {} } as never, + { + id: "tron:3448148188", + chainId: "3448148188", + family: "tron", + httpEndpoint: "http://127.0.0.1:1", + } as never, + { url: "https://payment.example", method: "GET", headers: [], token: "USDT" }, + ), + ).rejects.toMatchObject({ + code: "amount_exceeds_limit", + details: { paymentStatus: "not_sent", retryPayment: false }, + }); + expect(sign).not.toHaveBeenCalled(); + expect(signTypedData).not.toHaveBeenCalled(); + expect(rpc).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledOnce(); +}); diff --git a/ts/src/adapters/outbound/x402/gasfree-relay.test.ts b/ts/src/adapters/outbound/x402/gasfree-relay.test.ts index d499fd055..87849f441 100644 --- a/ts/src/adapters/outbound/x402/gasfree-relay.test.ts +++ b/ts/src/adapters/outbound/x402/gasfree-relay.test.ts @@ -54,3 +54,75 @@ it.each([ expect.objectContaining({ code: "invalid_value" }), ); }); + +/** + * A selected relay's failures used to collapse into one `provider_error`, so a script could not + * tell a rate limit from a timeout from a malformed answer. Each keeps its class and the safe + * retry metadata; none echoes the relay's text, and none falls back to another relay. + */ +const relay = (fetcher: typeof fetch, timeout = 1000) => + gasfreeRelayClient(network, "https://relay.example", {}, timeout, fetcher)!; + +it("classifies HTTP 429 as provider_rate_limited with a numeric Retry-After only", async () => { + const fetcher = vi.fn( + async () => new Response("SECRET", { status: 429, headers: { "retry-after": "7" } }), + ); + await expect(relay(fetcher as typeof fetch).getProviders()).rejects.toMatchObject({ + code: "provider_rate_limited", + details: { httpStatus: 429, retryAfterSeconds: 7, retryPayment: false }, + }); + const bad = vi.fn( + async () => new Response("SECRET", { status: 429, headers: { "retry-after": "Wed, 21 Oct" } }), + ); + const error = await relay(bad as typeof fetch) + .getProviders() + .catch((e) => e); + expect(error.details).not.toHaveProperty("retryAfterSeconds"); + expect(JSON.stringify(error)).not.toContain("SECRET"); +}); + +it("keeps other HTTP failures as provider_error with the status, without the body", async () => { + const fetcher = vi.fn(async () => new Response("SECRET", { status: 503 })); + const error = await relay(fetcher as typeof fetch) + .getProviders() + .catch((e) => e); + expect(error).toMatchObject({ + code: "provider_error", + details: { httpStatus: 503, retryPayment: false }, + }); + expect(JSON.stringify(error)).not.toContain("SECRET"); +}); + +it("keeps a transport timeout as timeout", async () => { + const fetcher = vi.fn( + (_url: unknown, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal!.reason)); + }), + ); + await expect(relay(fetcher as unknown as typeof fetch, 5).getProviders()).rejects.toMatchObject({ + code: "timeout", + details: { retryPayment: false }, + }); +}); + +it("keeps an oversized body as response_too_large", async () => { + const fetcher = vi.fn(async () => new Response("x".repeat(11 * 1024 * 1024), { status: 200 })); + await expect(relay(fetcher as typeof fetch).getProviders()).rejects.toMatchObject({ + code: "response_too_large", + details: { retryPayment: false }, + }); +}); + +it.each([ + ["not JSON", () => new Response("", { status: 200 })], + ["a non-200 envelope code", () => Response.json({ code: 500, data: {} })], + ["a missing data object", () => Response.json({ code: 200, data: [] })], +])("reports %s as invalid_x402_response", async (_label, make) => { + const fetcher = vi.fn(async () => make()); + await expect(relay(fetcher as typeof fetch).getProviders()).rejects.toMatchObject({ + code: "invalid_x402_response", + details: { retryPayment: false }, + }); + expect(fetcher).toHaveBeenCalledTimes(1); +}); diff --git a/ts/src/adapters/outbound/x402/gasfree-relay.ts b/ts/src/adapters/outbound/x402/gasfree-relay.ts index 45ea47fb9..7fc9f78d0 100644 --- a/ts/src/adapters/outbound/x402/gasfree-relay.ts +++ b/ts/src/adapters/outbound/x402/gasfree-relay.ts @@ -5,8 +5,9 @@ import { type GasFreeProvider, } from "@bankofai/x402-tron/gasfree"; import type { Config, NetworkDescriptor } from "../../../domain/types/index.js"; -import { TransportError, UsageError } from "../../../domain/errors/index.js"; +import { CliError, TransportError, UsageError } from "../../../domain/errors/index.js"; import { fetchBounded } from "../http/http-response.js"; +import { safeRetryAfter } from "./payment-error.js"; /** Read-only relay configuration used by the SDK to build the payer authorization. * Submission remains the protected endpoint's facilitator responsibility. */ @@ -97,29 +98,62 @@ class SelectedRelay extends GasFreeAPIClient { headers.Timestamp = timestamp; headers.Authorization = `ApiKey ${this.credentials.key}:${signature}`; } + // Each failure keeps its class so a caller can act on it (wait, shrink, fix the relay); + // none echoes the relay's text, and none falls back to another relay. + let response: Response; try { - const response = await fetchBounded( + response = await fetchBounded( this.fetcher, target.toString(), { headers, redirect: "error" }, this.timeout, ); - if (!response.ok) throw new Error(); - const result = (await response.json()) as { code?: unknown; data?: unknown }; - if ( - result.code !== 200 || - !result.data || - typeof result.data !== "object" || - Array.isArray(result.data) - ) - throw new Error(); - return result.data as Record; - } catch { + } catch (error) { + // fetchBounded already typed timeouts, oversized bodies and refused redirects. + if (error instanceof CliError) { + throw new TransportError(error.code, `Selected GasFree relay: ${error.message}`, { + ...error.details, + retryPayment: false, + }); + } throw new TransportError( "provider_error", "Selected GasFree relay request failed; no fallback was attempted", { retryPayment: false }, ); } + if (!response.ok) { + const limited = response.status === 429; + throw new TransportError( + limited ? "provider_rate_limited" : "provider_error", + limited + ? "Selected GasFree relay is rate limited; wait before retrying" + : `Selected GasFree relay returned HTTP ${response.status}; no fallback was attempted`, + { + httpStatus: response.status, + ...(limited ? safeRetryAfter(response.headers.get("retry-after")) : {}), + retryPayment: false, + }, + ); + } + let result: { code?: unknown; data?: unknown } | undefined; + try { + result = (await response.json()) as { code?: unknown; data?: unknown }; + } catch { + /* Not JSON: reported below as an invalid response. */ + } + if ( + result?.code !== 200 || + !result.data || + typeof result.data !== "object" || + Array.isArray(result.data) + ) { + throw new TransportError( + "invalid_x402_response", + "Selected GasFree relay returned an unexpected response; no fallback was attempted", + { retryPayment: false }, + ); + } + return result.data as Record; } } diff --git a/ts/src/adapters/outbound/x402/payment-client.test.ts b/ts/src/adapters/outbound/x402/payment-client.test.ts index d22beb5f2..494d6855d 100644 --- a/ts/src/adapters/outbound/x402/payment-client.test.ts +++ b/ts/src/adapters/outbound/x402/payment-client.test.ts @@ -172,6 +172,59 @@ describe("X402PaymentClient", () => { }), ).rejects.toMatchObject({ code: "output_exists" }); }); + + // A pre-existing --out is knowable before any request: refusing it only after the paid + // response arrives means the user has paid for bytes that were then thrown away. + it.each(["exists", "io"])( + "refuses an unusable output file (%s) before sending any request or signing", + async (mode) => { + const directory = await mkdtemp(join(tmpdir(), "wallet-cli-x402-out-")); + const output = join(directory, mode === "io" ? "missing/taken.bin" : "taken.bin"); + if (mode === "exists") await writeFile(output, "keep me"); + const fetcher = vi.fn(); + const paidFetch = vi.fn(); + const localResolver = { assertCanSign: vi.fn(), resolve: vi.fn(() => signer) } as never; + const client = new X402PaymentClient( + localResolver, + fetcher as typeof fetch, + vi.fn(async () => paidFetch as typeof fetch), + ); + await expect( + client.pay(scope, net, { + url: "https://api.example/file", + method: "GET", + headers: [], + out: output, + }), + ).rejects.toMatchObject({ + code: mode === "exists" ? "output_exists" : "io_error", + details: { paymentStatus: "not_sent" }, + }); + expect(fetcher).not.toHaveBeenCalled(); + expect(paidFetch).not.toHaveBeenCalled(); + if (mode === "exists") expect(await readFile(output, "utf8")).toBe("keep me"); + await rm(directory, { recursive: true }); + }, + ); + + it("releases the reserved output file when the request fails before anything is delivered", async () => { + const directory = await mkdtemp(join(tmpdir(), "wallet-cli-x402-out-")); + const output = join(directory, "response.bin"); + const fetcher = vi.fn(async () => { + throw new Error("connection refused"); + }); + const client = new X402PaymentClient(resolver, fetcher as typeof fetch); + await expect( + client.pay(scope, net, { + url: "https://api.example/file", + method: "GET", + headers: [], + out: output, + }), + ).rejects.toBeTruthy(); + await expect(readFile(output)).rejects.toMatchObject({ code: "ENOENT" }); + await rm(directory, { recursive: true }); + }); }); it.each([ @@ -313,63 +366,44 @@ it("bounds oversized 402 bodies before the SDK or signer handles them", async () expect(local.resolve).not.toHaveBeenCalled(); }); -it.each(["json", "exists", "io"])( - "retains settlement when response processing fails: %s", - async (mode) => { - const directory = await mkdtemp(join(tmpdir(), "wallet-cli-settled-")); - const out = join(directory, mode === "io" ? "missing/out" : "out"); - const transaction = "0x" + "a".repeat(64); - const paidFetch = vi.fn( - async () => - new Response(mode === "json" ? "{" : "ok", { - headers: { - "content-type": mode === "json" ? "application/json" : "text/plain", - "payment-response": Buffer.from( - JSON.stringify({ - success: true, - network: net.id, - transaction, - secret: "do-not-copy", - }), - ).toString("base64"), - }, - }), - ); - const client = new X402PaymentClient(resolver, globalThis.fetch, async () => paidFetch); - try { - if (mode === "exists") await writeFile(out, "original"); - const error = await client - .pay(scope, net, { - url: "https://api.example/paid", - method: "GET", - headers: [], - ...(mode === "json" ? {} : { out }), - }) - .catch((error) => error); - expect(error).toMatchObject({ - code: - mode === "json" - ? "invalid_x402_response" - : mode === "exists" - ? "output_exists" - : "io_error", - details: { - paymentStatus: "settled", - retryPayment: false, - txHash: transaction, - settled: true, - paymentResponse: { success: true, network: net.id, transaction }, - payer: { address: signer.address }, +// Output-file conflicts are now refused before payment (see above); a malformed paid body is +// the failure that can only be discovered after settlement, and its evidence must survive. +it("retains settlement when the paid response is malformed JSON", async () => { + const transaction = "0x" + "a".repeat(64); + const paidFetch = vi.fn( + async () => + new Response("{", { + headers: { + "content-type": "application/json", + "payment-response": Buffer.from( + JSON.stringify({ + success: true, + network: net.id, + transaction, + secret: "do-not-copy", + }), + ).toString("base64"), }, - }); - expect(JSON.stringify(error)).not.toContain("do-not-copy"); - expect(paidFetch).toHaveBeenCalledOnce(); - if (mode === "exists") expect(await readFile(out, "utf8")).toBe("original"); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }, -); + }), + ); + const client = new X402PaymentClient(resolver, globalThis.fetch, async () => paidFetch); + const error = await client + .pay(scope, net, { url: "https://api.example/paid", method: "GET", headers: [] }) + .catch((error) => error); + expect(error).toMatchObject({ + code: "invalid_x402_response", + details: { + paymentStatus: "settled", + retryPayment: false, + txHash: transaction, + settled: true, + paymentResponse: { success: true, network: net.id, transaction }, + payer: { address: signer.address }, + }, + }); + expect(JSON.stringify(error)).not.toContain("do-not-copy"); + expect(paidFetch).toHaveBeenCalledOnce(); +}); it.each([ [ diff --git a/ts/src/adapters/outbound/x402/payment-client.ts b/ts/src/adapters/outbound/x402/payment-client.ts index f28be45a7..169a5abd0 100644 --- a/ts/src/adapters/outbound/x402/payment-client.ts +++ b/ts/src/adapters/outbound/x402/payment-client.ts @@ -15,18 +15,29 @@ import { decodePaymentResponseHeader, } from "@bankofai/x402-fetch"; import { registerExactEvmScheme } from "@bankofai/x402-evm/exact/client"; -import { createClientTronSigner, registerToken, type ClientTronSigner } from "@bankofai/x402-tron"; +import { + createClientTronSigner, + registerToken, + PERMIT2_ADDRESSES, + type ClientTronSigner, +} from "@bankofai/x402-tron"; import { registerExactTronScheme } from "@bankofai/x402-tron/exact/client"; import { registerExactGasFreeTronScheme } from "@bankofai/x402-tron/gasfree/client"; import type { ClientEvmSigner } from "@bankofai/x402-evm"; import type { Network } from "@bankofai/x402-core/types"; import { decodePaymentRequiredHeader } from "@bankofai/x402-core/http"; -import { writeFile } from "node:fs/promises"; +import { open, unlink, type FileHandle } from "node:fs/promises"; import type { X402PayInput, X402PaymentPort } 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, Signer } from "../../../domain/types/index.js"; -import { ExecutionError, TransportError, UsageError } from "../../../domain/errors/index.js"; +import { + ExecutionError, + TransportError, + UsageError, + type CliError, +} from "../../../domain/errors/index.js"; +import type { SignedApproval } from "../../../application/contracts/x402-payer.js"; import { normalizeTypedData } from "../../../domain/typed-data/index.js"; import { toX402Wallet } from "./signer-bridge.js"; import { createPayerSigner } from "../../../application/services/x402/payer-signer.js"; @@ -91,9 +102,13 @@ export class X402PaymentClient implements X402PaymentPort { type: "activity", message: "Requesting the resource and checking payment requirements…", }); - const authorization = { signed: false }; + const authorization: PaymentAuthorization = { signed: false }; let phase: PaymentPhase = "request"; + let out: OutputReservation | undefined; try { + // An existing --out is knowable now; discovering it after settlement means paying for bytes + // that are then discarded. Reserve the target exclusively before the first request. + if (input.out !== undefined) out = await reserveOutput(input.out); if (this.paidFetchFactory && !input.expectedPayTo && input.exactAmount === undefined) { authorization.signed = true; // External fetch factories own their signing lifecycle. const signer = this.resolveSigner(scope, network); @@ -105,25 +120,13 @@ export class X402PaymentClient implements X402PaymentPort { } catch (error) { throw settlementError(error, response, toX402Network(network), signer); } - return await this.readResponse( - input.url, - bounded, - signer, - input.out, - toX402Network(network), - ); + return await this.readResponse(input.url, bounded, signer, out, toX402Network(network)); } const boundedFetch = this.boundedFetch(scope, network); const initial = await boundedFetch(input.url, requestInit); if (initial.status !== 402) - return await this.readResponse( - input.url, - initial, - undefined, - input.out, - toX402Network(network), - ); + return await this.readResponse(input.url, initial, undefined, out, toX402Network(network)); phase = "challenge"; scope.emit({ type: "activity", @@ -147,15 +150,20 @@ export class X402PaymentClient implements X402PaymentPort { ); phase = "payment_request"; scope.emit({ type: "activity", message: "Preparing payment for the service…" }); - return await this.readResponse( + const result = await this.readResponse( input.url, await paidFetch(input.url, requestInit), signer, - input.out, + out, toX402Network(network), ); + return authorization.approval ? { ...result, approval: authorization.approval } : result; } catch (error) { - throw authorization.signed ? sdkPaymentError(error, phase) : unsentPaymentError(error, phase); + await out?.release(); + const classified = authorization.signed + ? sdkPaymentError(error, phase) + : unsentPaymentError(error, phase); + throw withApproval(classified, authorization.approval); } } @@ -163,7 +171,7 @@ export class X402PaymentClient implements X402PaymentPort { url: string, response: Response, signer?: Pick, - out?: string, + out?: OutputReservation, expectedNetwork?: string, ) { const declaredLength = Number(response.headers.get("content-length")); @@ -251,8 +259,8 @@ export class X402PaymentClient implements X402PaymentPort { ); } if (out) { - await writeOutput(out, bytes); - return { ...base, output: { path: out, bytes: bytes.byteLength } }; + await out.write(bytes); + return { ...base, output: { path: out.path, bytes: bytes.byteLength } }; } const text = new TextDecoder().decode(bytes); const contentType = response.headers.get("content-type") ?? ""; @@ -286,26 +294,46 @@ export class X402PaymentClient implements X402PaymentPort { input: X402PayInput, initial: Response, relay: ReturnType, - authorization: { signed: boolean }, + authorization: PaymentAuthorization, ): Promise { - let maxGasfreeFeeRaw = input.maxGasfreeFeeRaw; - if (maxGasfreeFeeRaw === undefined && input.maxGasfreeFee !== undefined) { - const challenge = await decodeChallenge(initial.clone()); - const selected = selectMatching(challenge.accepts, network, input)[0]!; - maxGasfreeFeeRaw = decimalToRaw( - input.maxGasfreeFee, - paymentDecimals(network.id, selected.asset, input.decimals), - ); - } + // A human-unit fee ceiling is converted by the bridge against the token the SDK actually + // puts in the PermitTransfer — not against whichever candidate the CLI matched first, since + // the SDK's own selection (and its default spend control) may settle on another asset. const wallet = toX402Wallet(signer, { family: network.family, - maxGasfreeFeeRaw, + maxGasfreeFeeRaw: input.maxGasfreeFeeRaw, + maxGasfreeFee: input.maxGasfreeFee, + gasfreeFeeDecimals: (token) => metadata(network.id, token)?.decimals, + // The SDK has the RPC build its one-time Permit2 approve; the bridge signs nothing else. + ...(network.family === "tron" && PERMIT2_ADDRESSES[toX402Network(network)] + ? { + approveIntent: { + spender: PERMIT2_ADDRESSES[toX402Network(network)]!, + tokens: Object.values(X402_TOKENS[network.id] ?? {}).map((t) => t.address), + }, + } + : {}), + onApprovalSigned: (approval) => { + // Before the payment authorization exists the SDK broadcasts the approve itself and waits + // for its receipt (auto); after it, the approve is exported inside the payment package for + // the endpoint to sponsor. Either way the signature has left the wallet. + authorization.approval = { + ...approval, + status: authorization.signed ? "exported" : "submitted", + }; + scope.warn( + `Permit2 approval ${approval.txId} signed for ${approval.token} (unlimited allowance to ${approval.spender}); check it on-chain before approving again`, + ); + }, warn: (message) => scope.warn(message), }); const bridge = { ...wallet, async signTypedData(payload: unknown) { scope.emit({ type: "activity", message: "Signing payment authorization…" }); + // The SDK only asks for the payment authorization once its auto approve has confirmed. + if (authorization.approval?.status === "submitted") + authorization.approval = { ...authorization.approval, status: "confirmed" }; try { const signed = await wallet.signTypedData(normalizeTypedData(payload)); authorization.signed = true; @@ -324,7 +352,9 @@ export class X402PaymentClient implements X402PaymentPort { const signed = await wallet.signTransaction(tx).catch((error: unknown) => { throw sdkPaymentError(error, "sign"); }); - authorization.signed = true; + // A TRON approve is not the payment: its evidence is tracked in `approval`, and a later + // failure is still "no payment was sent". An EVM signed transaction may be the payment. + if (network.family === "evm") authorization.signed = true; scope.emit({ type: "activity", message: "Payment transaction signed; continuing payment verification and settlement…", @@ -424,15 +454,57 @@ export class X402PaymentClient implements X402PaymentPort { } } -async function writeOutput(path: string, bytes: Uint8Array): Promise { +interface PaymentAuthorization { + /** A payment authorization (typed data or EVM payment transaction) has been signed. */ + signed: boolean; + /** The one-time Permit2 approve, once signed, and how far the SDK has taken it. */ + approval?: SignedApproval & { status: "submitted" | "confirmed" | "exported" }; +} + +/** Never lose the approve's evidence to whatever failed after it. */ +function withApproval(error: CliError, approval: PaymentAuthorization["approval"]): CliError { + if (!approval) return error; + const ErrorType = error.kind === "usage" ? UsageError : TransportError; + return new ErrorType(error.code, error.message, { ...error.details, approval }); +} + +interface OutputReservation { + readonly path: string; + /** Write the delivered bytes; the reservation is then final and `release` keeps the file. */ + write(bytes: Uint8Array): Promise; + /** Remove the placeholder if nothing was written to it. */ + release(): Promise; +} + +/** Exclusively create `path` now (`wx`), so a taken target fails before any request is made. */ +async function reserveOutput(path: string): Promise { + let handle: FileHandle; try { - await writeFile(path, bytes, { flag: "wx", mode: 0o600 }); + handle = await open(path, "wx", 0o600); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") { throw new UsageError("output_exists", `output already exists: ${path}`); } - throw new ExecutionError("io_error", `could not write x402 response to ${path}`); + throw new ExecutionError("io_error", `could not create x402 output ${path}`); } + let written = false; + return { + path, + async write(bytes) { + try { + await handle.writeFile(bytes); + written = true; + await handle.close(); + } catch { + throw new ExecutionError("io_error", `could not write x402 response to ${path}`); + } + }, + async release() { + if (written) return; + await handle.close().catch(() => {}); + await unlink(path).catch(() => {}); + }, + }; } function metadata(network: string, asset: string) { diff --git a/ts/src/adapters/outbound/x402/payment-error.test.ts b/ts/src/adapters/outbound/x402/payment-error.test.ts index 3b3be56ea..e70ac169e 100644 --- a/ts/src/adapters/outbound/x402/payment-error.test.ts +++ b/ts/src/adapters/outbound/x402/payment-error.test.ts @@ -122,3 +122,29 @@ it("uses not_sent only when the caller has no authorization or transaction evide details: { paymentStatus: "unknown" }, }); }); + +/** + * Without an explicit --max-amount the SDK's own $1-per-payment spend control stays on, and it + * rejects inside `selectPaymentRequirements` — before any payment is created — with a plain + * Error. That is a deterministic policy refusal the caller can act on, not an upstream failure. + */ +it.each([ + [ + "All payment requirements were rejected by spendControls.maxAmountPerPayment ($1, including USDT). Raise maxAmountPerPayment, set it to false to disable, set allowedAssets[].maxAmountPerPayment for a per-asset atomic cap, or set spendControls: false to disable all spend controls.", + "amount_exceeds_limit", + ], + [ + "All payment requirements were rejected by spendControls: only default assets or entries in spendControls.allowedAssets are allowed. Add an allowedAssets entry for non-default tokens, set allowedAssets: true, or set spendControls: false.", + "no_matching_requirement", + ], +])("classifies the SDK's spend-control refusal as a typed, not-sent error", (message, code) => { + const error = sdkPaymentError( + new Error(`Failed to create payment payload: ${message}`), + "payment_request", + ); + expect(error).toMatchObject({ + code, + details: { phase: "payment_request", paymentStatus: "not_sent", retryPayment: false }, + }); + expect(error.message).toContain("--max-amount"); +}); diff --git a/ts/src/adapters/outbound/x402/payment-error.ts b/ts/src/adapters/outbound/x402/payment-error.ts index cc4f84eee..74f99e085 100644 --- a/ts/src/adapters/outbound/x402/payment-error.ts +++ b/ts/src/adapters/outbound/x402/payment-error.ts @@ -1,4 +1,9 @@ -import { CliError, TransportError, UsageError } from "../../../domain/errors/index.js"; +import { + CliError, + ExecutionError, + TransportError, + UsageError, +} from "../../../domain/errors/index.js"; // Only emit our own messages. SDK/provider messages can contain credentials and URLs. const transportCodes = ["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"] as const; @@ -152,6 +157,19 @@ export function sdkPaymentError(error: unknown, phase?: PaymentPhase): CliError retryPayment: false, }, ); + // The SDK's built-in spend control (a $1 per-payment ceiling on its default assets, active + // whenever no wallet-cli ceiling was given) refuses inside requirement selection, before any + // payment exists. A deterministic policy refusal, not an upstream fault: keep it typed. + if (/^All payment requirements were rejected by spendControls/.test(cause)) { + const overLimit = cause.includes("maxAmountPerPayment"); + 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", + { ...(phase ? { phase } : {}), paymentStatus: "not_sent", retryPayment: false }, + ); + } let reason: string | undefined; if (/^Insufficient balance in GasFree wallet /.test(cause)) { reason = "gasfree_insufficient_balance"; @@ -217,7 +235,8 @@ function candidateEvidence(value?: Record) { }; } -function safeRetryAfter(value: unknown): { retryAfterSeconds?: number } { +/** Only a plain number of seconds is passed on; an HTTP-date or anything else is dropped. */ +export function safeRetryAfter(value: unknown): { retryAfterSeconds?: number } { if ( (typeof value === "string" && /^\d{1,9}$/.test(value)) || (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 999999999) diff --git a/ts/src/adapters/outbound/x402/signer-bridge.test.ts b/ts/src/adapters/outbound/x402/signer-bridge.test.ts index a040058ac..20ffaf256 100644 --- a/ts/src/adapters/outbound/x402/signer-bridge.test.ts +++ b/ts/src/adapters/outbound/x402/signer-bridge.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { toX402Wallet } from "./signer-bridge.js"; import type { PayerSigner } from "../../../application/contracts/x402-payer.js"; import type { TypedDataPayload } from "../../../domain/types/index.js"; +import { tronHexAddress } from "../../../domain/address/index.js"; const EVM_ADDRESS = "0xaB5801a7D398351b8bE11C439e05C5B3259aeC9B"; const TRON_ADDRESS = "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ"; @@ -221,13 +222,6 @@ describe("toX402Wallet", () => { expect(payer.signTypedData).not.toHaveBeenCalled(); }); - it("passes a TRON transaction through untouched", async () => { - const payer = payerOf(TRON_ADDRESS); - const tx = { raw_data: {}, txID: "abc" }; - expect(await toX402Wallet(payer, { family: "tron" }).signTransaction(tx)).toEqual(tx); - expect(payer.signTransaction).toHaveBeenCalledWith(tx); - }); - it("unwraps an EVM signature to the raw serialisation x402 broadcasts", async () => { const payer: PayerSigner = { address: EVM_ADDRESS, @@ -277,3 +271,219 @@ it("warns about an uncapped high GasFree fee before requesting the signature", a vi.mocked(payer.signTypedData).mock.invocationCallOrder[0]!, ); }); + +/** + * A human-unit fee ceiling (`--max-gasfree-fee 1`) has no meaning until the token is known, and + * the token is only known once the SDK has chosen the requirement and filled the PermitTransfer. + * Converting the ceiling up front, with whichever candidate the CLI saw first, let a 1 USDD + * (18 decimals) ceiling authorise 1.3 USDT (6 decimals) of fees. + */ +describe("toX402Wallet converts a human fee ceiling with the SIGNED token's precision", () => { + const USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + const USDT_HEX = "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"; // the same address as TIP-712 spells it + const withToken = (maxFee: string, token = USDT_HEX): TypedDataPayload => ({ + ...permitPayload(TRON_ADDRESS, maxFee), + message: { user: TRON_ADDRESS, maxFee, token }, + }); + const policy = { + family: "tron" as const, + maxGasfreeFee: "1", + gasfreeFeeDecimals: (token: string) => (token === USDT ? 6 : undefined), + }; + + it("refuses a fee above the ceiling in the signed token's units", async () => { + const payer = payerOf(TRON_ADDRESS, "sig", "PermitTransfer"); + const wallet = toX402Wallet(payer, policy); + await expect(wallet.signTypedData(withToken("1300000"))).rejects.toMatchObject({ + code: "fee_cap_exceeded", + details: { fee: "1300000", cap: "1000000" }, + }); + expect(payer.signTypedData).not.toHaveBeenCalled(); + }); + + it("signs a fee within the ceiling in the signed token's units", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), policy); + await expect(wallet.signTypedData(withToken("1000000"))).resolves.toBeDefined(); + }); + + it("refuses rather than guess when the signed token's precision is unknown", async () => { + const payer = payerOf(TRON_ADDRESS, "sig", "PermitTransfer"); + const wallet = toX402Wallet(payer, policy); + await expect( + wallet.signTypedData(withToken("1", "0x0000000000000000000000000000000000000001")), + ).rejects.toMatchObject({ code: "fee_cap_exceeded" }); + expect(payer.signTypedData).not.toHaveBeenCalled(); + }); +}); + +/** + * A payment authorization carries its own deadline (Permit2 `deadline`, EIP-3009 `validBefore`, + * GasFree `deadline`). The SDK computes it before waiting for an approve to confirm, so by the + * time it asks for the signature — or by the time a device returns one — the window can already + * be gone. An expired authorization must be refused before it reaches the device, and a signature + * that expired while the device was open must not be handed on to be sent. + */ +describe("toX402Wallet refuses expired payment authorizations", () => { + const EVM = "0x1111111111111111111111111111111111111111"; + const NOW = 1_700_000_000; + const permit2 = (deadline: number): TypedDataPayload => ({ + domain: { name: "Permit2" }, + types: { + PermitWitnessTransferFrom: [ + { name: "spender", type: "address" }, + { name: "deadline", type: "uint256" }, + ], + }, + primaryType: "PermitWitnessTransferFrom", + message: { spender: EVM, deadline: String(deadline) }, + }); + const eip3009 = (validBefore: number): TypedDataPayload => ({ + domain: { name: "USD Coin" }, + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "validBefore", type: "uint256" }, + ], + }, + primaryType: "TransferWithAuthorization", + message: { from: EVM, validBefore: String(validBefore) }, + }); + + it("refuses a Permit2 authorization whose deadline has passed, before signing", async () => { + const payer = payerOf(EVM, "sig", "PermitWitnessTransferFrom"); + const wallet = toX402Wallet(payer, { family: "evm", now: () => NOW }); + await expect(wallet.signTypedData(permit2(NOW - 1))).rejects.toMatchObject({ + code: "tx_expired", + }); + expect(payer.signTypedData).not.toHaveBeenCalled(); + }); + + it("refuses an EIP-3009 authorization whose validBefore has passed", async () => { + const payer = payerOf(EVM, "sig", "TransferWithAuthorization"); + const wallet = toX402Wallet(payer, { family: "evm", now: () => NOW }); + await expect(wallet.signTypedData(eip3009(NOW))).rejects.toMatchObject({ code: "tx_expired" }); + }); + + it("refuses a GasFree PermitTransfer whose deadline has passed", async () => { + const payer = payerOf(TRON_ADDRESS, "sig", "PermitTransfer"); + const wallet = toX402Wallet(payer, { family: "tron", now: () => NOW }); + const payload = { + ...permitPayload(TRON_ADDRESS, "1"), + message: { user: TRON_ADDRESS, maxFee: "1", deadline: String(NOW - 5) }, + }; + await expect(wallet.signTypedData(payload)).rejects.toMatchObject({ code: "tx_expired" }); + }); + + it("signs an authorization whose deadline is still ahead", async () => { + const wallet = toX402Wallet(payerOf(EVM, "sig", "PermitWitnessTransferFrom"), { + family: "evm", + now: () => NOW, + }); + await expect(wallet.signTypedData(permit2(NOW + 30))).resolves.toBe("0xsig"); + }); + + it("does not hand on a signature whose deadline passed while the device was open", async () => { + let clock = NOW; + const payer = payerOf(EVM, "sig", "PermitWitnessTransferFrom"); + (payer.signTypedData as ReturnType).mockImplementation(async () => { + clock = NOW + 61; + return { signature: "0xsig", digest: "0xdig", primaryType: "PermitWitnessTransferFrom" }; + }); + const wallet = toX402Wallet(payer, { family: "evm", now: () => clock }); + await expect(wallet.signTypedData(permit2(NOW + 60))).rejects.toMatchObject({ + code: "tx_expired", + }); + }); +}); + +/** + * The x402 TRON approve is the one transaction this wallet signs that a remote RPC built. The + * generic integrity check proves the JSON and the bytes are the same transaction — not that it is + * the approve that was asked for. A compromised RPC can return any self-consistent, owner-only + * transaction and the signature is handed over. The bridge therefore only ever signs the exact + * `approve(Permit2, MaxUint256)` the SDK requested, checked before any device prompt. + */ +describe("toX402Wallet signs only the Permit2 approve it was asked for (TRON)", () => { + const PERMIT2 = "TTJxU3P8rHycAyFY4kVtGNfmnMH4ezcuM9"; + const USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + const hex20 = (base58: string) => tronHexAddress(base58).slice(2); + const approveData = (spender: string, amount = "f".repeat(64)) => + `095ea7b3${"0".repeat(24)}${hex20(spender)}${amount}`; + const approveTx = ( + over: Record = {}, + valueOver: Record = {}, + ) => ({ + txID: "ab".repeat(32), + raw_data_hex: "0a", + raw_data: { + fee_limit: 100_000_000, + contract: [ + { + type: "TriggerSmartContract", + parameter: { + type_url: "type.googleapis.com/protocol.TriggerSmartContract", + value: { + owner_address: tronHexAddress(TRON_ADDRESS), + contract_address: tronHexAddress(USDT), + data: approveData(PERMIT2), + ...valueOver, + }, + }, + }, + ], + ...over, + }, + }); + const policy = { + family: "tron" as const, + approveIntent: { spender: PERMIT2, tokens: [USDT] }, + }; + + it("signs the approve the SDK asked for, passing the transaction through untouched", async () => { + const payer = payerOf(TRON_ADDRESS); + const tx = approveTx(); + expect(await toX402Wallet(payer, policy).signTransaction(tx)).toEqual(tx); + expect(payer.signTransaction).toHaveBeenCalledWith(tx); + }); + + it.each([ + [ + "an owner-only contract type substituted by the RPC", + { + contract: [ + { + type: "WithdrawBalanceContract", + parameter: { value: { owner_address: tronHexAddress(TRON_ADDRESS) } }, + }, + ], + }, + {}, + ], + [ + "a second contract appended", + { contract: [approveTx().raw_data.contract[0], approveTx().raw_data.contract[0]] }, + {}, + ], + ["a different spender", {}, { data: approveData(TRON_ADDRESS) }], + ["an amount other than MaxUint256", {}, { data: approveData(PERMIT2, "0".repeat(63) + "1") }], + ["a token this wallet does not know", {}, { contract_address: tronHexAddress(PERMIT2) }], + ["a different owner", {}, { owner_address: tronHexAddress(PERMIT2) }], + ["TRX attached to the call", {}, { call_value: 1 }], + ["a fee limit above the approve budget", { fee_limit: 100_000_001 }, {}], + ["no fee limit at all", { fee_limit: undefined }, {}], + ])("refuses %s before the signer is asked", async (_label, over, valueOver) => { + const payer = payerOf(TRON_ADDRESS); + await expect( + toX402Wallet(payer, policy).signTransaction(approveTx(over, valueOver)), + ).rejects.toMatchObject({ code: "signed_payload_mismatch" }); + expect(payer.signTransaction).not.toHaveBeenCalled(); + }); + + it("refuses any TRON transaction when no approve intent was declared", async () => { + const payer = payerOf(TRON_ADDRESS); + await expect( + toX402Wallet(payer, { family: "tron" }).signTransaction(approveTx()), + ).rejects.toMatchObject({ code: "signed_payload_mismatch" }); + expect(payer.signTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/adapters/outbound/x402/signer-bridge.ts b/ts/src/adapters/outbound/x402/signer-bridge.ts index e41f9f203..1f7e2ed91 100644 --- a/ts/src/adapters/outbound/x402/signer-bridge.ts +++ b/ts/src/adapters/outbound/x402/signer-bridge.ts @@ -11,12 +11,17 @@ * guards live here rather than at the call sites. Two of them refuse BEFORE the signature is * requested, so a rejected payment never reaches a device prompt. */ -import type { PayerPolicy, PayerSigner } from "../../../application/contracts/x402-payer.js"; +import type { + PayerPolicy, + PayerSigner, + SignedApproval, +} from "../../../application/contracts/x402-payer.js"; import type { TypedDataPayload, TypedDataSignature } from "../../../domain/types/index.js"; import type { ChainFamily } from "../../../domain/family/chain-family.js"; import { ChainError } from "../../../domain/errors/index.js"; -import { tronHexToBase58 } from "../../../domain/address/index.js"; +import { tronHexToBase58, tronHexAddress } from "../../../domain/address/index.js"; import { resolvePrimaryType } from "../../../domain/typed-data/index.js"; +import { toBaseUnits } from "../../../domain/amounts/index.js"; /** * The wallet an x402 scheme calls. Structural on purpose — see the module comment. TRON's scheme @@ -81,9 +86,11 @@ function assertPayerMatches( function assertFeeWithinCap( payload: TypedDataPayload, primaryType: string, - maxGasfreeFeeRaw?: string, + policy: PayerPolicy, ): void { - if (maxGasfreeFeeRaw === undefined || primaryType !== PERMIT_TRANSFER) return; + if (primaryType !== PERMIT_TRANSFER) return; + const maxGasfreeFeeRaw = feeCapRaw(payload, policy); + if (maxGasfreeFeeRaw === undefined) return; const declared = payload.message.maxFee; let fee: bigint; let cap: bigint; @@ -105,6 +112,69 @@ function assertFeeWithinCap( } } +/** + * The ceiling in base units of the token the payload names. A raw ceiling is used as given; a + * human one is converted with that token's precision, and an unknown token refuses rather than + * guesses — a ceiling that cannot be applied must never be treated as "no ceiling". + */ +function feeCapRaw(payload: TypedDataPayload, policy: PayerPolicy): string | undefined { + if (policy.maxGasfreeFeeRaw !== undefined) return policy.maxGasfreeFeeRaw; + if (policy.maxGasfreeFee === undefined) return undefined; + const token = payload.message.token; + const decimals = + typeof token === "string" ? policy.gasfreeFeeDecimals?.(canonicalTronPayer(token)) : undefined; + if (decimals === undefined) { + throw new ChainError( + "fee_cap_exceeded", + `cannot apply the GasFree fee ceiling: unknown precision for token ${String(token)}`, + ); + } + try { + return toBaseUnits(policy.maxGasfreeFee, decimals, "token", "--max-gasfree-fee"); + } catch { + throw new ChainError( + "fee_cap_exceeded", + `GasFree fee ceiling ${policy.maxGasfreeFee} is not a valid amount for token ${token}`, + ); + } +} + +/** + * Which field bounds the authorization's validity, in unix seconds. Permit2 and GasFree call it + * `deadline`; EIP-3009 calls it `validBefore`. Structs without one carry no deadline to check. + */ +function declaredDeadline(payload: TypedDataPayload, primaryType: string): unknown { + return primaryType === "TransferWithAuthorization" + ? payload.message.validBefore + : payload.message.deadline; +} + +/** + * The SDK fixes the deadline before it waits for an allowance to confirm, so the window can be + * gone by the time it asks for the signature, and again by the time a device returns one. Called + * before signing (nothing reaches the device) and after (nothing expired is handed on to be sent). + */ +function assertNotExpired(payload: TypedDataPayload, primaryType: string, now: number): void { + const declared = declaredDeadline(payload, primaryType); + if (declared === undefined) return; + let deadline: bigint; + try { + deadline = BigInt(declared as string | number | bigint); + } catch { + throw new ChainError( + "tx_expired", + `payment authorization deadline ${String(declared)} is not a whole number`, + ); + } + if (deadline <= BigInt(now)) { + throw new ChainError( + "tx_expired", + "the payment authorization's deadline has passed; it was not signed or sent", + { deadline: deadline.toString(), now: String(now) }, + ); + } +} + /** A signature is only evidence about the struct it was produced for. */ function assertSignedTheRequest(signed: TypedDataSignature, primaryType: string): void { if (signed.primaryType !== primaryType) { @@ -130,6 +200,63 @@ function evmRawTransaction(signed: unknown): string { return raw; } +/** `approve(address,uint256)`; the spender is left-padded to 32 bytes, the amount is 32 bytes. */ +const APPROVE_SELECTOR = "095ea7b3"; +const MAX_UINT256_HEX = "f".repeat(64); +/** The SDK's own fee limit for the approve (100 TRX); anything above it was not built for us. */ +const APPROVE_FEE_LIMIT_SUN = 100_000_000; + +function tronAddressField(value: unknown): string | undefined { + try { + return typeof value === "string" ? tronHexToBase58(value) : undefined; + } catch { + return undefined; + } +} + +/** + * The generic integrity check proves the JSON and the bytes describe the same transaction; this + * proves that transaction is the approve the SDK asked a remote RPC to build. Everything is read + * from `raw_data`, which the integrity check binds to `raw_data_hex` before signing. + */ +function assertApproveIntent( + tx: unknown, + payer: string, + policy: PayerPolicy, +): Omit { + const refuse = (why: string): never => { + throw new ChainError( + "signed_payload_mismatch", + `x402 TRON transaction is not the requested Permit2 approve: ${why}; refusing to sign`, + ); + }; + const intent = policy.approveIntent; + if (!intent) return refuse("no approve was requested"); + const raw = (tx as { raw_data?: { contract?: unknown; fee_limit?: unknown } } | null)?.raw_data; + const contracts = Array.isArray(raw?.contract) ? raw.contract : []; + if (contracts.length !== 1) return refuse(`expected one contract, got ${contracts.length}`); + const contract = contracts[0] as { + type?: unknown; + parameter?: { value?: Record }; + }; + if (contract?.type !== "TriggerSmartContract") + return refuse(`contract type ${String(contract?.type)}`); + const value = contract.parameter?.value ?? {}; + if (tronAddressField(value.owner_address) !== payer) return refuse("owner is not the payer"); + const token = tronAddressField(value.contract_address); + if (token === undefined || !intent.tokens.includes(token)) + return refuse("unknown token contract"); + if (value.call_value !== undefined && Number(value.call_value) !== 0) + return refuse("call_value is not 0"); + const data = typeof value.data === "string" ? value.data.replace(/^0x/, "").toLowerCase() : ""; + const expected = `${APPROVE_SELECTOR}${"0".repeat(24)}${tronHexAddress(intent.spender).slice(2).toLowerCase()}${MAX_UINT256_HEX}`; + if (data !== expected) return refuse("calldata is not approve(Permit2, MaxUint256)"); + const feeLimit = raw?.fee_limit; + if (typeof feeLimit !== "number" || !(feeLimit > 0 && feeLimit <= APPROVE_FEE_LIMIT_SUN)) + return refuse(`fee_limit ${String(feeLimit)} is outside the approve budget`); + return { token, spender: intent.spender, allowance: "unlimited", feeLimitSun: feeLimit }; +} + export function toX402Wallet(payer: PayerSigner, policy: PayerPolicy): X402Wallet { return { address: payer.address, @@ -146,8 +273,12 @@ export function toX402Wallet(payer: PayerSigner, policy: PayerPolicy): X402Walle ); } assertPayerMatches(payload, primaryType, payer.address, policy.family); - assertFeeWithinCap(payload, primaryType, policy.maxGasfreeFeeRaw); - if (primaryType === PERMIT_TRANSFER && policy.maxGasfreeFeeRaw === undefined) { + assertFeeWithinCap(payload, primaryType, policy); + if ( + primaryType === PERMIT_TRANSFER && + policy.maxGasfreeFeeRaw === undefined && + policy.maxGasfreeFee === undefined + ) { const fee = String(payload.message.maxFee); const value = String(payload.message.value); if (/^\d+$/.test(fee) && /^\d+$/.test(value) && BigInt(value) > 0n) { @@ -157,15 +288,23 @@ export function toX402Wallet(payer: PayerSigner, policy: PayerPolicy): X402Walle ); } } + const now = policy.now ?? (() => Math.floor(Date.now() / 1000)); + assertNotExpired(payload, primaryType, now()); const signed = await payer.signTypedData(payload); assertSignedTheRequest(signed, primaryType); + assertNotExpired(payload, primaryType, now()); return prefixedHex(signed.signature); }, async signTransaction(tx) { - const signed = await payer.signTransaction( - policy.family === "evm" ? evmTransactionInput(tx) : tx, - ); - return policy.family === "evm" ? evmRawTransaction(signed) : signed; + if (policy.family === "evm") { + return evmRawTransaction(await payer.signTransaction(evmTransactionInput(tx))); + } + const approval = assertApproveIntent(tx, payer.address, policy); + const signed = await payer.signTransaction(tx); + // The signer has verified txID against raw_data_hex before signing; it is the id to look up. + const txId = (signed as { txID?: unknown })?.txID ?? (tx as { txID?: unknown })?.txID; + if (typeof txId === "string") policy.onApprovalSigned?.({ ...approval, txId }); + return signed; }, }; } diff --git a/ts/src/application/contracts/x402-payer.ts b/ts/src/application/contracts/x402-payer.ts index ff1ef264b..7e18151cc 100644 --- a/ts/src/application/contracts/x402-payer.ts +++ b/ts/src/application/contracts/x402-payer.ts @@ -23,4 +23,31 @@ export interface PayerPolicy { readonly warn?: (message: string) => void; /** GasFree `PermitTransfer.maxFee` ceiling in base units; absent means no ceiling. */ readonly maxGasfreeFeeRaw?: string; + /** + * The same ceiling in human units. It is converted at signing time with the precision of the + * token the payload actually names (`gasfreeFeeDecimals`), never with a candidate chosen + * earlier — the SDK may settle on a different asset than the CLI first matched. + */ + readonly maxGasfreeFee?: string; + /** Precision of a GasFree token by its family-native address; undefined when unknown. */ + readonly gasfreeFeeDecimals?: (token: string) => number | undefined; + /** Wall clock in unix seconds, for the authorization deadline check; defaults to Date.now. */ + readonly now?: () => number; + /** + * The only TRON transaction an x402 flow may sign: `approve(spender, MaxUint256)` on one of + * `tokens`, from the payer. The SDK has a remote RPC build that transaction, so the bridge + * compares what came back with this intent before signing; absent, no TRON transaction is signed. + */ + readonly approveIntent?: { readonly spender: string; readonly tokens: readonly string[] }; + /** Called once the approve has been signed: the evidence a later failure must not lose. */ + readonly onApprovalSigned?: (approval: SignedApproval) => void; +} + +/** What the wallet signed for a TRON Permit2 approve — enough to find and reason about it later. */ +export interface SignedApproval { + readonly txId: string; + readonly token: string; + readonly spender: string; + readonly allowance: "unlimited"; + readonly feeLimitSun: number; } diff --git a/ts/src/application/services/tron-confirmation.test.ts b/ts/src/application/services/tron-confirmation.test.ts index a1f91067f..c8644e808 100644 --- a/ts/src/application/services/tron-confirmation.test.ts +++ b/ts/src/application/services/tron-confirmation.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { stageTronBroadcast } from "./tron-confirmation.js"; +import { stageTronBroadcast, tronConfirmation } from "./tron-confirmation.js"; import type { TransactionScope } from "../contracts/execution-scope.js"; import type { TronGateway, TronTxInfo } from "../ports/chain/tron-gateway.js"; @@ -121,3 +121,47 @@ describe("stageTronBroadcast reports the transaction id we signed", () => { expect(s.warnings).toEqual([]); }); }); + +/** + * A mined transaction is not a successful one. TRON reports contract failures in two places — + * the top-level `result: "FAILED"` and the nested `receipt.result` — and a receipt that carries + * neither says nothing about a smart-contract call's execution. Reading "no failure recorded" as + * "succeeded" reported reverted ERC-8004 writes as confirmed. + */ +describe("tronConfirmation distinguishes mined from succeeded", () => { + it("top-level result FAILED with no nested receipt result → failed", async () => { + const s = scope(); + const out = await stageTronBroadcast( + gateway({ blockNumber: 42, result: "FAILED", receipt: {} }), + s, + { txId: "abc" }, + ); + expect(out.stage).toBe("failed"); + }); + + it("nested receipt result other than SUCCESS/DEFAULT → failed even when the top level is silent", async () => { + const s = scope(); + const out = await stageTronBroadcast( + gateway({ blockNumber: 42, receipt: { result: "17" } }), + s, + { txId: "abc" }, + ); + expect(out.stage).toBe("failed"); + }); + + it("a contract call whose receipt carries no execution result is not confirmed", async () => { + const s = scope({ waitTimeoutMs: 0 }); + const confirm = tronConfirmation(gateway({ blockNumber: 42, receipt: {} }), s, { + requireReceiptResult: true, + }); + expect(await confirm("abc")).toBeUndefined(); + }); + + it("a native transaction may legitimately omit receipt.result and still confirms", async () => { + const s = scope(); + const out = await stageTronBroadcast(gateway({ blockNumber: 42, receipt: {} }), s, { + txId: "abc", + }); + expect(out.stage).toBe("confirmed"); + }); +}); diff --git a/ts/src/application/services/tron-confirmation.ts b/ts/src/application/services/tron-confirmation.ts index cc1f64e0f..592007633 100644 --- a/ts/src/application/services/tron-confirmation.ts +++ b/ts/src/application/services/tron-confirmation.ts @@ -33,20 +33,38 @@ function normalize(info: TronTxInfo): Record { result.exchangeWithdrawnOther = info.exchange_withdraw_another_amount; } if (receipt.result !== undefined) result.result = receipt.result; + // TRON records a failure in two places: the top-level `result: "FAILED"` (with `resMessage`) + // and the nested `receipt.result`. Either one is authoritative; neither being present is not + // evidence of success — see `requireReceiptResult` below. result.failed = - receipt.result !== undefined && receipt.result !== "SUCCESS" && receipt.result !== "DEFAULT"; + info.result === "FAILED" || + (receipt.result !== undefined && receipt.result !== "SUCCESS" && receipt.result !== "DEFAULT"); return result; } +export interface TronConfirmationOptions { + /** + * A smart-contract call always carries `receipt.result` once mined; an info that has a block + * but no execution result is not a confirmation of that call, so keep polling rather than + * defaulting it to success. Native transactions (transfer, stake…) legitimately omit it. + */ + requireReceiptResult?: boolean; +} + export function tronConfirmation( gateway: TronGateway, scope: TransactionScope, + options: TronConfirmationOptions = {}, ): (txId: string) => Promise | undefined> { return async (txId) => { const deadline = Date.now() + Math.max(0, scope.waitTimeoutMs); for (;;) { const info = await gateway.getTransactionInfoById(txId).catch(() => undefined); - if (info?.blockNumber !== undefined) return normalize(info); + if (info?.blockNumber !== undefined) { + const normalized = normalize(info); + if (!options.requireReceiptResult || normalized.failed || normalized.result !== undefined) + return normalized; + } const remaining = deadline - Date.now(); if (remaining <= 0) return undefined; await sleep(Math.min(1500, remaining)); diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index 624fa3cb2..adbba323d 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -82,7 +82,7 @@ export class TronContractService { broadcaster: gateway, ...transactionMode(input), ...tronTransactionHooks(gateway), - confirm: tronConfirmation(gateway, scope), + confirm: tronConfirmation(gateway, scope, { requireReceiptResult: true }), build: async (from) => gateway.triggerSmartContract(from, input.contract, input.method, input.parameters, { feeLimit: input.feeLimit, @@ -144,7 +144,7 @@ export class TronContractService { return prepared; }, signerOptions: { requireSoftware: true }, - confirm: tronConfirmation(gateway, scope), + confirm: tronConfirmation(gateway, scope, { requireReceiptResult: true }), build: (from) => gateway.deployContract(from, input), estimate: async () => ({ feeModel: "tron-resource", @@ -261,7 +261,7 @@ export class TronContractService { account: scope.activeAccount, broadcaster: gateway, ...mode, - confirm: tronConfirmation(gateway, scope), + confirm: tronConfirmation(gateway, scope, { requireReceiptResult: true }), ...tronTransactionHooks(gateway), build: async (address) => await build(gateway, address), estimate: async (_tx: UnsignedTx) => ({ diff --git a/ts/test/build-entry.ts b/ts/test/build-entry.ts new file mode 100644 index 000000000..1999c102e --- /dev/null +++ b/ts/test/build-entry.ts @@ -0,0 +1,6 @@ +import { execFileSync } from "node:child_process"; + +/** Golden tests run the built CLI; build it once here so a stale dist never gets tested. */ +export default function setup(): void { + execFileSync("npx", ["tsup"], { cwd: process.cwd(), stdio: "inherit", timeout: 120_000 }); +} diff --git a/ts/test/contract-deploy.test.ts b/ts/test/contract-deploy.test.ts index 41fb03796..13af16487 100644 --- a/ts/test/contract-deploy.test.ts +++ b/ts/test/contract-deploy.test.ts @@ -26,7 +26,11 @@ import { DETACHED } from "./detached.js"; // RUN_LIVE_BROADCAST=1 → actually deploy + confirm on Nile (spends testnet TRX) const HERE = dirname(fileURLToPath(import.meta.url)); -const ENTRY = join(process.cwd(), "src", "index.ts"); +// A built entry (WALLET_CLI_TEST_ENTRY, set by vitest.config for the golden project) runs as +// plain `node `; without one, the TypeScript source is executed through tsx per spawn. +const ENTRY_ARGS = process.env.WALLET_CLI_TEST_ENTRY + ? [process.env.WALLET_CLI_TEST_ENTRY] + : ["--import", "tsx", join(process.cwd(), "src", "index.ts")]; const PW = "testpw123A"; // Minimal init code whose runtime ignores appended constructor args (a known-good Nile deploy @@ -81,7 +85,7 @@ function deploy( ]; if (opts.dryRun) local.push("--dry-run"); local.push("--password-stdin"); - const r = spawnSync(process.execPath, ["--import", "tsx", ENTRY, ...globals, ...local], { + const r = spawnSync(process.execPath, [...ENTRY_ARGS, ...globals, ...local], { input: PW + "\n", encoding: "utf8", env: { ...process.env, WALLET_CLI_HOME: HOME, NO_COLOR: "1" }, diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 15697c409..31556e935 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -9,7 +9,11 @@ import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.j import type { TokenEntry, WalletsFile } from "../src/domain/types/index.js"; import { DETACHED } from "./detached.js"; -const ENTRY = join(process.cwd(), "src", "index.ts"); +// A built entry (WALLET_CLI_TEST_ENTRY, set by vitest.config for the golden project) runs as +// plain `node `; without one, the TypeScript source is executed through tsx per spawn. +const ENTRY_ARGS = process.env.WALLET_CLI_TEST_ENTRY + ? [process.env.WALLET_CLI_TEST_ENTRY] + : ["--import", "tsx", join(process.cwd(), "src", "index.ts")]; const PACKAGE_VERSION = ( JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")) as { version: string } ).version; @@ -41,7 +45,7 @@ function run(args: string[], opts: { input?: string; password?: string | null } // signal instead of silently eating the whole test budget. // `node --import tsx` executes the same TypeScript entry without the tsx CLI's IPC control // socket, so black-box tests also run in restricted CI/sandbox environments. - const r = spawnSync(process.execPath, ["--import", "tsx", ENTRY, ...finalArgs], { + const r = spawnSync(process.execPath, [...ENTRY_ARGS, ...finalArgs], { input: stdin, encoding: "utf8", env, diff --git a/ts/test/unknown-command.test.ts b/ts/test/unknown-command.test.ts index 63eea3421..c6f5de97f 100644 --- a/ts/test/unknown-command.test.ts +++ b/ts/test/unknown-command.test.ts @@ -5,7 +5,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { DETACHED } from "./detached.js"; -const ENTRY = join(process.cwd(), "src", "index.ts"); +// A built entry (WALLET_CLI_TEST_ENTRY, set by vitest.config for the golden project) runs as +// plain `node `; without one, the TypeScript source is executed through tsx per spawn. +const ENTRY_ARGS = process.env.WALLET_CLI_TEST_ENTRY + ? [process.env.WALLET_CLI_TEST_ENTRY] + : ["--import", "tsx", join(process.cwd(), "src", "index.ts")]; let HOME: string; beforeEach(() => { @@ -15,7 +19,7 @@ beforeEach(() => { function run(args: string[]) { const env = { ...process.env, WALLET_CLI_HOME: HOME } as Record; delete env.MASTER_PASSWORD; - const r = spawnSync(process.execPath, ["--import", "tsx", ENTRY, ...args], { + const r = spawnSync(process.execPath, [...ENTRY_ARGS, ...args], { encoding: "utf8", env, timeout: 18_000, diff --git a/ts/vitest.config.ts b/ts/vitest.config.ts index a105c9349..e9fa2b3b6 100644 --- a/ts/vitest.config.ts +++ b/ts/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "vitest/config"; +import { join } from "node:path"; export default defineConfig({ test: { @@ -26,6 +27,14 @@ export default defineConfig({ include: ["test/**/*.test.ts"], testTimeout: 30_000, hookTimeout: 30_000, + // Build once, then every case spawns `node dist/index.js` instead of cold-transpiling + // the whole CLI through tsx. `verify:package` sets WALLET_CLI_TEST_ENTRY to the + // independently installed package and must keep testing that, not dist. + globalSetup: ["./test/build-entry.ts"], + env: { + WALLET_CLI_TEST_ENTRY: + process.env.WALLET_CLI_TEST_ENTRY ?? join(process.cwd(), "dist", "index.js"), + }, }, }, ],