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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ts/docs/commands/x402/pay.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ wallet-cli x402 pay <url> [--method <m>] [--header "Name: value"]... [--body <s>

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:**

Expand Down Expand Up @@ -54,7 +54,7 @@ Requires an account, even for an endpoint that turns out to be free. The master
| `--gasfree-relay <official\|gasfree\|url>` | Source of GasFree account data for `exact_gasfree` (default `official`); a URL must be HTTPS with no credentials, query, or fragment |
| `--max-gasfree-fee <n>` | Highest GasFree fee to authorize, in whole tokens; excludes `--max-gasfree-fee-raw` |
| `--max-gasfree-fee-raw <n>` | The same cap in smallest units |
| `--out <path>` | Write the response body to a new file instead of `data.response`; an existing file is never overwritten |
| `--out <path>` | 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 |

Expand Down Expand Up @@ -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`) |
Expand Down
1 change: 1 addition & 0 deletions ts/docs/machine-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"docs/guide",
"docs/machine-interface.md",
"docs/troubleshooting.md",
"docs/development/erc8004-sdk-integration.md",
"docs/troubleshooting",
"README.md",
"LICENSE"
],
Expand Down
24 changes: 18 additions & 6 deletions ts/scripts/verify-package.mjs
Original file line number Diff line number Diff line change
@@ -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, "..");
Expand All @@ -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");
Expand Down
57 changes: 57 additions & 0 deletions ts/src/adapters/inbound/cli/commands/x402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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");
});
14 changes: 10 additions & 4 deletions ts/src/adapters/inbound/cli/commands/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -331,10 +332,13 @@ async function requestBody(
): Promise<string | undefined> {
if (!path) return inline;
if (path === "-") {
if (ctx.secrets.has("password")) {
// Every `--<kind>-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");
Expand Down Expand Up @@ -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;
}
10 changes: 9 additions & 1 deletion ts/src/adapters/inbound/cli/contracts/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
12 changes: 12 additions & 0 deletions ts/src/adapters/inbound/cli/render/x402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
1 change: 1 addition & 0 deletions ts/src/adapters/inbound/cli/render/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions ts/src/adapters/outbound/chain/tron/tron-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
3 changes: 2 additions & 1 deletion ts/src/adapters/outbound/chain/tron/tron-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading