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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ graphify-out/
# Scoped registry credentials for the private @sun-sdk packages; local-only until the SDK
# is published to public npm (see ts/docs/superpowers/specs, D6).
ts/.npmrc

# Codex
.codex/

# MCP
.mcp.json
4 changes: 3 additions & 1 deletion ts/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ docs/superpowers
docs/qa
docs/adr
CONTEXT.md
.boundary
.boundary
audit-claude/
.codegraph/
13 changes: 8 additions & 5 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`. Without a limit of your own, a built-in ceiling of **$1 per payment** applies to the known stablecoins; a route priced above it also fails with `amount_exceeds_limit`, and passing `--max-amount` or `--max-raw-amount` replaces that ceiling with yours.
**Nothing is signed until a route matches.** Routes on other networks are ignored; `--token`, `--asset` and `--scheme` narrow the choice further, and `--max-amount` (whole tokens) or `--max-raw-amount` (smallest units) rules out a route priced above it. If no route matches, the command fails with `no_matching_requirement`; if the matching route is priced over the limit, with `amount_exceeds_limit`. Both are raised before signing, with `paymentStatus: "not_sent"`, and no password is asked for — with or without `--dry-run`. A raw amount has no unit, so `--max-raw-amount` requires `--token` or `--asset` to name the token it counts (`invalid_option` otherwise). Without a limit of your own, only the known stablecoins are accepted, under a built-in ceiling of **$1 per payment**; a route priced above it also fails with `amount_exceeds_limit`. A limit of your own replaces that ceiling and the stablecoin restriction, but other tokens are still refused: `--max-amount` needs a token whose decimals are known (or `--asset` with `--decimals`), and `--max-raw-amount` pays only the token you pinned.

**Two payment schemes:**

Expand Down Expand Up @@ -52,7 +52,7 @@ Requires an account, even for an endpoint that turns out to be free. The master
| `--body <string>` | Request body; excludes `--body-file` |
| `--body-file <path>` | Read the request body from a file, or from stdin with `-`; excludes `--body` |
| `--max-amount <n>` | Refuse a route priced above this, in whole tokens; excludes `--max-raw-amount` |
| `--max-raw-amount <n>` | The same limit in smallest units |
| `--max-raw-amount <n>` | The same limit in smallest units; requires `--token` or `--asset`, since a raw amount has no unit |
| `--token <symbol>` | Accept only routes paying in this token |
| `--asset <address>` | Accept only routes paying in this token contract |
| `--decimals <n>` | Decimals of the `--asset` token; requires `--asset` |
Expand Down Expand Up @@ -114,6 +114,8 @@ URL https://x402-gateway.bankofai.io/providers/dia-price-tron/v1/quotat
Status 200
Settled Yes
Delivered Yes
Amount 0.000001 (1 base units)
Asset USDT
From TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6
Transaction 9b41c7e2d05f83a6e1c4b8d27f9a03e5c6d8b1f4a2e7c9d0b3f5a8e1c6d2b7f4
Output btc-quote.json
Expand All @@ -124,10 +126,10 @@ printf '%s' "$PW" | wallet-cli x402 pay https://x402-gateway.bankofai.io/provide
```

```json
{"schema":"wallet-cli.result.v1","success":true,"command":"x402.pay","data":{"url":"https://x402-gateway.bankofai.io/providers/dia-price-tron/v1/quotation/BTC","status":200,"delivered":true,"settled":true,"payer":{"address":"TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"},"paymentResponse":{"success":true,"transaction":"9b41c7e2d05f83a6e1c4b8d27f9a03e5c6d8b1f4a2e7c9d0b3f5a8e1c6d2b7f4","network":"tron:0x2b6653dc","payer":"0xe2e1a54926527fbb4e4420de4c6bab82beaee24d"},"output":{"path":"btc-quote.json","bytes":214}},"meta":{"durationMs":6412,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}}
{"schema":"wallet-cli.result.v1","success":true,"command":"x402.pay","data":{"url":"https://x402-gateway.bankofai.io/providers/dia-price-tron/v1/quotation/BTC","status":200,"delivered":true,"settled":true,"payer":{"address":"TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"},"paymentResponse":{"success":true,"transaction":"9b41c7e2d05f83a6e1c4b8d27f9a03e5c6d8b1f4a2e7c9d0b3f5a8e1c6d2b7f4","network":"tron:0x2b6653dc","payer":"0xe2e1a54926527fbb4e4420de4c6bab82beaee24d"},"output":{"path":"btc-quote.json","bytes":214},"payment":{"asset":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","token":"USDT","decimals":6,"amount":"0.000001","rawAmount":"1"}},"meta":{"durationMs":6412,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}}
```

`Settled Yes` means the payment went through on chain; `Delivered Yes` means the paid response arrived and was written to `btc-quote.json`. `From` is the paying account and `Transaction` the payment's transaction ID (JSON: `data.payer.address` and `data.paymentResponse.transaction`). The facilitator submits the payment transaction and pays its energy, so the payment itself costs the account only the quoted price. This account already had a Permit2 allowance; on a first payment without one, see the Permit2 approve note above.
`Settled Yes` means the payment went through on chain; `Delivered Yes` means the paid response arrived and was written to `btc-quote.json`. `Amount` and `Asset` are what was paid (JSON: `data.payment`). `From` is the paying account and `Transaction` the payment's transaction ID (JSON: `data.payer.address` and `data.paymentResponse.transaction`). The facilitator submits the payment transaction and pays its energy, so the payment itself costs the account only the quoted price. This account already had a Permit2 allowance; on a first payment without one, see the Permit2 approve note above.

## Output

Expand All @@ -138,6 +140,7 @@ printf '%s' "$PW" | wallet-cli x402 pay https://x402-gateway.bankofai.io/provide
| `delivered` | boolean | Whether the final response was successful (2xx) |
| `settled` | boolean | Whether a valid settlement receipt for the selected network came back |
| `payer` | object | `{address}` of the paying account; present when a payment was signed |
| `payment` | object | What the signed payment pays, present when one was created: `asset` (token contract), `token` (symbol, when the token is registered), `decimals` and `amount` (whole tokens, when the decimals are known), and `rawAmount` (smallest units) |
| `paymentResponse` | object | The facilitator's settlement receipt, when the endpoint sent one: `success`, `transaction` (the payment's transaction ID), `network`, and `payer` — both as the facilitator writes them, e.g. `tron:0xcd8690dc` and a hex address |
| `approval` | object | TRON only, when the payment needed a one-time Permit2 approve: `{txId, token, spender, allowance: "unlimited", feeLimitSun, status}`. `status` is `confirmed` (broadcast and mined before the payment was signed) or `exported` (signed into the payment package for the endpoint to sponsor). The same object appears in `error.details.approval` when anything after the approve fails |
| `response` | any | The response body — parsed JSON, or text; absent with `--out` |
Expand All @@ -146,7 +149,7 @@ printf '%s' "$PW" | wallet-cli x402 pay https://x402-gateway.bankofai.io/provide

## Exit status

`0` success, including a free response · `1` execution failure (`no_matching_requirement`, `amount_exceeds_limit` — both before signing, `paymentStatus: "not_sent"`; `gasfree_insufficient_balance` / `gasfree_not_activated`, `permit2_allowance_required` / `approval_reset_required`, `fee_cap_exceeded`, `payer_mismatch`, `invalid_settlement`, `invalid_x402_response`, `response_too_large`, `provider_rate_limited` — HTTP 429; `provider_error` — any other failed request or payment, with `error.details.phase` and `httpStatus`; `missing_wallet_address`; `auth_required` — a payment needs signing and no `--password-stdin` was given; `auth_failed`, `timeout`) · `2` usage error (`output_exists` — the `--out` file exists; `gasfree_credentials_missing`; `invalid_option` — a binary response without `--out`, two exclusive options, `--decimals` without `--asset`, or `--wait` / `--wait-timeout`, which this command does not take because the facilitator settles the payment; `invalid_amount` — a limit that is not a positive amount; `invalid_value` — e.g. a malformed `--header`, an unsupported `--method`, or a `--gasfree-relay` URL that is not HTTPS).
`0` success, including a free response · `1` execution failure (`no_matching_requirement`, `amount_exceeds_limit` — both before signing, `paymentStatus: "not_sent"`; `gasfree_insufficient_balance` / `gasfree_not_activated`, `permit2_allowance_required` / `approval_reset_required`, `fee_cap_exceeded`, `payer_mismatch`, `invalid_settlement`, `invalid_x402_response`, `response_too_large`, `provider_rate_limited` — HTTP 429; `provider_error` — any other failed request or payment, with `error.details.phase` and `httpStatus`; `missing_wallet_address`; `auth_required` — a payment needs signing and no `--password-stdin` was given; `auth_failed`, `timeout`) · `2` usage error (`output_exists` — the `--out` file exists; `gasfree_credentials_missing`; `invalid_option` — a binary response without `--out`, two exclusive options, `--decimals` without `--asset`, `--max-raw-amount` without `--token` or `--asset`, or `--wait` / `--wait-timeout`, which this command does not take because the facilitator settles the payment; `invalid_amount` — a limit that is not a positive amount; `invalid_value` — e.g. a malformed `--header`, an unsupported `--method`, or a `--gasfree-relay` URL that is not HTTPS).

## See also

Expand Down
24 changes: 21 additions & 3 deletions ts/src/adapters/inbound/cli/commands/r4-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,34 @@ describe("R4 payment input contracts", () => {
it.each([
[{ maxAmount: "0" }, "invalid_amount"],
[{ maxAmount: "1e3" }, "invalid_amount"],
[{ maxRawAmount: "0" }, "invalid_amount"],
[{ maxRawAmount: (1n << 256n).toString() }, "invalid_amount"],
[{ maxAmount: "1", maxRawAmount: "1" }, "invalid_option"],
[{ maxRawAmount: "0", token: "USDT" }, "invalid_amount"],
[{ maxRawAmount: (1n << 256n).toString(), token: "USDT" }, "invalid_amount"],
[{ maxAmount: "1", maxRawAmount: "1", token: "USDT" }, "invalid_option"],
[{ body: "", bodyFile: "body.json" }, "invalid_option"],
[{ maxGasfreeFee: "1", maxGasfreeFeeRaw: "1" }, "invalid_option"],
])("rejects %j with %s", (input, code) => {
expect(() => parse("pay", { url: "https://example.test", ...input })).toThrow(
expect.objectContaining({ code }),
);
});
it("rejects a raw payment limit that names no token, before anything is requested", () => {
expect(() =>
parse("pay", { url: "https://example.test", maxRawAmount: "1000000000000000000" }),
).toThrow(
expect.objectContaining({
code: "invalid_option",
message: expect.stringContaining("--max-raw-amount: requires --token or --asset"),
}),
);
});
it.each([{ token: "USDT" }, { asset: "0x55d398326f99059fF775485246999027B3197955" }])(
"accepts a raw payment limit pinned by %j",
(pin) => {
expect(
parse("pay", { url: "https://example.test", maxRawAmount: "1000000", ...pin }),
).toMatchObject({ maxRawAmount: "1000000", ...pin });
},
);
it.each(["1e3", "1.5", "-1"])("rejects port literal %s", (port) => {
expect(() => parse("serve", { payTo: "x", port })).toThrow();
});
Expand Down
1 change: 1 addition & 0 deletions ts/src/adapters/inbound/cli/commands/x402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe("x402 command surface", () => {
header: [],
maxAmount: "1",
maxRawAmount: "1",
token: "USDT",
}).success,
).toBe(false);
});
Expand Down
14 changes: 13 additions & 1 deletion ts/src/adapters/inbound/cli/commands/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ const payFields = z.object({
decimals: integerLiteral(0, 18).optional().describe("decimals for an explicit asset"),
scheme: z.enum(["exact", "exact_gasfree"]).optional(),
maxAmount: paymentAmount.optional().describe("maximum payment in whole tokens"),
maxRawAmount: rawPaymentAmount.optional().describe("maximum payment in smallest units"),
maxRawAmount: rawPaymentAmount
.optional()
.describe("maximum payment in smallest units; requires --token or --asset"),
dryRun: z.boolean().default(false).describe("inspect the payment challenge without signing"),
gasfreeRelay: z
.string()
Expand All @@ -60,6 +62,16 @@ const payInput = payFields.superRefine((value, context) => {
message: "cannot be combined with --max-raw-amount",
});
}
// A raw limit has no unit: unpinned, it would cap whatever token the seller names.
if (value.maxRawAmount !== undefined && !value.token && !value.asset) {
context.addIssue({
code: "custom",
params: { errorCode: "invalid_option" },
path: ["maxRawAmount"],
message:
"requires --token or --asset; a raw limit has no unit, so pin it to one token or use --max-amount",
});
}
if (value.body !== undefined && value.bodyFile !== undefined) {
context.addIssue({
code: "custom",
Expand Down
22 changes: 22 additions & 0 deletions ts/src/adapters/inbound/cli/render/x402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,28 @@ it("separates response text while output-file receipts omit body", () => {
"--- response ---",
);
});
it("shows the paid asset and amount after a payment", () => {
const rendered = paymentText({
status: 200,
settled: true,
payment: {
asset: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
token: "USDT",
decimals: 6,
amount: "0.01",
rawAmount: "10000",
},
});
expect(rendered).toMatch(/^Amount\s+0\.01 \(10000 base units\)$/m);
expect(rendered).toMatch(/^Asset\s+USDT$/m);
const unregistered = paymentText({
status: 200,
payment: { asset: "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c", rawAmount: "5" },
});
expect(unregistered).toMatch(/^Amount\s+5 base units$/m);
expect(unregistered).toMatch(/^Asset\s+0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c$/m);
expect(paymentText({ status: 200 })).not.toMatch(/Amount|Asset/);
});
it("wraps a wide-character summary with aligned continuation lines", () => {
const original = Object.getOwnPropertyDescriptor(process.stdout, "columns");
Object.defineProperty(process.stdout, "columns", { value: 32, configurable: true });
Expand Down
6 changes: 5 additions & 1 deletion ts/src/adapters/inbound/cli/render/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,16 @@ export function providerEndpointsText(value: unknown): string {
);
}
export function paymentText(value: unknown): string {
const p = asObj(value);
const p = asObj(value),
paid = asObj(p.payment);
const raw = typeof paid.rawAmount === "string" ? `${text(paid.rawAmount)} base units` : "";
const details = query([
["URL", text(p.url)],
["Status", text(p.status)],
["Settled", p.settled === true ? "Yes" : "No"],
["Delivered", p.delivered === true ? "Yes" : "No"],
["Amount", typeof paid.amount === "string" && raw ? `${text(paid.amount)} (${raw})` : raw],
["Asset", text(paid.token ?? paid.asset)],
["From", text(asObj(p.payer).address)],
["Transaction", text(asObj(p.paymentResponse).transaction)],
["Approval", text(asObj(p.approval).txId)],
Expand Down
1 change: 1 addition & 0 deletions ts/src/adapters/outbound/x402/nile-compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ it("uses the selected relay to construct and sign the final GasFree permit", asy
scheme: "exact_gasfree",
gasfreeRelay: "https://relay.example/nile",
maxRawAmount: "10000",
asset,
},
),
).resolves.toMatchObject({ delivered: true });
Expand Down
2 changes: 1 addition & 1 deletion ts/src/adapters/outbound/x402/payment-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ it.each(["tron:3448148188", "tron:0xcd8690dc"])(
);

it.each([
[{ maxRawAmount: "99" }, "amount_exceeds_limit"],
[{ maxRawAmount: "99", token: "USDT" }, "amount_exceeds_limit"],
[{ maxAmount: "0.000000000000000099" }, "amount_exceeds_limit"],
[{ token: "USDD" }, "no_matching_requirement"],
[{ scheme: "exact_gasfree" as const }, "no_matching_requirement"],
Expand Down
41 changes: 40 additions & 1 deletion ts/src/adapters/outbound/x402/payment-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { toX402Wallet } from "./signer-bridge.js";
import { createPayerSigner } from "../../../application/services/x402/payer-signer.js";
import type { PayerSigner } from "../../../application/contracts/x402-payer.js";
import { toX402Network, sameX402Network } from "../../../domain/x402/network-id.js";
import { fromBaseUnits } from "../../../domain/amounts/index.js";

type PaidFetchFactory = (
network: NetworkDescriptor,
Expand Down Expand Up @@ -159,7 +160,13 @@ export class X402PaymentClient implements X402PaymentPort {
out,
toX402Network(network),
);
return authorization.approval ? { ...result, approval: authorization.approval } : result;
return {
...result,
...(authorization.requirement
? { payment: paidAmount(network, authorization.requirement, input) }
: {}),
...(authorization.approval ? { approval: authorization.approval } : {}),
};
} catch (error) {
await out?.release();
const classified = authorization.signed
Expand Down Expand Up @@ -439,6 +446,10 @@ export class X402PaymentClient implements X402PaymentPort {
client.onPaymentCreationFailure(async ({ error }) => {
creationError = sdkPaymentError(error, "create_payment");
});
// Report the requirement the SDK actually built the payment for, not an earlier candidate.
client.onAfterPaymentCreation(async ({ selectedRequirements }) => {
authorization.requirement = selectedRequirements;
});
const boundedFetch = this.boundedFetch(scope, network, signer);
let first: Response | undefined = initial;
const fetchWithInitial: typeof fetch = (request, init) => {
Expand Down Expand Up @@ -498,6 +509,27 @@ interface PaymentAuthorization {
signed: boolean;
/** The one-time Permit2 approve, once signed, and how far the SDK has taken it. */
approval?: SignedApproval & { status: "submitted" | "confirmed" | "exported" };
/** The requirement the created payment pays. */
requirement?: { asset: string; amount: string };
}

/** What the signed payment pays: the token (symbol when registered) and its amount. */
function paidAmount(
network: NetworkDescriptor,
requirement: { asset: string; amount: string },
input: X402PayInput,
) {
const known = metadata(network.id, requirement.asset);
// An explicit --decimals is only accepted beside --asset, which this requirement matched.
const decimals = known?.decimals ?? input.decimals;
return {
asset: requirement.asset,
...(known ? { token: known.symbol } : {}),
...(decimals === undefined
? {}
: { decimals, amount: fromBaseUnits(requirement.amount, decimals) }),
rawAmount: requirement.amount,
};
}

/** Never lose the approve's evidence to whatever failed after it. */
Expand Down Expand Up @@ -624,6 +656,13 @@ function selectMatching<T extends OfferedRequirement>(
network: NetworkDescriptor,
input: X402PayInput,
): T[] {
// A raw limit has no unit; without a pinned token it would cap whatever asset the seller names.
if (input.maxRawAmount !== undefined && !input.asset && !input.token) {
throw new UsageError(
"invalid_option",
"--max-raw-amount requires --token or --asset; a raw limit has no unit, so pin it to one token or use --max-amount",
);
}
const expectedNetwork =
network.family === "tron" ? `tron:0x${BigInt(network.chainId).toString(16)}` : network.id;
const matching = requirements.filter((requirement) => {
Expand Down
Loading