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
85 changes: 80 additions & 5 deletions ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,10 @@ transaction pipeline. See [the SDK integration](docs/development/erc8004-sdk-int

The v4.14 command names are:

| Group | Commands |
| --- | --- |
| `x402` | `pay`, `serve`, `roundtrip`, `provider-list`, `provider-show`, `endpoint-list`, `update-catalog` |
| `bai` | `usage-summary`, `usage-records`, `recharge`, `report-recharge`, `recharge-orders` |
| Group | Commands |
| ------ | -------------------------------------------------------------------------------------------------------- |
| `x402` | `pay`, `serve`, `roundtrip`, `provider-list`, `provider-show`, `endpoint-list`, `update-catalog` |
| `bai` | `usage-summary`, `usage-records`, `recharge`, `report-recharge`, `recharge-orders` |
| `8004` | `show`, `register`, `update`, `transfer`, `approve`, `add-operator`, `remove-operator`, `operator-check` |

JSON command identifiers use these names, for example `bai.usage-summary`.
Expand All @@ -254,7 +254,7 @@ including the existing device precheck and signing ceremony. Payment guards
validate the declared payer and configured GasFree fee ceiling. Base USDC,
BSC, and TRON routes are supported according to the provider's challenge.

For `x402 pay` and `x402 roundtrip`, `--gasfree-relay official` (the default)
For `x402 pay`, `x402 roundtrip` and `bai recharge`, `--gasfree-relay official` (the default)
uses the SDK's credential-free proxy. `--gasfree-relay gasfree` reads the
configured GasFree Open API using `gasfreeApiKey` and `gasfreeApiSecret`;
missing credentials fail before payment. An HTTPS URL selects a custom relay
Expand All @@ -269,3 +269,78 @@ maximum authorized fee is not evidence of the actual fee charged.

Provider queries prefer the local snapshot. Run `x402 update-catalog` to refresh
it; see [catalog caching](docs/concepts/provider-catalog.md).

### Facilitator compatibility

For TRON, local `x402 serve`, `x402 roundtrip` and `bai recharge` query the
configured facilitator's `/supported` endpoint before advertising the payment
requirement. The CLI matches the network, scheme and x402 version 2, then uses the
network spelling that the facilitator supports:

- Decimal only: use the decimal ID, such as `tron:3448148188`.
- Hexadecimal only: use the advertised hexadecimal ID, such as `tron:0xcd8690dc`.
- Both: prefer the canonical decimal ID.

Capability lookup failures, malformed responses and missing matching capabilities
stop the flow before signing. The selected representation stays consistent through
the challenge, payment payload, verify and settle requests. The CLI does not rewrite
signed payloads or retry settlement with another format after an error. CLI network
selection and server result fields retain canonical decimal IDs; settlement receipts
accept either representation of the same chain. EVM network IDs remain unchanged.
External providers remain responsible for their own facilitator compatibility.

### Local x402 server

`serve` and `roundtrip` accept either `--amount` or `--raw-amount`, and either
`--token` or `--asset`. An unregistered asset requires `--decimals`; registered
precision cannot be overridden. `--valid-for-seconds` sets authorization validity
(default 300 seconds).

```sh
wallet-cli x402 serve --network nile --token USDT --raw-amount 100 \
--pay-to <recipient-address> --valid-for-seconds 300 --daemon --output json
```

The daemon returns its PID, payment URL and access-log path after the listener is
ready. Stop it with `kill -TERM <pid>`. Foreground access logs go to stderr; daemon
logs are written to a private file. Logs exclude query strings, headers and payment
bodies. `serve --resource-url` changes the advertised resource URL and `--host`
selects a loopback bind address. `roundtrip` always binds to loopback and closes its
server on completion; it does not accept `--host`, `--resource-url` or `--daemon`.

### B.AI setup, recharge and recovery

Before the first recharge, the selected wallet must already be bound to the B.AI
account. Configure the personal API key through stdin; setup verifies the binding
for the selected account and network before saving the key:

```sh
wallet-cli config baiApiKey --network tron --account payer --api-key-stdin
wallet-cli bai recharge 1 --network base --token USDC --dry-run --output json
wallet-cli bai recharge 1 --network tron --token USDT --to recipient@example.com --dry-run
```

Base, BSC and TRON recharge routes remain supported. Omit `--to` to recharge your
own account; recipient recharge resolves the target before using the same preorder,
payment and transaction-report flow. The on-chain destination remains the platform
address, not the recipient's wallet.

Dry-run checks binding, amount, recipient and the payment challenge without creating
an order, unlocking, signing, paying or reporting a transaction. It reads payer wallet
balances when RPC is available; these are not GasFree account balances. Final network
or relay fees may be unavailable and are explicitly reported as unestimated. For TRON,
`--scheme exact_gasfree` supports the relay and fee-limit options described above.

If payment succeeded but reporting failed, retain the original transaction hash and
use `bai report-recharge` with the original chain and recipient information. Reconcile
an unknown payment outcome before proceeding; do not pay again to retry reporting.
`bai recharge-orders` caps requests above 100 at the backend's 100-row limit and returns
an effective limit plus a warning. `usage-records` retains its independent limit.

### ERC-8004 registration metadata

Register/update URI validation is separate from metadata loading: HTTPS, IPFS and
JSON data URIs remain supported for writing. Metadata reads accept only HTTP/HTTPS,
never follow redirects, and enforce a 1 MiB limit on compressed and expanded content,
a maximum JSON depth of 20, and the shorter of the global timeout and 10 seconds.
Unsupported or unavailable metadata produces a warning while preserving chain data.
3 changes: 3 additions & 0 deletions ts/scripts/verify-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const call = (
env = { ...process.env, WALLET_CLI_HOME: join(temp, "wallet") },
) => execFileSync(cmd, args, { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
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 }) =>
Expand Down Expand Up @@ -42,6 +44,7 @@ try {
"run",
"test/beta-command-surface.test.ts",
"test/beta-server-roundtrip.test.ts",
"test/x402-daemon.test.ts",
"test/erc8004.test.ts",
"test/x402-provider-payment.test.ts",
"test/bai-nile-compatibility.test.ts",
Expand Down
16 changes: 14 additions & 2 deletions ts/src/adapters/inbound/cli/commands/bai.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { CommandRegistry } from "../registry/index.js";
import { registerBaiCommands } from "./bai.js";
import { baiRechargeSpec, registerBaiCommands } from "./bai.js";
import type { BaiService } from "../../../../application/use-cases/bai-service.js";

function service(): BaiService {
Expand Down Expand Up @@ -111,6 +111,18 @@ it("shares recharge schema across families and selects the Base token in its bin
command.spec.baseFields.parse({ amount: "1" }),
),
).resolves.toMatchObject({ amount: "1", token: "USDC" });
for (const field of ["dryRun", "signOnly", "buildOnly"])
for (const field of ["signOnly", "buildOnly"])
expect(command.spec.baseFields.shape).not.toHaveProperty(field);
});

it.each(["0", "0.000", "-1", "1e3", "9007199254740992", "1." + "0".repeat(100)])(
"rejects invalid recharge amount %s at the command boundary",
(amount) => {
const result = baiRechargeSpec.baseFields.safeParse({ amount });
expect(result.success).toBe(false);
if (!result.success)
expect(result.error.issues[0]).toMatchObject({
params: { errorCode: "invalid_amount" },
});
},
);
65 changes: 56 additions & 9 deletions ts/src/adapters/inbound/cli/commands/bai.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { integerLiteral } from "../schemas/payment-values.js";
import {
assertBaiRechargeMinimum,
baiRechargeAmount,
} from "../../../../domain/bai/recharge-policy.js";
import { integerLiteral, rawPaymentAmount, paymentAmount } from "../schemas/payment-values.js";
import { z } from "zod";
import type { CommandDefinition, ChainSpec, FamilyBinding } from "../contracts/index.js";
import type { CommandRegistry } from "../registry/index.js";
Expand All @@ -14,8 +18,28 @@ const listFields = z.object({
sort: z.enum(["asc", "desc"]).default("desc").describe("creation-time sort direction"),
});

const rechargeAmount = z.string().superRefine((value, ctx) => {
try {
baiRechargeAmount(value);
} catch {
ctx.addIssue({
code: "custom",
message: "must be a positive recharge amount within the supported range",
params: { errorCode: "invalid_amount" },
});
}
});

const rechargeFields = z.object({
amount: z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/, "must be a decimal amount"),
dryRun: z
.boolean()
.default(false)
.describe("preview without creating an order, unlocking, signing or paying"),
scheme: z.enum(["exact", "exact_gasfree"]).default("exact"),
gasfreeRelay: z.string().optional().describe("GasFree relay: official, gasfree, or HTTPS URL"),
maxGasfreeFee: paymentAmount.optional().describe("maximum GasFree fee in whole tokens"),
maxGasfreeFeeRaw: rawPaymentAmount.optional().describe("maximum GasFree fee in smallest units"),
amount: rechargeAmount,
token: z
.string()
.trim()
Expand All @@ -42,7 +66,7 @@ export const baiRechargeSpec: ChainSpec = {
positionals: [{ field: "amount" }],
summary: "Recharge your own or another B.AI account",
description:
"Recharge B.AI using the selected network and token. Omit --to to recharge the API-key account, or set --to to the recipient's email or wallet address. Both modes use the same recharge flow. Recharge uses local x402 exact on mainnet: TRON USDT/USDD, BSC USDT, or Base USDC. USDT/USDC minimum: 1. Token and amount precision are checked before an order is created.",
"Recharge B.AI using the selected network and token. Omit --to to recharge the API-key account, or set --to to the recipient's email or wallet address. Both modes use the same recharge flow. Recharge uses local x402 on mainnet (exact, or TRON exact_gasfree): TRON USDT/USDD, BSC USDT, or Base USDC. USDT/USDC minimum: 1. Token and amount precision are checked before an order is created.",
baseFields: rechargeFields,
examples: [
{ cmd: "wallet-cli bai recharge 10 --token USDT --network tron --password-stdin" },
Expand All @@ -57,13 +81,36 @@ export const baiRechargeSpec: ChainSpec = {

export function baiRechargeBinding(service: BaiService): FamilyBinding {
return {
refine: (input, ctx) => {
try {
assertBaiRechargeMinimum(input.token ?? "USDT", baiRechargeAmount(input.amount));
} catch (error) {
ctx.addIssue({
code: "custom",
path: ["amount"],
message: error instanceof Error ? error.message : "Invalid recharge amount",
params: { errorCode: "invalid_amount" },
});
}
if (input.maxGasfreeFee !== undefined && input.maxGasfreeFeeRaw !== undefined)
ctx.addIssue({
code: "custom",
message: "GasFree fee limits are mutually exclusive",
params: { errorCode: "invalid_option" },
});
},
run: async (ctx, network, input) => {
if (!network) throw new Error("B.AI recharge requires a resolved network");
return service.recharge(ctx, network, {
amount: input.amount,
token: input.token ?? (network.id === "eip155:8453" ? "USDC" : "USDT"),
to: input.to,
apiKey: ctx.config.baiApiKey,
dryRun: input.dryRun,
scheme: input.scheme,
gasfreeRelay: input.gasfreeRelay,
maxGasfreeFee: input.maxGasfreeFee,
maxGasfreeFeeRaw: input.maxGasfreeFeeRaw,
});
},
};
Expand All @@ -76,11 +123,7 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi
const reportFields = z.object({
txHash: z.string().max(66).describe("existing transaction hash from the original recharge"),
chain: z.enum(["tron", "bnb", "base"]).describe("original recharge chain; BSC is bnb"),
amount: z
.string()
.regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/)
.optional()
.describe("original recharge amount, when available"),
amount: rechargeAmount.optional().describe("original recharge amount, when available"),
to: z
.string()
.trim()
Expand Down Expand Up @@ -177,6 +220,10 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi
fields: listFields,
input: listFields,
examples: [{ cmd: "wallet-cli bai recharge-orders --limit 20" }],
run: async (_context, _network, input) => service.rechargeList(input),
run: async (context, _network, input) => {
const { warnings, ...result } = await service.rechargeList(input);
for (const warning of warnings ?? []) context.warn(warning);
return result;
},
} satisfies CommandDefinition);
}
51 changes: 41 additions & 10 deletions ts/src/adapters/inbound/cli/commands/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,21 @@ const listFields = z.object({

const serveFields = z.object({
payTo: z.string().trim().min(1).describe("recipient address on the selected network"),
amount: paymentAmount.default("0.0001").describe("human-readable token amount"),
token: z.string().trim().min(1).default("USDT").describe("payment token symbol"),
amount: paymentAmount.optional().describe("human-readable token amount"),
token: z.string().trim().min(1).optional().describe("payment token symbol (default USDT)"),
rawAmount: rawPaymentAmount
.optional()
.describe("payment in smallest units; mutually exclusive with amount"),
asset: z.string().trim().min(1).optional().describe("explicit payment asset address"),
decimals: integerLiteral(0, 18).optional().describe("precision for an explicit asset"),
resourceUrl: url.optional().describe("advertised resource URL"),
validForSeconds: integerLiteral(1, 86400)
.default(300)
.describe("payment authorization validity in seconds"),
daemon: z
.boolean()
.default(false)
.describe("run in the background and return its PID and log path"),
scheme: z.enum(["exact", "exact_gasfree"]).default("exact"),
host: z.enum(["127.0.0.1", "::1"]).default("127.0.0.1").describe("loopback bind address"),
port: integerLiteral(1, 65535).default(4020),
Expand All @@ -138,18 +151,36 @@ const serveFields = z.object({
.default("https://facilitator.bankofai.io"),
});

const roundtripFields = serveFields.extend({
function serveRefinement(
value: { amount?: string; rawAmount?: string; token?: string; asset?: string; decimals?: number },
ctx: z.RefinementCtx,
) {
for (const [invalid, message] of [
[
value.amount !== undefined && value.rawAmount !== undefined,
"--amount and --raw-amount are mutually exclusive",
],
[
value.token !== undefined && value.asset !== undefined,
"--token and --asset are mutually exclusive",
],
[value.decimals !== undefined && value.asset === undefined, "--decimals requires --asset"],
] as const) {
if (invalid) ctx.addIssue({ code: "custom", message, params: { errorCode: "invalid_option" } });
}
}

const roundtripFields = serveFields.omit({ host: true, resourceUrl: true, daemon: true }).extend({
gasfreeRelay: payFields.shape.gasfreeRelay,
maxGasfreeFee: payFields.shape.maxGasfreeFee,
maxGasfreeFeeRaw: payFields.shape.maxGasfreeFeeRaw,
});
const roundtripInput = roundtripFields.refine(
(value) => !(value.maxGasfreeFee !== undefined && value.maxGasfreeFeeRaw !== undefined),
{
const roundtripInput = roundtripFields
.superRefine(serveRefinement)
.refine((value) => !(value.maxGasfreeFee !== undefined && value.maxGasfreeFeeRaw !== undefined), {
message: "GasFree fee limits are mutually exclusive",
params: { errorCode: "invalid_option" },
},
);
});

export function registerX402Commands(registry: CommandRegistry, service: X402Service): void {
registry.add({
Expand Down Expand Up @@ -188,7 +219,7 @@ export function registerX402Commands(registry: CommandRegistry, service: X402Ser
capability: "x402.serve",
summary: "Run a local x402-protected endpoint",
fields: serveFields,
input: serveFields,
input: serveFields.superRefine(serveRefinement),
examples: [
{ cmd: "wallet-cli x402 serve --pay-to T... --amount 1 --token USDT --network tron" },
],
Expand All @@ -211,7 +242,7 @@ export function registerX402Commands(registry: CommandRegistry, service: X402Ser
examples: [{ cmd: "wallet-cli x402 roundtrip --pay-to T... --network tron --password-stdin" }],
run: async (ctx, network, input) => {
if (!network) throw new Error("x402 roundtrip requires a resolved network");
return service.roundtrip(ctx, network, input);
return service.roundtrip(ctx, network, { ...input, host: "127.0.0.1" });
},
} satisfies CommandDefinition);

Expand Down
Loading