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
3 changes: 3 additions & 0 deletions .github/workflows/ts-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
node:
- "22"
- "24"
- "26"

defaults:
run:
Expand All @@ -45,6 +46,8 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
# The runner image can lag behind the security releases that `engines` requires.
check-latest: true
cache: npm
cache-dependency-path: ts/package-lock.json

Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/ts-standalone-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:
contents: read

env:
NODE_VERSION: "20"
NODE_VERSION: "22"

jobs:
quality:
Expand All @@ -29,6 +29,8 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
# The runner image can lag behind the security releases that `engines` requires.
check-latest: true
cache: npm
cache-dependency-path: ts/package-lock.json

Expand Down Expand Up @@ -94,6 +96,8 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
# The runner image can lag behind the security releases that `engines` requires.
check-latest: true
cache: npm
cache-dependency-path: ts/package-lock.json

Expand Down
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Everything below (except the TypeScript Implementation section) refers to the Ja

## TypeScript Implementation

The `ts/` package is a self-contained, agent-first CLI (Node.js 20+, ESM, TypeScript). Every command
The `ts/` package is a self-contained, agent-first CLI (Node.js 22.23.4+ / 24.22.1+ / 26.12.1+, ESM, TypeScript). Every command
has a stable JSON envelope, deterministic exit codes, and discoverable schemas; interactive prompts
are used only for secret input (create / import / backup / delete). **All `ts/` commands run from the
`ts/` directory.**
Expand Down
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/
2 changes: 1 addition & 1 deletion ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The TRON ids used before CAIP-2 (`tron:mainnet`, `tron:nile`, `tron:shasta`) rem

## Install

**Prerequisites**: [Node.js](https://nodejs.org) **20 or later** (`node --version` to check). Ledger signing additionally needs a supported Ledger device with the TRON or Ethereum app installed — see the [Ledger guide](docs/guide/ledger.md).
**Prerequisites**: [Node.js](https://nodejs.org) **22.23.4+, 24.22.1+ or 26.12.1+** — the October 2026 security releases (`node --version` to check). Ledger signing additionally needs a supported Ledger device with the TRON or Ethereum app installed — see the [Ledger guide](docs/guide/ledger.md).

```bash
npm install -g @tron-walletcli/wallet-cli
Expand Down
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
2 changes: 1 addition & 1 deletion ts/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"access": "public"
},
"engines": {
"node": ">=20"
"node": ">=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1"
},
"scripts": {
"build": "tsup",
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
Loading
Loading