diff --git a/.gitignore b/.gitignore index 03b3ed20d..23fcc258f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,7 @@ ts/.npmrc .codex/ # MCP -.mcp.json \ No newline at end of file +.mcp.json + +# Team working files (status, briefs, handoffs); local only +.team/ diff --git a/.graphifyignore b/.graphifyignore new file mode 100644 index 000000000..c6989e0aa --- /dev/null +++ b/.graphifyignore @@ -0,0 +1,33 @@ +# Generated code and build/dependency output obscure the handwritten code graph. +java/src/main/gen/ +java/src/gen/ +node_modules/ +build/ +dist/ +bin/ +.gradle/ +coverage/ +graphify-out/ + +# Generated fixtures, dependency locks, and architecture drawing data. +**/__snapshots__/ +**/package-lock.json +**/yarn.lock +**/pnpm-lock.yaml +ts/.dependency-cruiser-known-violations.json +ts/eslint-suppressions.json +ts/docs/ +java/docs/ + +# Local agent state and wallet data are outside the source graph. +.claude/ +.codex/ +.agents/ +.superpowers/ +.team/ +.private/ +.mcp.json +Wallet/ +Mnemonic/ +wallet_data/ +.wallet-cli/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..cbb5bbcd7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# AGENTS.md + +Read `CLAUDE.md` for the repository layout, build, and test commands. + +Before changing anything under `ts/`, read every file in `ts/docs/dev/rules/`. Those rules are the single +source of truth for the TypeScript architecture and comment style. diff --git a/CLAUDE.md b/CLAUDE.md index dc63e814f..2a96374a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,20 +6,37 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co The repository holds two independent implementations: -- `java/` — the original REPL-first implementation, described by the rest of this file. -- `ts/` — the agent-first TypeScript rewrite (npm package `@tron-walletcli/wallet-cli`). See the - **TypeScript Implementation** section below and `ts/README.md`. - -Everything below (except the TypeScript Implementation section) refers to the Java implementation. -**All Java paths are relative to `java/`, and all Java commands are run from that directory** -(`cd java` first). - -## TypeScript Implementation - -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.** +- `ts/` — the agent-first TypeScript rewrite (npm package `@tron-walletcli/wallet-cli`). See + [TypeScript](#typescript-ts) and `ts/README.md`. +- `java/` — the original REPL-first implementation. See [Java](#java-java). + +## Graphify + +Use the local code graph when you need to explore: where a symbol is used, how a flow passes through modules, +or what a change affects. Query it with +`bash scripts/graphify.sh query "" --budget 1200` from the repository root. +Use this wrapper to select the project's pinned Graphify version and disable LLM backends. When you already +know the file and lines, or need every occurrence of a string, use source search instead. + +- Initial setup: `bash scripts/graphify.sh setup` (requires `uv` and Python 3.13). +- Refresh only before a query, when the graph may be stale (a new session, a branch change, or code edits since + the last refresh): `bash scripts/graphify.sh refresh`. Extraction reuses unchanged files. If refresh fails, + say the graph is stale and use source search. +- From `ts/` or `java/`, use `bash ../scripts/graphify.sh` with the same arguments. The wrapper changes + to the repository root and updates the shared graph for both implementations. +- View `graphify-out/graph.html` for communities or `graphify-out/GRAPH_TREE.html` for symbols by file; + query data is in `graphify-out/graph.json` and the summary is in + `graphify-out/GRAPH_REPORT.md`. These generated files and the local Python environment are git-ignored. +- The scan covers code and tests in both implementations; `.graphifyignore` excludes generated code, + snapshots, build output, and local state. Document semantics are outside this code-only graph. +- Confirm graph findings in source, especially port-to-adapter wiring in `ts/src/bootstrap/composition.ts`. + Architecture rules and automated checks remain authoritative. If Graphify is unavailable, use source search. + +## TypeScript (`ts/`) + +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.** ```bash cd ts @@ -28,50 +45,56 @@ npm run build # bundle to dist/ via tsup (bin: wallet-cli -> dist/index npm run dev -- # run from source via tsx (e.g. npm run dev -- create --label main) npm test # vitest (tests are co-located as *.test.ts) npm run typecheck # tsc --noEmit -npm run depcruise # dependency-cruiser — enforces the architecture rules below +npm run depcruise # dependency-cruiser — enforces the layer rules +npm run lint # ESLint, with known violations suppressed ``` -### Architecture (hexagonal / ports & adapters) - -Dependencies point inward, and the table below is the rule — read it before changing boundaries, -ports, or command routing. `depcruise` enforces it in CI (`ts/.dependency-cruiser.cjs`). -`ts/docs/machine-interface.md` is the source of truth for the JSON contract (envelope, exit codes, -stdout/stderr discipline). - -| Area (`ts/src/…`) | Role | May depend on | Must NOT depend on | -|---|---|---|---| -| `domain` | Pure rules & values, zero I/O (address, amounts, derivation, wallet, family, errors) | Node / pure libs only | application, adapters, bootstrap | -| `application` | Use cases, services, contracts, and **ports** (interfaces it owns) | `domain` | adapters, bootstrap | -| `adapters/inbound` | CLI driving side — parse argv, route to use cases, render output | application, domain | adapters/outbound, bootstrap | -| `adapters/outbound` | Implements application ports — keystore, TronWeb/Tron gateway, Ledger, price, config, persistence | application ports, domain | adapters/inbound, bootstrap | -| `bootstrap` | Composition root + process lifecycle (`runner.ts`, `composition.ts`, `argv.ts`, `families/`) | all areas | — (assembly only) | - -Key points: -- **Ports live in `application/ports/`** (e.g. `wallet-repository`, `tron-gateway`, `ledger-device`, - `price-provider`); outbound adapters implement them (dependency inversion). Shared transaction input types live in - `application/contracts/`; ports must not import use cases or services (`ports-are-innermost`). -- **Chain-family differences** are isolated per family — `application/use-cases//`, - `adapters/outbound/chain//`, and the family plugin under `bootstrap/families/`. Both - `tron` and `evm` are registered unconditionally (`bootstrap/composition.ts`) and reachable: the - builtin networks and aliases cover ETH, Sepolia, BSC, BSC testnet, Base and Base Sepolia alongside the TRON three. - There is no family-level feature gate. EVM simply binds a narrower command set (30 bindings: - account, block, chain, tx, token, contract, message/typed-data signing, `bai recharge` and eight ERC-8004 identity commands) against TRON's 96, which adds - stake, permission, proposal, asset, GasFree, TronLink multisig, and the SunSwap and SunPump groups (17 commands). - The counts are the `families` tags in `wallet-cli --json-schema`. -- **A single Zod schema per command** drives validation, yargs arity, help text, and JSON Schema. -- **Secrets** (private keys, mnemonics, BIP39 passphrases) are encrypted at rest and never accepted - from argv or env — only a dedicated stdin channel or hidden TTY prompt. - -### Adding a TypeScript command +`npm run depcruise` uses `ts/.dependency-cruiser-known-violations.json` and fails on new violations or +stale/duplicate baseline entries. After fixing a known violation or enabling a rule with existing violations, +run `npm run depcruise:baseline` from `ts/`, review the exact entries added or removed, and rerun +`npm run depcruise`. Commit the baseline diff with the corresponding code or rule change. Regeneration records +all current violations; fix unintended new violations before regenerating. To see unsuppressed diagnostics, +run `npx --no-install depcruise src`. + +`npm run lint` uses `ts/eslint-suppressions.json`, ESLint's bulk suppressions file, which records a violation +count per file and rule. It fails when a count rises, when it falls (the entry stays until regeneration), and +on an entry for a file that is deleted, git-ignored, or ignored by ESLint (including `--ignore-pattern`). +Keys must use ESLint's relative POSIX paths, such as `src/example.ts`; aliases such as `./src/example.ts` fail. +Counts are compared, not locations, so fixing one violation and adding another of the same rule in the same +file passes. After fixing a known +violation or enabling a rule with existing violations, run `npm run lint:baseline` from `ts/`, review the exact +entries added or removed, and rerun `npm run lint`; commit the diff as for depcruise. Pass the same +`--ignore-pattern` options to both commands, for example for local git-ignored directories. To see the suppressed +diagnostics, run `npx --no-install eslint . --format json` and read each file's `suppressedMessages`. +Boolean ignore options use the last setting: `--ignore` or `--no-ignore`, optionally followed by `=true` +or `=false`. The lint wrapper rejects other assignment values, including whitespace-padded booleans. + +Local ESLint rules live in `ts/eslint-rules/`; their tests in `ts/test/lint/rules/` run with +`npm run test:unit`. The baseline can include test and tooling files. The npm-script integration suite +`ts/test/lint/suppressions.test.ts` remains separate from these rule tests. + +### Development rules + +The rules for `ts/` are in `ts/docs/dev/rules/`. They are the single source of truth; do not restate them here. + +@ts/docs/dev/rules/architecture.md +@ts/docs/dev/rules/comments.md + +Which commands each chain family supports: the `families` tags in `wallet-cli --json-schema`. + +### Adding a command 1. Add the command module under `adapters/inbound/cli/commands/` with its Zod schema. -2. Route it to an application use case (`application/use-cases/…`, e.g. `tron/transaction-service.ts`); - do not put I/O or chain logic in the inbound layer. -3. If it needs new I/O, define a **port** in `application/ports/` and implement it in - `adapters/outbound/`. Wire it in `bootstrap/composition.ts`. +2. Route it to an application use case under `application/use-cases/`. +3. If it needs new I/O, define a port in `application/ports/`, implement it in `adapters/outbound/`, and wire + it in `bootstrap/composition.ts`. 4. Add co-located `*.test.ts` and run `npm run depcruise && npm run typecheck && npm test`. -## Build & Run +## Java (`java/`) + +**All Java paths are relative to `java/`, and all Java commands are run from that directory** (`cd java` first). + +### Build & Run ```bash # Build the project (generates protobuf sources into src/main/gen/) @@ -96,7 +119,7 @@ Key points: Java 8 source/target compatibility. Protobuf sources are in `src/main/protos/` and generate into `src/main/gen/` — this directory is git-tracked but rebuilt on `clean`. -## End-to-end coverage +### End-to-end coverage `java/qa-repl/` drives the real interactive shell over a pty (`expect`) against a funded Nile account — wallet lifecycle, account queries, a signed 1 TRX transfer, and @@ -110,11 +133,11 @@ cd java ./qa-repl/cli-boundary.sh build/libs/wallet-cli.jar # entry point takes only --version/--help ``` -## Architecture +### Architecture This is a **TRON blockchain CLI wallet** built on the [Trident SDK](https://github.com/tronprotocol/trident). It communicates with TRON nodes via gRPC. -### One CLI Mode +#### One CLI Mode **Interactive REPL** — `Client` class with JCommander `@Parameters` inner classes. Entry point: `org.tron.walletcli.Client`. Features tab completion, interactive prompts, and conversational @@ -125,20 +148,20 @@ The shell is started one way only: a bare `java -jar wallet-cli.jar`. The entry TypeScript CLI on stderr and exits 2. Non-interactive, scriptable and CI use belongs to `ts/` (npm `@tron-walletcli/wallet-cli`). -### Request Flow +#### Request Flow ``` User Input → Client (JCommander) → WalletApiWrapper → WalletApi → Trident SDK → gRPC → TRON Node ``` -### Key Classes +#### Key Classes - **`org.tron.walletcli.Client`** — REPL entry point and command dispatcher. Each command is a JCommander `@Parameters` inner class. - **`org.tron.walletcli.WalletApiWrapper`** — Orchestration layer between CLI and core wallet logic. Handles transaction construction, signing, and broadcasting. - **`org.tron.walletserver.WalletApi`** — Core wallet operations: account management, transaction creation, proposals, asset operations. Delegates gRPC calls to Trident. - **`org.tron.walletcli.ApiClientFactory`** — Creates gRPC client instances for different networks (mainnet, Nile testnet, Shasta testnet, custom). -### Package Organization +#### Package Organization | Package | Purpose | |---------|---------| @@ -152,13 +175,13 @@ User Input → Client (JCommander) → WalletApiWrapper → WalletApi → Triden | `multi` | Multi-signature transaction handling | | `gasfree` | GasFree transaction API (transfer tokens without gas) | -### Configuration +#### Configuration - **Network config:** `src/main/resources/config.conf` (HOCON format via Typesafe Config) - **Logging:** `src/main/resources/logback.xml` (Logback, INFO level console + rolling file) - **Lombok:** `lombok.config` — uses `logger` as the log field name (not the default `log`) -### Key Frameworks & Libraries +#### Key Frameworks & Libraries - **Trident SDK 0.11.0** — All gRPC API calls to TRON nodes - **JCommander 1.82** — CLI argument parsing (interactive REPL) diff --git a/java/AGENTS.md b/java/AGENTS.md new file mode 100644 index 000000000..374905b50 --- /dev/null +++ b/java/AGENTS.md @@ -0,0 +1,4 @@ +# Java agent instructions + +Read [the repository instructions](../AGENTS.md) and [the local instructions](CLAUDE.md), including +the Graphify usage and refresh requirements, before working in this directory. diff --git a/java/CLAUDE.md b/java/CLAUDE.md new file mode 100644 index 000000000..2d4dec9d1 --- /dev/null +++ b/java/CLAUDE.md @@ -0,0 +1,16 @@ +# Java agent instructions + +Read [the repository instructions](../CLAUDE.md) for build commands and the shared Graphify workflow. + +## Graphify + +Follow the [Graphify refresh requirements](../CLAUDE.md#graphify) when modifying code or tests here. +From `java/`, use: + +```bash +bash ../scripts/graphify.sh query "WalletApiWrapper" --budget 1200 +bash ../scripts/graphify.sh explain WalletApi +bash ../scripts/graphify.sh refresh +``` + +The wrapper uses the shared repository graph at `../graphify-out/`. diff --git a/scripts/graphify.sh b/scripts/graphify.sh new file mode 100644 index 000000000..6ac76252d --- /dev/null +++ b/scripts/graphify.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" +graphify_python="$repo_root/graphify-out/.venv/bin/python" +graphify_cli="$repo_root/graphify-out/.venv/bin/graphify" +export GRAPHIFY_BACKEND=none + +case "${1:-help}" in + setup) + if [[ ! -x "$graphify_python" ]]; then + uv venv --python 3.13 graphify-out/.venv + fi + uv pip install --python "$graphify_python" 'graphifyy==0.9.97' + ;; + refresh) + if [[ ! -x "$graphify_cli" ]]; then + echo 'Run bash scripts/graphify.sh setup first.' >&2 + exit 1 + fi + "$graphify_cli" extract . --code-only --max-workers 4 + "$graphify_cli" cluster-only . + "$graphify_cli" tree --root . --label wallet-cli + ;; + help|--help|-h) + echo 'Usage: bash scripts/graphify.sh setup|refresh| [args...]' + echo 'Examples: query "MessageSigner" --budget 1200; explain "TronGridHistoryReader"' + ;; + *) + if [[ ! -x "$graphify_cli" ]]; then + echo 'Run bash scripts/graphify.sh setup first.' >&2 + exit 1 + fi + exec "$graphify_cli" "$@" + ;; +esac diff --git a/ts/.dependency-cruiser-known-violations.json b/ts/.dependency-cruiser-known-violations.json new file mode 100644 index 000000000..cbc9651c6 --- /dev/null +++ b/ts/.dependency-cruiser-known-violations.json @@ -0,0 +1,35 @@ +[ + { + "type": "dependency", + "from": "src/adapters/outbound/x402/gasfree-errors.test.ts", + "to": "@bankofai/x402-tron/gasfree/client", + "unresolvedTo": "@bankofai/x402-tron/gasfree/client", + "dependencyTypes": ["unknown"], + "rule": { + "severity": "error", + "name": "x402-payment-modules-only-in-payment-client" + } + }, + { + "type": "dependency", + "from": "src/adapters/outbound/x402/gasfree-errors.test.ts", + "to": "node_modules/@bankofai/x402-fetch/dist/cjs/index.js", + "unresolvedTo": "@bankofai/x402-fetch", + "dependencyTypes": ["npm-dev", "import"], + "rule": { + "severity": "error", + "name": "x402-payment-modules-only-in-payment-client" + } + }, + { + "type": "dependency", + "from": "src/adapters/outbound/x402/nile-settlement.test.ts", + "to": "@bankofai/x402-tron/exact/facilitator", + "unresolvedTo": "@bankofai/x402-tron/exact/facilitator", + "dependencyTypes": ["unknown"], + "rule": { + "severity": "error", + "name": "x402-payment-modules-only-in-payment-client" + } + } +] diff --git a/ts/.dependency-cruiser.cjs b/ts/.dependency-cruiser.cjs index 2e8def540..231bad9ba 100644 --- a/ts/.dependency-cruiser.cjs +++ b/ts/.dependency-cruiser.cjs @@ -6,13 +6,93 @@ * * Inbound and outbound adapters are peers. They may meet only in bootstrap/composition. */ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { readdirSync } = require("node:fs"); + +const SDK_PACKAGES = "(tronweb|ethers)(/|$)|@(sun-protocol|bankofai|ledgerhq)/"; +const HTTP_CLIENT_PACKAGES = "(axios|undici|node-fetch|got|ky)(/|$)"; + +// Only a missing directory means "no integrations"; any other error would silently drop rules. +const directories = (path) => { + try { + return readdirSync(path, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } +}; + +// Listed from the cruised tree so each name is regex-escaped; a `$1` capture would be inserted unescaped. +const OUTBOUND_INTEGRATIONS = [ + ...directories("src/adapters/outbound/chain").map((family) => `chain/${family}`), + ...directories("src/adapters/outbound").filter((name) => name !== "chain"), +]; + +const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// Export-map subpaths of the modules that sign, send, or settle. Each pattern matches the bare +// specifier and the file it resolves to, since depcruise reports whichever it ends up with. +const LEDGER_SIGNING_MODULES = { + "hw-app-trx": ["Trx", "TIP712"], + "hw-app-eth": ["Eth", "modules/EIP712"], +}; +const X402_PAYMENT_MODULES = { + "x402-core": ["client", "facilitator"], + "x402-evm": [ + "exact/client", + "exact/facilitator", + "exact/v1/client", + "exact/v1/facilitator", + "upto/client", + "upto/facilitator", + "batch-settlement/client", + "batch-settlement/server", + "batch-settlement/facilitator", + "auth-capture/client", + "adapters/agent-wallet", + ], + "x402-tron": [ + "exact", + "exact/client", + "exact/facilitator", + "upto", + "upto/client", + "upto/facilitator", + "gasfree/client", + "gasfree/facilitator", + "batch-settlement/client", + "batch-settlement/server", + "batch-settlement/facilitator", + ], +}; +const LEDGER_SIGNING_PATTERN = Object.entries(LEDGER_SIGNING_MODULES) + .map(([app, modules]) => `${app}($|/(lib/|lib-es/|)(${modules.join("|")})(/index|)(\\.js|)$)`) + .join("|"); +const X402_PAYMENT_PATTERN = [ + "x402-fetch($|/dist/(esm|cjs)/index\\.(mjs|js)$)", + ...Object.entries(X402_PAYMENT_MODULES).map( + ([pkg, modules]) => `${pkg}/(dist/(esm|cjs)/|)(${modules.join("|")})(/index|)(\\.(mjs|js)|)$`, + ), +].join("|"); + module.exports = { forbidden: [ { name: "ports-are-innermost", severity: "error", - from: { path: "^src/application/ports/" }, - to: { path: "^src/application/(use-cases|services)/" }, + from: { path: "^src/application/(ports|contracts)/", pathNot: "\\.test\\.ts$" }, + to: { path: "^src/application/", pathNot: "^src/application/(ports|contracts)/" }, + }, + { + name: "application-imports-no-use-case", + severity: "error", + comment: + "a use case is a *-service.ts module under application/use-cases; nothing in application imports one", + from: { path: "^src/application/", pathNot: "\\.test\\.ts$" }, + to: { path: "^src/application/use-cases/.*[^/]-service\\.ts$" }, }, { name: "no-circular", @@ -47,6 +127,67 @@ module.exports = { from: { path: "^src/adapters/outbound/", pathNot: "\\.test\\.ts$" }, to: { path: "^src/(adapters/inbound|bootstrap)/" }, }, + { + name: "outbound-imports-no-service-or-use-case", + severity: "error", + comment: + "outbound adapters import application ports and contracts, never services or use cases", + from: { path: "^src/adapters/outbound/", pathNot: "\\.test\\.ts$" }, + to: { path: "^src/application/(services|use-cases)/" }, + }, + { + name: "domain-and-application-use-allowlisted-packages", + severity: "error", + comment: + "domain and application import outside packages only from @noble/*, @scure/*, and node:crypto", + from: { path: "^src/(domain|application)/", pathNot: "\\.test\\.ts$" }, + // Node core modules resolve without the `node:` prefix. + to: { pathNot: "^(src/|crypto$)|(^|node_modules/)@(noble|scure)/" }, + }, + { + name: "sdks-and-http-clients-only-in-outbound", + severity: "error", + comment: + "chain, device, and protocol SDKs and HTTP clients are imported only in adapters/outbound", + from: { path: "^src/", pathNot: "^src/adapters/outbound/|\\.test\\.ts$" }, + to: { path: `(^|node_modules/)(${SDK_PACKAGES}|${HTTP_CLIENT_PACKAGES})` }, + }, + ...OUTBOUND_INTEGRATIONS.map((integration) => ({ + name: "integrations-do-not-import-each-other", + severity: "error", + comment: + "within outbound, an integration imports only itself and the shared http/ and persistence/ plumbing", + from: { + path: `^src/adapters/outbound/${escapeRegExp(integration)}/`, + pathNot: "\\.test\\.ts$", + }, + to: { + path: "^src/adapters/outbound/", + pathNot: `^src/adapters/outbound/(${escapeRegExp(integration)}|http|persistence)/`, + }, + })), + { + name: "sdks-only-inside-integrations", + severity: "error", + comment: + "outbound modules outside an integration, and the shared http/ and persistence/ plumbing, import no SDK", + from: { + path: "^src/adapters/outbound/((chain/)?[^/]+$|(http|persistence)/)", + pathNot: "\\.test\\.ts$", + }, + to: { path: `(^|node_modules/)(${SDK_PACKAGES})` }, + }, + { + name: "outbound-modules-outside-integrations-import-only-plumbing", + severity: "error", + comment: + "a module directly in adapters/outbound/ or chain/ belongs to no integration; within outbound it imports only http/ and persistence/", + from: { path: "^src/adapters/outbound/(chain/)?[^/]+$", pathNot: "\\.test\\.ts$" }, + to: { + path: "^src/adapters/outbound/", + pathNot: "^src/adapters/outbound/(http|persistence)/", + }, + }, { name: "sdk-adapters-only", severity: "error", @@ -60,26 +201,37 @@ module.exports = { to: { path: "@sun-protocol/sun-sdk-" }, }, { - name: "sunswap-does-not-import-sunpump", + name: "no-runtime-import", + severity: "error", + comment: + "@sun-protocol/sun-sdk-runtime can broadcast (sendAction) and build a wallet from a private key; exactly one file builds a read-only runtime over a TronClient that throws on both, and nothing else may reach for it", + from: { path: "^src/", pathNot: "^src/adapters/outbound/sunswap/sdk-runtime\\.ts$" }, + to: { path: "@sun-protocol/sun-sdk-runtime" }, + }, + { + name: "ledger-apps-only-in-ledger-wrapper", severity: "error", - comment: "the two SunSwap/SunPump adapters are separate integrations and share no code", - from: { path: "^src/adapters/outbound/sunswap/" }, - to: { path: "^src/adapters/outbound/sunpump/" }, + comment: "the Ledger app modules that sign; only the ledger wrapper imports them", + from: { path: "^src/", pathNot: "^src/adapters/outbound/ledger/index\\.ts$" }, + to: { path: `(^|node_modules/)@ledgerhq/(${LEDGER_SIGNING_PATTERN})` }, }, { - name: "sunpump-does-not-import-sunswap", + name: "erc8004-sdk-only-in-registry-wrapper", severity: "error", - comment: "the reverse of sunswap-does-not-import-sunpump", - from: { path: "^src/adapters/outbound/sunpump/" }, - to: { path: "^src/adapters/outbound/sunswap/" }, + comment: "the ERC-8004 SDK client can send; only the registry wrapper imports it at runtime", + from: { path: "^src/", pathNot: "^src/adapters/outbound/erc8004/sdk-registry\\.ts$" }, + to: { + path: "(^|node_modules/)@bankofai/8004-sdk(/|$)", + dependencyTypesNot: ["type-only", "type-import"], + }, }, { - name: "no-runtime-import", + name: "x402-payment-modules-only-in-payment-client", severity: "error", comment: - "@sun-protocol/sun-sdk-runtime can broadcast (sendAction) and build a wallet from a private key; exactly one file builds a read-only runtime over a TronClient that throws on both, and nothing else may reach for it", - from: { path: "^src/", pathNot: "^src/adapters/outbound/sunswap/sdk-runtime\\.ts$" }, - to: { path: "@sun-protocol/sun-sdk-runtime" }, + "the x402 modules that sign, send, or settle payments; only the payment client imports them", + from: { path: "^src/", pathNot: "^src/adapters/outbound/x402/payment-client\\.ts$" }, + to: { path: `(^|node_modules/)@bankofai/(${X402_PAYMENT_PATTERN})` }, }, ], options: { diff --git a/ts/.prettierignore b/ts/.prettierignore index c9dd8c467..a5b44dd2a 100644 --- a/ts/.prettierignore +++ b/ts/.prettierignore @@ -11,3 +11,13 @@ package-lock.json # scanned by eye and diffed one-line-per-code. Prettier would explode each entry into a multi-line # object, defeating the reason it is formatted this way. src/domain/errors/codes.ts + +# generated by archify +docs/dev/architecture.archify.json +docs/dev/architecture.html + +# generated by eslint --suppress-all, which writes no trailing newline +eslint-suppressions.json + +# file snapshots written by vitest +__snapshots__/ diff --git a/ts/AGENTS.md b/ts/AGENTS.md new file mode 100644 index 000000000..ec367acde --- /dev/null +++ b/ts/AGENTS.md @@ -0,0 +1,4 @@ +# TypeScript agent instructions + +Read [the repository instructions](../AGENTS.md) and [the local instructions](CLAUDE.md), including +the Graphify usage and refresh requirements, before working in this directory. diff --git a/ts/CLAUDE.md b/ts/CLAUDE.md new file mode 100644 index 000000000..c27695926 --- /dev/null +++ b/ts/CLAUDE.md @@ -0,0 +1,17 @@ +# TypeScript agent instructions + +Read [the repository instructions](../CLAUDE.md) for build commands and the shared Graphify workflow. +Before changing files here, read every file in [docs/dev/rules/](docs/dev/rules/). + +## Graphify + +Follow the [Graphify refresh requirements](../CLAUDE.md#graphify) when modifying code or tests here. +From `ts/`, use: + +```bash +bash ../scripts/graphify.sh query "MessageSigner" --budget 1200 +bash ../scripts/graphify.sh explain TxPipeline +bash ../scripts/graphify.sh refresh +``` + +The wrapper uses the shared repository graph at `../graphify-out/`. diff --git a/ts/README.md b/ts/README.md index 35693e8ff..b3455ca72 100644 --- a/ts/README.md +++ b/ts/README.md @@ -44,11 +44,11 @@ Networks are identified by a canonical [CAIP-2](https://chainagnostic.org/CAIPs/ Balances, tokens, and transactions are isolated per network. The family decides two things: **which address** a command acts as — one account holds a TRON base58 address and an EVM `0x` address, derived from the same seed — and **which commands exist**, since TRON protocol features (staking, SR voting, TRC10, the Bancor exchange, on-chain permissions, GasFree) have no EVM counterpart and are refused there with `family_mismatch`. Fees follow the family too: TRON's `tron-resource` model (bandwidth + energy) or EVM gas. See [networks](docs/concepts/networks.md), [accounts](docs/concepts/accounts-and-hd.md) and [energy & bandwidth](docs/concepts/energy-bandwidth.md). -The TRON ids used before CAIP-2 (`tron:mainnet`, `tron:nile`, `tron:shasta`) remain permanent aliases, so existing invocations keep working — but **output** now reports the CAIP-2 id, so a consumer that string-matches `tron:nile` must be updated. +`tron:mainnet`, `tron:nile` and `tron:shasta` are permanent aliases. Output reports canonical CAIP-2 network ids, such as `tron:3448148188` for Nile. ## Install -**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). +**Prerequisites**: [Node.js](https://nodejs.org) **22.23.4+, 24.22.1+ or 26.12.1+** (`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 diff --git a/ts/docs/commands/8004/add-operator.md b/ts/docs/commands/8004/add-operator.md index 681ad6e0f..bbffe8f09 100644 --- a/ts/docs/commands/8004/add-operator.md +++ b/ts/docs/commands/8004/add-operator.md @@ -6,7 +6,7 @@ Let an operator manage every Agent this account owns. ``` wallet-cli 8004 add-operator - [--wait [--wait-timeout ] | --sign-only | --build-only | --dry-run] + [--wait | --no-wait | --sign-only | --build-only | --dry-run] [--fee-limit ] [--permission-id ] [--expiration ] [options] ``` @@ -28,7 +28,8 @@ Runs on networks with an ERC-8004 Identity Registry: `tron`, `nile`, `shasta`, ` | Option | Description | |---|---| -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only` | | `--build-only` | Build and estimate, output the **unsigned** hex without unlocking the wallet; excludes `--dry-run` / `--sign-only` | | `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | @@ -51,7 +52,7 @@ Plus the [global options](../index.md#global-options-every-command). Let `TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH` manage every Agent of the active account. `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -printf '%s' "$PW" | wallet-cli 8004 add-operator TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --wait --password-stdin +printf '%s' "$PW" | wallet-cli 8004 add-operator TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --password-stdin ``` ```console @@ -66,7 +67,7 @@ printf '%s' "$PW" | wallet-cli 8004 add-operator TGkbaCYB4kRBc3Q6wjqkACefUvRwf2K ``` ```bash -printf '%s' "$PW" | wallet-cli 8004 add-operator TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --wait --password-stdin -o json +printf '%s' "$PW" | wallet-cli 8004 add-operator TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --password-stdin -o json ``` ```json @@ -81,17 +82,17 @@ printf '%s' "$PW" | wallet-cli 8004 add-operator TGkbaCYB4kRBc3Q6wjqkACefUvRwf2K | Mode | Fields | |---|---| -| default (submit) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | -| `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | +| `--no-wait` (submitted) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | +| default or `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | | `--sign-only` | `kind`, `mode: "sign-only"`, `hex`, `signed`, `address` (signer), `txId`, `fee`, `method`, `contract`, `identity` | | `--build-only` | `kind`, `mode: "build-only"`, `hex` (**unsigned**), `tx`, `fee`, `method`, `contract`, `identity` | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee`, unsigned `tx`, `method`, `contract`, plus `nonce` on EVM, `identity` | -`identity` holds `operator` and `requestedApproval` (`true` for this command). After a confirmed `--wait`, it adds `approved` — the permission read back from the registry, which should equal `requestedApproval`. If that read fails, you get a warning instead; do not send the transaction again. +`identity` holds `operator` and `requestedApproval` (`true` for this command). After confirmation, it adds `approved` — the permission read back from the registry, which should equal `requestedApproval`. If that read fails, you get a warning instead; do not send the transaction again. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`execution_reverted` — the registry refused the call during estimation, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`execution_reverted` — the registry refused the call during estimation, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` or `--no-wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) ## See also diff --git a/ts/docs/commands/8004/approve.md b/ts/docs/commands/8004/approve.md index 425ed6bbc..42512e484 100644 --- a/ts/docs/commands/8004/approve.md +++ b/ts/docs/commands/8004/approve.md @@ -7,7 +7,7 @@ Approve an operator for one Agent, or clear the approval. ``` wallet-cli 8004 approve wallet-cli 8004 approve --revoke - [--wait [--wait-timeout ] | --sign-only | --build-only | --dry-run] + [--wait | --no-wait | --sign-only | --build-only | --dry-run] [--fee-limit ] [--permission-id ] [--expiration ] [options] ``` @@ -33,7 +33,8 @@ Runs on networks with an ERC-8004 Identity Registry: `tron`, `nile`, `shasta`, ` | Option | Description | |---|---| | `--revoke` | Clear the current approval | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only` | | `--build-only` | Build and estimate, output the **unsigned** hex without unlocking the wallet; excludes `--dry-run` / `--sign-only` | | `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | @@ -56,7 +57,7 @@ Plus the [global options](../index.md#global-options-every-command). Let `TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH` manage Agent 173. `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -printf '%s' "$PW" | wallet-cli 8004 approve 173 TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --wait --password-stdin +printf '%s' "$PW" | wallet-cli 8004 approve 173 TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --password-stdin ``` ```console @@ -72,7 +73,7 @@ printf '%s' "$PW" | wallet-cli 8004 approve 173 TGkbaCYB4kRBc3Q6wjqkACefUvRwf2Kz ``` ```bash -printf '%s' "$PW" | wallet-cli 8004 approve 173 TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --wait --password-stdin -o json +printf '%s' "$PW" | wallet-cli 8004 approve 173 TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --password-stdin -o json ``` ```json @@ -87,8 +88,8 @@ To clear the approval later, run `8004 approve 173 --revoke`; its receipt shows | Mode | Fields | |---|---| -| default (submit) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | -| `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | +| `--no-wait` (submitted) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | +| default or `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | | `--sign-only` | `kind`, `mode: "sign-only"`, `hex`, `signed`, `address` (signer), `txId`, `fee`, `method`, `contract`, `identity` | | `--build-only` | `kind`, `mode: "build-only"`, `hex` (**unsigned**), `tx`, `fee`, `method`, `contract`, `identity` | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee`, unsigned `tx`, `method`, `contract`, plus `nonce` on EVM, `identity` | @@ -97,7 +98,7 @@ To clear the approval later, run `8004 approve 173 --revoke`; its receipt shows ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_authorized` — the account may not do this for this Agent; `agent_not_found` — no Agent with that id; `execution_reverted` — the registry refused the call for another reason, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`not_authorized` — the account may not do this for this Agent; `agent_not_found` — no Agent with that id; `execution_reverted` — the registry refused the call for another reason, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` or `--no-wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) ## See also diff --git a/ts/docs/commands/8004/index.md b/ts/docs/commands/8004/index.md index 29a99b9d5..a14ff0487 100644 --- a/ts/docs/commands/8004/index.md +++ b/ts/docs/commands/8004/index.md @@ -28,9 +28,9 @@ wallet-cli 8004 COMMAND - **Networks.** The registry is deployed on `tron`, `nile`, `shasta`, `bsc`, `bsc-testnet`, `base`, and `base-sepolia`. On `ethereum` and `sepolia` every command fails with `unsupported_network_capability`. - **Agent IDs** are decimal strings. An id may carry its canonical network id, as in `tron:3448148188:172` or `eip155:97:42` (aliases such as `nile:172` are not accepted); that network must be the selected one, or the command fails with `chain_id_mismatch`. - **URIs** must be `https://`, `ipfs://`, or a base64 JSON `data:` URI, at most 2048 characters. [`show`](show.md) loads only `https://` and `http://` registration documents, so prefer `https://`. -- **Reads** (`show`, `operator-check`) need no wallet. **Writes** sign with the active account and take the usual transaction options: `--wait`, `--sign-only`, `--build-only`, and on TRON `--fee-limit`, `--permission-id`, `--expiration`. +- **Reads** (`show`, `operator-check`) need no wallet. **Writes** sign with the active account and take the usual transaction options: `--wait` / `--no-wait`, `--sign-only`, `--build-only`, and on TRON `--fee-limit`, `--permission-id`, `--expiration`. - **Who may write.** `update` and `transfer` need the Agent's owner, its approved operator (from `approve`), or an operator of the owner (from `add-operator`). `approve` needs the owner or an operator of the owner — an Agent's own approved operator cannot pass the approval on. Anyone else is refused during fee estimation, before signing, with `not_authorized`; an Agent ID that does not exist fails with `agent_not_found`. -- **Receipts** group the Agent fields under `data.identity`. Values read from the chain after the change — a new Agent's `agentId`, `newURI`, `newOwner`, an operator's `approved` — appear only with `--wait`. +- **Receipts** group the Agent fields under `data.identity`. Values read from the chain after the change — a new Agent's `agentId`, `newURI`, `newOwner`, an operator's `approved` — appear only in a confirmed receipt. ## See also diff --git a/ts/docs/commands/8004/register.md b/ts/docs/commands/8004/register.md index 5cb388914..195f34e94 100644 --- a/ts/docs/commands/8004/register.md +++ b/ts/docs/commands/8004/register.md @@ -6,7 +6,7 @@ Register a new Agent. ``` wallet-cli 8004 register - [--wait [--wait-timeout ] | --sign-only | --build-only | --dry-run] + [--wait | --no-wait | --sign-only | --build-only | --dry-run] [--fee-limit ] [--permission-id ] [--expiration ] [options] ``` @@ -16,7 +16,7 @@ Calls the registry's `register(string)` from the active account (or `--account`) The URI must be `https://`, `ipfs://`, or a base64 JSON `data:` URI, at most 2048 characters, with no credentials in it. [`8004 show`](show.md) loads only `https://` and `http://` documents, so an Agent registered with an `ipfs://` or `data:` URI shows no registration details there; prefer `https://` if you want them readable with wallet-cli. -**The Agent ID is assigned by the chain.** The default submitted receipt has only `identity.uri`. With `--wait`, the receipt adds `identity.agentId` once the confirmed registration event can be read. Without `--wait`, read it from the transaction afterwards: in [`tx info`](../tx/info.md)` -o json`, the first entry of `data.info.log` is the registry's `Transfer` event, and its last topic is the Agent ID in hex (`…00aa` is Agent 170). Do not register again to get the id — that creates a second Agent. +**The Agent ID is assigned by the chain.** A submitted receipt has only `identity.uri`. A confirmed receipt adds `identity.agentId` once the registration event can be read. Otherwise, for example with `--no-wait`, read it from the transaction afterwards: in [`tx info`](../tx/info.md)` -o json`, the first entry of `data.info.log` is the registry's `Transfer` event, and its last topic is the Agent ID in hex (`…00aa` is Agent 170). Do not register again to get the id — that creates a second Agent. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign. @@ -30,7 +30,8 @@ Runs on networks with an ERC-8004 Identity Registry: `tron`, `nile`, `shasta`, ` | Option | Description | |---|---| -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only` | | `--build-only` | Build and estimate, output the **unsigned** hex without unlocking the wallet; excludes `--dry-run` / `--sign-only` | | `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | @@ -53,7 +54,7 @@ Plus the [global options](../index.md#global-options-every-command). Register an Agent on Nile and wait for its ID. The registration document here is a small `data:` URI. `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -printf '%s' "$PW" | wallet-cli 8004 register "data:application/json;base64,eyJuYW1lIjoiV2VhdGhlciBBZ2VudCIsImRlc2NyaXB0aW9uIjoiUmV0dXJucyB3ZWF0aGVyIGZvcmVjYXN0cyBmb3IgYSBjaXR5In0=" --network nile --wait --password-stdin +printf '%s' "$PW" | wallet-cli 8004 register "data:application/json;base64,eyJuYW1lIjoiV2VhdGhlciBBZ2VudCIsImRlc2NyaXB0aW9uIjoiUmV0dXJucyB3ZWF0aGVyIGZvcmVjYXN0cyBmb3IgYSBjaXR5In0=" --network nile --password-stdin ``` ```console @@ -69,7 +70,7 @@ printf '%s' "$PW" | wallet-cli 8004 register "data:application/json;base64,eyJuY ``` ```bash -printf '%s' "$PW" | wallet-cli 8004 register "data:application/json;base64,eyJuYW1lIjoiV2VhdGhlciBBZ2VudCIsImRlc2NyaXB0aW9uIjoiUmV0dXJucyB3ZWF0aGVyIGZvcmVjYXN0cyBmb3IgYSBjaXR5In0=" --network nile --wait --password-stdin -o json +printf '%s' "$PW" | wallet-cli 8004 register "data:application/json;base64,eyJuYW1lIjoiV2VhdGhlciBBZ2VudCIsImRlc2NyaXB0aW9uIjoiUmV0dXJucyB3ZWF0aGVyIGZvcmVjYXN0cyBmb3IgYSBjaXR5In0=" --network nile --password-stdin -o json ``` ```json @@ -84,17 +85,17 @@ The new Agent ID is `173` — in JSON, `data.identity.agentId`. `Fee` is the TRX | Mode | Fields | |---|---| -| default (submit) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | -| `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | +| `--no-wait` (submitted) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | +| default or `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | | `--sign-only` | `kind`, `mode: "sign-only"`, `hex`, `signed`, `address` (signer), `txId`, `fee`, `method`, `contract`, `identity` | | `--build-only` | `kind`, `mode: "build-only"`, `hex` (**unsigned**), `tx`, `fee`, `method`, `contract`, `identity` | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee`, unsigned `tx`, `method`, `contract`, plus `nonce` on EVM, `identity` | -`identity` holds `uri`, plus `agentId` (decimal string) after a confirmed `--wait`. +`identity` holds `uri`, plus `agentId` (decimal string) after confirmation. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`execution_reverted` — the registry refused the call during estimation, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`execution_reverted` — the registry refused the call during estimation, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` or `--no-wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) ## See also diff --git a/ts/docs/commands/8004/remove-operator.md b/ts/docs/commands/8004/remove-operator.md index 73e14b807..c3591e753 100644 --- a/ts/docs/commands/8004/remove-operator.md +++ b/ts/docs/commands/8004/remove-operator.md @@ -6,7 +6,7 @@ Remove an operator's permission to manage all of this account's Agents. ``` wallet-cli 8004 remove-operator - [--wait [--wait-timeout ] | --sign-only | --build-only | --dry-run] + [--wait | --no-wait | --sign-only | --build-only | --dry-run] [--fee-limit ] [--permission-id ] [--expiration ] [options] ``` @@ -26,7 +26,8 @@ Runs on networks with an ERC-8004 Identity Registry: `tron`, `nile`, `shasta`, ` | Option | Description | |---|---| -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only` | | `--build-only` | Build and estimate, output the **unsigned** hex without unlocking the wallet; excludes `--dry-run` / `--sign-only` | | `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | @@ -49,7 +50,7 @@ Plus the [global options](../index.md#global-options-every-command). Remove `TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH` as an operator for all Agents of the active account. `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -printf '%s' "$PW" | wallet-cli 8004 remove-operator TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --wait --password-stdin +printf '%s' "$PW" | wallet-cli 8004 remove-operator TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --password-stdin ``` ```console @@ -64,7 +65,7 @@ printf '%s' "$PW" | wallet-cli 8004 remove-operator TGkbaCYB4kRBc3Q6wjqkACefUvRw ``` ```bash -printf '%s' "$PW" | wallet-cli 8004 remove-operator TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --wait --password-stdin -o json +printf '%s' "$PW" | wallet-cli 8004 remove-operator TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --password-stdin -o json ``` ```json @@ -79,17 +80,17 @@ printf '%s' "$PW" | wallet-cli 8004 remove-operator TGkbaCYB4kRBc3Q6wjqkACefUvRw | Mode | Fields | |---|---| -| default (submit) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | -| `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | +| `--no-wait` (submitted) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | +| default or `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | | `--sign-only` | `kind`, `mode: "sign-only"`, `hex`, `signed`, `address` (signer), `txId`, `fee`, `method`, `contract`, `identity` | | `--build-only` | `kind`, `mode: "build-only"`, `hex` (**unsigned**), `tx`, `fee`, `method`, `contract`, `identity` | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee`, unsigned `tx`, `method`, `contract`, plus `nonce` on EVM, `identity` | -`identity` holds `operator` and `requestedApproval` (`false` for this command). After a confirmed `--wait`, it adds `approved` — the permission read back from the registry, which should equal `requestedApproval`. If that read fails, you get a warning instead; do not send the transaction again. +`identity` holds `operator` and `requestedApproval` (`false` for this command). After confirmation, it adds `approved` — the permission read back from the registry, which should equal `requestedApproval`. If that read fails, you get a warning instead; do not send the transaction again. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`execution_reverted` — the registry refused the call during estimation, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`execution_reverted` — the registry refused the call during estimation, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` or `--no-wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) ## See also diff --git a/ts/docs/commands/8004/show.md b/ts/docs/commands/8004/show.md index d565f98d7..ccb09538a 100644 --- a/ts/docs/commands/8004/show.md +++ b/ts/docs/commands/8004/show.md @@ -12,7 +12,7 @@ wallet-cli 8004 show [options] Reads the Agent's owner, URI, and per-Agent approved operator from the registry of the selected network, then loads the registration document the URI points to. No wallet or password is needed. -The on-chain fields always come back. Loading the document is best-effort, and **only `https://` and `http://` URIs are fetched**. The response must be JSON (`application/json`, UTF-8), at most 1 MiB, and a JSON object; redirects and addresses on local or private networks are refused, and the request gives up after at most 10 seconds. +The on-chain fields always come back. Loading the document is best-effort, and **only `https://` and `http://` URIs are fetched**. The response must be JSON (`application/json`, UTF-8), at most 1 MiB, and a JSON object; redirects and addresses on local or private networks are refused, and the request gives up after at most 10 seconds, also with `--timeout 0`. If the command's `--timeout` ends during the load, the command fails with `timeout`. Anything else becomes a warning instead of an error, and the document is left out. That includes `data:` and `ipfs://` URIs: [`8004 register`](register.md) and [`8004 update`](update.md) accept them, but `show` does not read them. The warning is in `meta.warnings` (text output prints it as `warning: …`). diff --git a/ts/docs/commands/8004/transfer.md b/ts/docs/commands/8004/transfer.md index b6b8f90c3..b8379d509 100644 --- a/ts/docs/commands/8004/transfer.md +++ b/ts/docs/commands/8004/transfer.md @@ -6,7 +6,7 @@ Transfer an Agent to a new owner. ``` wallet-cli 8004 transfer - [--wait [--wait-timeout ] | --sign-only | --build-only | --dry-run] + [--wait | --no-wait | --sign-only | --build-only | --dry-run] [--fee-limit ] [--permission-id ] [--expiration ] [options] ``` @@ -16,7 +16,7 @@ Calls the registry's `transferFrom(address,address,uint256)`, moving the Agent f **The transfer is final** — after it, only the new owner can update, approve, or transfer the Agent. Check `newOwner` before sending. -The receipt names the owner read before the transfer (`oldOwner`) and the one requested. With `--wait`, it reads the owner again after confirmation and adds `newOwner`; if that read fails, you get a warning instead. +The receipt names the owner read before the transfer (`oldOwner`) and the one requested. Once the transaction is confirmed, it reads the owner again and adds `newOwner`; if that read fails, you get a warning instead. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign. @@ -31,7 +31,8 @@ Runs on networks with an ERC-8004 Identity Registry: `tron`, `nile`, `shasta`, ` | Option | Description | |---|---| -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only` | | `--build-only` | Build and estimate, output the **unsigned** hex without unlocking the wallet; excludes `--dry-run` / `--sign-only` | | `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | @@ -54,7 +55,7 @@ Plus the [global options](../index.md#global-options-every-command). Transfer Agent 173 to `TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH`. `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -printf '%s' "$PW" | wallet-cli 8004 transfer 173 TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --wait --password-stdin +printf '%s' "$PW" | wallet-cli 8004 transfer 173 TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --password-stdin ``` ```console @@ -72,7 +73,7 @@ printf '%s' "$PW" | wallet-cli 8004 transfer 173 TGkbaCYB4kRBc3Q6wjqkACefUvRwf2K ``` ```bash -printf '%s' "$PW" | wallet-cli 8004 transfer 173 TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --wait --password-stdin -o json +printf '%s' "$PW" | wallet-cli 8004 transfer 173 TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --network nile --password-stdin -o json ``` ```json @@ -87,17 +88,17 @@ printf '%s' "$PW" | wallet-cli 8004 transfer 173 TGkbaCYB4kRBc3Q6wjqkACefUvRwf2K | Mode | Fields | |---|---| -| default (submit) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | -| `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | +| `--no-wait` (submitted) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | +| default or `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | | `--sign-only` | `kind`, `mode: "sign-only"`, `hex`, `signed`, `address` (signer), `txId`, `fee`, `method`, `contract`, `identity` | | `--build-only` | `kind`, `mode: "build-only"`, `hex` (**unsigned**), `tx`, `fee`, `method`, `contract`, `identity` | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee`, unsigned `tx`, `method`, `contract`, plus `nonce` on EVM, `identity` | -`identity` holds `agentId`, `oldOwner` and `requestedOwner`, plus `newOwner` after a confirmed `--wait`. +`identity` holds `agentId`, `oldOwner` and `requestedOwner`, plus `newOwner` after confirmation. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_authorized` — the account may not do this for this Agent; `agent_not_found` — no Agent with that id; `execution_reverted` — the registry refused the call for another reason, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_address` — `newOwner` is not a valid address, or is the zero address; `invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`not_authorized` — the account may not do this for this Agent; `agent_not_found` — no Agent with that id; `execution_reverted` — the registry refused the call for another reason, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_address` — `newOwner` is not a valid address, or is the zero address; `invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` or `--no-wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) ## See also diff --git a/ts/docs/commands/8004/update.md b/ts/docs/commands/8004/update.md index 17d8b1678..37757f7d4 100644 --- a/ts/docs/commands/8004/update.md +++ b/ts/docs/commands/8004/update.md @@ -6,7 +6,7 @@ Change an Agent's registration URI. ``` wallet-cli 8004 update - [--wait [--wait-timeout ] | --sign-only | --build-only | --dry-run] + [--wait | --no-wait | --sign-only | --build-only | --dry-run] [--fee-limit ] [--permission-id ] [--expiration ] [options] ``` @@ -16,7 +16,7 @@ Calls the registry's `setAgentURI(uint256,string)`. Only the Agent's owner, its The URI rules are the same as for [`8004 register`](register.md): `https://`, `ipfs://`, or base64 JSON `data:`, at most 2048 characters. [`8004 show`](show.md) loads only `https://` and `http://` documents, so an Agent registered with an `ipfs://` or `data:` URI shows no registration details there; prefer `https://` if you want them readable with wallet-cli. -The receipt names the URI read before the change (`oldURI`) and the one requested. With `--wait`, it reads the URI again after confirmation and adds `newURI`. +The receipt names the URI read before the change (`oldURI`) and the one requested. Once the transaction is confirmed, it reads the URI again and adds `newURI`. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign. @@ -31,7 +31,8 @@ Runs on networks with an ERC-8004 Identity Registry: `tron`, `nile`, `shasta`, ` | Option | Description | |---|---| -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only` | | `--build-only` | Build and estimate, output the **unsigned** hex without unlocking the wallet; excludes `--dry-run` / `--sign-only` | | `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | @@ -54,7 +55,7 @@ Plus the [global options](../index.md#global-options-every-command). Point Agent 173 at a new registration document and wait for confirmation. `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -printf '%s' "$PW" | wallet-cli 8004 update 173 "data:application/json;base64,eyJuYW1lIjoiV2VhdGhlciBBZ2VudCIsImRlc2NyaXB0aW9uIjoiUmV0dXJucyB3ZWF0aGVyIGZvcmVjYXN0cyBhbmQgYWxlcnRzIGZvciBhIGNpdHkifQ==" --network nile --wait --password-stdin +printf '%s' "$PW" | wallet-cli 8004 update 173 "data:application/json;base64,eyJuYW1lIjoiV2VhdGhlciBBZ2VudCIsImRlc2NyaXB0aW9uIjoiUmV0dXJucyB3ZWF0aGVyIGZvcmVjYXN0cyBhbmQgYWxlcnRzIGZvciBhIGNpdHkifQ==" --network nile --password-stdin ``` ```console @@ -72,7 +73,7 @@ printf '%s' "$PW" | wallet-cli 8004 update 173 "data:application/json;base64,eyJ ``` ```bash -printf '%s' "$PW" | wallet-cli 8004 update 173 "data:application/json;base64,eyJuYW1lIjoiV2VhdGhlciBBZ2VudCIsImRlc2NyaXB0aW9uIjoiUmV0dXJucyB3ZWF0aGVyIGZvcmVjYXN0cyBhbmQgYWxlcnRzIGZvciBhIGNpdHkifQ==" --network nile --wait --password-stdin -o json +printf '%s' "$PW" | wallet-cli 8004 update 173 "data:application/json;base64,eyJuYW1lIjoiV2VhdGhlciBBZ2VudCIsImRlc2NyaXB0aW9uIjoiUmV0dXJucyB3ZWF0aGVyIGZvcmVjYXN0cyBhbmQgYWxlcnRzIGZvciBhIGNpdHkifQ==" --network nile --password-stdin -o json ``` ```json @@ -87,17 +88,17 @@ printf '%s' "$PW" | wallet-cli 8004 update 173 "data:application/json;base64,eyJ | Mode | Fields | |---|---| -| default (submit) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | -| `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | +| `--no-wait` (submitted) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract`, `identity` | +| default or `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `energyFeeSun` / `netFeeSun` / `result` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | | `--sign-only` | `kind`, `mode: "sign-only"`, `hex`, `signed`, `address` (signer), `txId`, `fee`, `method`, `contract`, `identity` | | `--build-only` | `kind`, `mode: "build-only"`, `hex` (**unsigned**), `tx`, `fee`, `method`, `contract`, `identity` | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee`, unsigned `tx`, `method`, `contract`, plus `nonce` on EVM, `identity` | -`identity` holds `agentId`, `oldURI` and `requestedURI`, plus `newURI` after a confirmed `--wait`. +`identity` holds `agentId`, `oldURI` and `requestedURI`, plus `newURI` after confirmation. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_authorized` — the account is not the owner or an operator of this Agent; `agent_not_found` — no Agent with that id; `execution_reverted` — the registry refused the call for another reason, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`not_authorized` — the account is not the owner or an operator of this Agent; `agent_not_found` — no Agent with that id; `execution_reverted` — the registry refused the call for another reason, with the raw revert data in `error.details.revertData`; `auth_required` — a signing mode without `--password-stdin`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value` — a malformed id or URI, or a bad option combination such as `--expiration` without `--sign-only` / `--build-only`; `family_mismatch` — an address of the other chain family; `invalid_option` — `--wait` or `--no-wait` with an early-exit mode; `unsupported_network_capability` — no registry on the selected network) ## See also diff --git a/ts/docs/commands/account/activate.md b/ts/docs/commands/account/activate.md index b92b05349..b1a44d84c 100644 --- a/ts/docs/commands/account/activate.md +++ b/ts/docs/commands/account/activate.md @@ -6,7 +6,7 @@ Activate a not-yet-existing account on-chain. ``` wallet-cli account activate --address - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -18,7 +18,7 @@ Use it only when an address needs to *exist* on its own — to be queryable, or Requires the payer account. The master password via `--password-stdin` is needed only when the selected mode signs — `--dry-run` and `--build-only` never unlock the wallet. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. -The Ledger TRON app cannot sign `AccountCreateContract`. A Ledger account can still use `--dry-run` or `--build-only`; `--sign-only`, plain submission and `--wait` fail with `ledger_unsupported` before the device is touched. +The Ledger TRON app cannot sign `AccountCreateContract`. A Ledger account can still use `--dry-run` or `--build-only`; `--sign-only` and submission fail with `ledger_unsupported` before the device is touched. ## Options @@ -30,7 +30,8 @@ The Ledger TRON app cannot sign `AccountCreateContract`. A Ledger account can st | `--build-only` | Build and estimate, output the **unsigned** hex (feed [`tx multisig --create`](../tx/multisig.md)). Excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | Plus the [global options](../index.md#global-options-every-command). `--account` selects the payer. @@ -39,10 +40,10 @@ Plus the [global options](../index.md#global-options-every-command). `--account` In the examples, `$PW` is your master password, fed on stdin via `--password-stdin`. -Default — broadcast and return the **submitted** receipt: +With `--no-wait` — broadcast and return the **submitted** receipt: ```bash -echo "$PW" | wallet-cli account activate --address TGQhRHn5tseyGo3RpWjn9ZA7fGDhJyWmcZ --network nile --password-stdin +echo "$PW" | wallet-cli account activate --address TGQhRHn5tseyGo3RpWjn9ZA7fGDhJyWmcZ --network nile --no-wait --password-stdin ``` ```console @@ -58,10 +59,10 @@ echo "$PW" | wallet-cli account activate --address TGQhRHn5tseyGo3RpWjn9ZA7fGDhJ {"schema":"wallet-cli.result.v1","success":true,"command":"account.activate","data":{"kind":"account-activate","stage":"submitted","txId":"a1b...","address":"TGQhRHn5tseyGo3RpWjn9ZA7fGDhJyWmcZ","payer":"TP2Zs9qKScTMs8jDYV3SAHQ5pqgKY1NQ5V"},"meta":{"durationMs":17,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` -Add `--wait` to block until confirmed, with the actual block and fee: +Without `--no-wait`, the command blocks until confirmed, with the actual block and fee: ```bash -echo "$PW" | wallet-cli account activate --address TGQhRHn5tseyGo3RpWjn9ZA7fGDhJyWmcZ --network nile --wait --password-stdin +echo "$PW" | wallet-cli account activate --address TGQhRHn5tseyGo3RpWjn9ZA7fGDhJyWmcZ --network nile --password-stdin ``` ```console @@ -84,13 +85,13 @@ echo "$PW" | wallet-cli account activate --address TGQhRHn5tseyGo3RpWjn9ZA7fGDhJ | Stage | Fields | |---|---| -| default (submit) | `kind: "account-activate"`, `stage: "submitted"`, `txId`, `address`, `payer` | -| `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | +| `--no-wait` (submitted) | `kind: "account-activate"`, `stage: "submitted"`, `txId`, `address`, `payer` | +| default or `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | | `--dry-run` | `kind`, `mode: "dry-run"`, fee estimate, `address`, `payer`; no `txId` | ## Exit status -`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`account_already_active`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value` — malformed address). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed/dry-run in early-exit modes) · `1` execution failure (`account_already_active`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value` — malformed address). After a **confirmed** transaction the command reads the account back to verify the change took effect. That follow-up never turns an already-paid transaction into a command failure: a mismatch or an unreadable read is reported as a `meta.warnings` entry (`account_activate_postcheck_mismatch` / `account_activate_postcheck_unavailable`) with `success` still `true` and exit `0`. diff --git a/ts/docs/commands/account/history.md b/ts/docs/commands/account/history.md index 5fe8785b8..2e8627cdd 100644 --- a/ts/docs/commands/account/history.md +++ b/ts/docs/commands/account/history.md @@ -14,7 +14,7 @@ wallet-cli account history [--limit ] [--only ] [options] Lists recent activity touching the account, newest first. History is served by **TronGrid**, not plain node RPC — on networks/endpoints without TronGrid this command fails while `balance`/`info` still work. -`--only token` selects TronGrid's TRC20 transfer endpoint. `--only native` selects the general transactions endpoint and does not post-filter its records, so it can include non-native contract activity; omitting `--only` queries that same endpoint. Do not read `only: "native"` in the JSON as proof that every record is a TRX transfer. +`--only token` selects TronGrid's TRC20 transfer endpoint. `--only native` selects the general transactions endpoint and does not post-filter its records, so it can include non-native contract activity, such as TRC10 transfers (reported in the asset's own units, never as TRX); omitting `--only` queries that same endpoint. Do not read `only: "native"` in the JSON as proof that every record is a TRX transfer. ## Options @@ -57,14 +57,16 @@ wallet-cli account history --limit 2 --network nile -o json | `records[].txId` | string | Feed to [`tx info`](../tx/info.md) for detail | | `records[].time` | number | Epoch ms | | `records[].type` | string | Transaction type (e.g. `Transfer`, `CreateSmart`) | -| `records[].amount` | string | Transfer amount; empty when not a value transfer | -| `records[].symbol` | string | Asset symbol (e.g. `TRX`) | +| `records[].amount` | string | Transfer amount in `symbol` units; empty when not a value transfer, or for a TRC10 transfer whose asset metadata could not be read | +| `records[].rawAmount` | string | TRC10 transfers (`type: "TransferAsset"`) only: the amount in the asset's base units | +| `records[].assetId` | string | TRC10 transfers only: the asset id | +| `records[].symbol` | string | Asset symbol: `TRX`, or a TRC10 asset's abbreviation or name when known (absent otherwise) | | `records[].from` / `to` / `counterparty` | string | Addresses (may be empty per type) | -| `records[].status` | string | `ok` or failure marker | +| `records[].status` | string | `ok`, `failed`, or `unknown` when the record carries no execution result | ## Exit status -`0` · `1` execution failure (`history_not_supported` — a missing or incompatible TronGrid endpoint) · `2` usage error (limit out of 1–200; `family_mismatch` on an EVM network). +`0` · `1` execution failure (`history_not_supported` — no TronGrid endpoint is configured, or it answers HTTP 404; `timeout`, `rpc_error` — the endpoint did not answer or failed, retry; `provider_rate_limited` — HTTP 429, `error.details.httpStatus: 429`, retry later; `invalid_node_response` — the endpoint returned a body that is not a history page) · `2` usage error (limit out of 1–200; `family_mismatch` on an EVM network). ## See also diff --git a/ts/docs/commands/account/info.md b/ts/docs/commands/account/info.md index 1ec04f5b7..2f70b3bcb 100644 --- a/ts/docs/commands/account/info.md +++ b/ts/docs/commands/account/info.md @@ -41,7 +41,7 @@ wallet-cli account info --network nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"account.info","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","account":{"balance":"1976489000","create_time":1782787719000,"owner_permission":{},"active_permission":[],"frozenV2":[{},{"type":"ENERGY","amount":"12000000"},{"type":"TRON_POWER"}]},"resources":{"bandwidth":{"used":0,"limit":600},"energy":{"used":0,"limit":888}}},"meta":{"durationMs":1914,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"account.info","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","account":{"balance":"1976489000","create_time":1782787719000,"owner_permission":{},"active_permission":[],"frozenV2":[{},{"type":"ENERGY","amount":"12000000"},{"type":"TRON_POWER"}]},"resources":{"bandwidth":{"used":0,"limit":600},"energy":{"used":0,"limit":888}},"balanceSun":"1976489000","staked":{"energySun":"12000000","bandwidthSun":"0","tronPowerSun":"0","totalSun":"12000000"},"createdAt":1782787719000,"permissions":{"ownerThreshold":1,"ownerKeys":1,"activeGroups":1}},"meta":{"durationMs":1914,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` On an EVM network the same command reports the EVM account state: @@ -78,8 +78,12 @@ TRON: | `account` | object | Account object returned as-is by the TRON node: `balance` (SUN string), timestamps, `owner_permission` / `active_permission` (multi-sig keys & thresholds), `frozenV2` (staked amounts by type), etc.; fields are determined by the node, wallet-cli does not reshape them | | `resources.bandwidth` | object | `used` / `limit` (bytes) | | `resources.energy` | object | `used` / `limit` | +| `balanceSun` | string | Balance in SUN | +| `staked.energySun` / `.bandwidthSun` / `.tronPowerSun` / `.totalSun` | string | Staked TRX in SUN by resource, and their sum. `tronPowerSun` is stake that yields voting power only; `frozenV2` entries of any other type are not counted | +| `createdAt` | number \| null | Account creation time, epoch ms; `null` when the node reports none | +| `permissions` | object \| null | `{ownerThreshold, ownerKeys, activeGroups}`; `null` when the account is not activated | -The `resources` block is normalized by wallet-cli — stable, safe to program against; `account` is returned as-is by the node, its fields vary with the node/protocol and are not guaranteed stable. +Every field except `account` is normalized by wallet-cli — stable, safe to program against; `account` is returned as-is by the node, its fields vary with the node/protocol and are not guaranteed stable. EVM: @@ -92,6 +96,8 @@ EVM: | `symbol` | string | The network's native coin — `ETH`, `BNB` | | `type` | string | `eoa` (no code) or `contract` (code deployed at this address) | +Malformed TRON account balances, staking quantities or permissions fail with `provider_error`; missing protobuf zero values retain their zero defaults. An unactivated account has `permissions: null`, displayed as `—`. + ## Exit status `0` · `1` execution failure · `2` usage error. diff --git a/ts/docs/commands/account/portfolio.md b/ts/docs/commands/account/portfolio.md index a5e4b5a61..aa991ee04 100644 --- a/ts/docs/commands/account/portfolio.md +++ b/ts/docs/commands/account/portfolio.md @@ -18,7 +18,7 @@ Three outcomes are kept distinct, because they are different claims: - **Test network** — priced at `0` without asking anyone. A testnet coin is not traded, so there is nothing to look up; the network declares itself a testnet rather than being guessed from its id. - **Unknown** — `priceUsd` / `valueUsd` are `null` (an unlisted chain, or the price source failed). `null` means "we could not find out", which is not the same as worthless. If the price source itself errored, `data.priceUnavailable` is `true` with a `priceReason`. -A token whose **balance** cannot be read keeps its row rather than vanishing: `balance` / `rawBalance` are `null` and `balanceUnavailable` is `true` with a `reason`. One unreadable token never takes the whole portfolio down. `totalValueUsd` sums only the rows that could be valued. +A token whose **balance** cannot be read keeps its row rather than vanishing: `balance` / `rawBalance` are `null` and `balanceUnavailable` is `true` with a `reason`. One unreadable token never takes the whole portfolio down. `totalValueUsd` sums only the rows that could be valued. Running out of `--timeout` is different: the command fails with `timeout` and returns no partial portfolio. ## Options diff --git a/ts/docs/commands/account/set.md b/ts/docs/commands/account/set.md index 62a445ec5..e460a3648 100644 --- a/ts/docs/commands/account/set.md +++ b/ts/docs/commands/account/set.md @@ -6,7 +6,7 @@ Set the account's on-chain name or account id. ``` wallet-cli account set (--name | --id ) - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -29,9 +29,10 @@ Ledger support differs by field: the TRON app can sign `--name`, but not `--id` | `--dry-run` | Build and estimate only; no signature/broadcast, no password. Excludes `--sign-only` / `--build-only` | | `--sign-only` | Build and sign, output the signed hex (feed [`tx broadcast`](../tx/broadcast.md)). Excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build and estimate, output the **unsigned** hex (feed [`tx multisig --create`](../tx/multisig.md)). Excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s; [Ledger exception](../../guide/ledger.md#3-when-the-device-doesnt-respond)) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | Plus the [global options](../index.md#global-options-every-command). @@ -43,7 +44,7 @@ In the examples, `$PW` is your master password, fed on stdin via `--password-std Set the on-chain name and wait for confirmation: ```bash -echo "$PW" | wallet-cli account set --name "Acme Treasury" --network nile --wait --password-stdin +echo "$PW" | wallet-cli account set --name "Acme Treasury" --network nile --password-stdin ``` ```console @@ -57,7 +58,7 @@ echo "$PW" | wallet-cli account set --name "Acme Treasury" --network nile --wait ``` ```bash -echo "$PW" | wallet-cli account set --name "Acme Treasury" --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli account set --name "Acme Treasury" --network nile --password-stdin -o json ``` ```json @@ -67,7 +68,7 @@ echo "$PW" | wallet-cli account set --name "Acme Treasury" --network nile --wait Set the account id instead (`--id`); the id's uniqueness is enforced on-chain: ```bash -echo "$PW" | wallet-cli account set --id acme-treasury-01 --network nile --wait --password-stdin +echo "$PW" | wallet-cli account set --id acme-treasury-01 --network nile --password-stdin ``` ```console @@ -86,13 +87,13 @@ echo "$PW" | wallet-cli account set --id acme-treasury-01 --network nile --wait | Stage | Fields | |---|---| -| default (submit) | `kind: "account-set"`, `stage: "submitted"`, `txId`, `field` (`name`/`id`), `value`, `address` | -| `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | +| `--no-wait` (submitted) | `kind: "account-set"`, `stage: "submitted"`, `txId`, `field` (`name`/`id`), `value`, `address` | +| default or `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | | `--dry-run` | `kind`, `mode: "dry-run"`, fee estimate, `field`, `value`, `address`; no `txId` | ## Exit status -`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`name_already_set`, `id_already_set`, `id_taken`, `watch_only_no_signer`, `ledger_unsupported` — Ledger signing with `--id`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`, `invalid_option` — malformed or missing name/id). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed/dry-run in early-exit modes) · `1` execution failure (`name_already_set`, `id_already_set`, `id_taken`, `watch_only_no_signer`, `ledger_unsupported` — Ledger signing with `--id`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`, `invalid_option` — malformed or missing name/id). After a **confirmed** transaction the command reads the account back to verify the change took effect. That follow-up never turns an already-paid transaction into a command failure: a mismatch or an unreadable read is reported as a `meta.warnings` entry (`account_set_postcheck_mismatch` / `account_set_postcheck_unavailable`) with `success` still `true` and exit `0`. diff --git a/ts/docs/commands/address/generate.md b/ts/docs/commands/address/generate.md index d7eab6568..4a463f0e8 100644 --- a/ts/docs/commands/address/generate.md +++ b/ts/docs/commands/address/generate.md @@ -57,10 +57,11 @@ wallet-cli address generate -o json | `tron` | string | TRON base58 address | | `evm` | string | EVM `0x` address (EIP-55) | | `secretFile` | string | Path the private key was written to (absent with `--print-secret`) | +| `privateKey` | string | The private key, 64 hex characters; only with `--print-secret` | ## Exit status -`0` success · `1` execution failure (`io_error`; `entropy_failure` — the system CSPRNG was unavailable) · `2` usage error (`output_exists` — the `--out` target already exists and is never overwritten; `invalid_value`). +`0` success · `1` execution failure (`io_error` — the file or a missing parent directory cannot be created; nothing is written; `entropy_failure` — the system CSPRNG was unavailable) · `2` usage error (`output_exists` — the `--out` target already exists and is never overwritten; `invalid_value`). ## See also diff --git a/ts/docs/commands/asset/issue.md b/ts/docs/commands/asset/issue.md index f60f7e9fe..658b20d22 100644 --- a/ts/docs/commands/asset/issue.md +++ b/ts/docs/commands/asset/issue.md @@ -10,7 +10,7 @@ wallet-cli asset issue --name --supply --price : [--abbr ] [--precision <0-6>] [--description ] [--free-net-per-account ] [--public-free-net ] [--freeze : ...] - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -30,7 +30,7 @@ Constraints checked locally before broadcast: `--name` and `--abbr` are 1–32 v The Ledger TRON app cannot sign TRC10 issuance contract types. A Ledger account can dry-run or build unsigned hex; signing modes fail with `ledger_unsupported` before the device is touched. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -53,7 +53,8 @@ The Ledger TRON app cannot sign TRC10 issuance contract types. A Ledger account | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -65,7 +66,7 @@ In the examples, `$PW` is your master password (from an environment variable, pa ```bash echo "$PW" | wallet-cli asset issue --name MyToken --abbr MTK --supply 1000000000 --price 1:100 --precision 6 \ --start 2026-08-01 --end 2026-08-31 --url https://mytoken.io --description "Demo TRC10" \ - --freeze 100000000:30 --freeze 50000000:90 --network nile --wait --password-stdin + --freeze 100000000:30 --freeze 50000000:90 --network nile --password-stdin ``` ```console @@ -92,7 +93,7 @@ echo "$PW" | wallet-cli asset issue --name MyToken --abbr MTK --supply 100000000 ```bash echo "$PW" | wallet-cli asset issue --name MyToken --abbr MTK --supply 1000000000 --price 1:100 --precision 6 \ --start 2026-08-01 --end 2026-08-31 --url https://mytoken.io --description "Demo TRC10" \ - --freeze 100000000:30 --freeze 50000000:90 --network nile --wait --password-stdin -o json + --freeze 100000000:30 --freeze 50000000:90 --network nile --password-stdin -o json ``` ```json @@ -105,14 +106,14 @@ echo "$PW" | wallet-cli asset issue --name MyToken --abbr MTK --supply 100000000 | Stage | Fields | |---|---| -| default (submit) | `kind: "asset-issue"`, `stage: "submitted"`, `txId`, and the token definition below except `assetId` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), `failed`, and `assetId` — assigned by the chain, so known only once confirmed | +| `--no-wait` (submitted) | `kind: "asset-issue"`, `stage: "submitted"`, `txId`, and the token definition below except `assetId` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), `failed`, and `assetId` — assigned by the chain, so known only once confirmed | Definition fields: `issuerAddress`, `name`, `abbr`, `totalSupply` (raw decimal string), `precision`, `price` (a `trx:tokens` string derived back from the stored pair, so it is the **reduced** rate, not necessarily what you typed — `--price 2:200 --precision 6` reports `"1:100"`) with the stored `trxNum` / `num` pair, `startTime` / `endTime` (ms since epoch), `url`, `description`, `freeAssetNetLimit`, `publicFreeAssetNetLimit`, and `frozenSupply[]` (`amount` raw decimal string, `days`). Confirmation settlement fields are flat — there is no `resource` object, and the bandwidth field is `netUsed`. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`already_issued_asset` — this account already issued one, `transaction_rejected` — the node refused it, e.g. the balance cannot cover the issuance fee or a chain-side bound was exceeded, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`missing_option` — a required flag is absent; `invalid_asset_name` — name or abbreviation outside 1–32 visible ASCII; `invalid_value` — a malformed or non-positive rate, precision, supply or frozen tranche, a malformed date, `--start` not in the future, `--end` not after `--start`, or the rate exceeding int32 after reduction). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`already_issued_asset` — this account already issued one, `transaction_rejected` — the node refused it, e.g. the balance cannot cover the issuance fee or a chain-side bound was exceeded, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`missing_option` — a required flag is absent; `invalid_asset_name` — name or abbreviation outside 1–32 visible ASCII; `invalid_value` — a malformed or non-positive rate, precision, supply or frozen tranche, a malformed date, `--start` not in the future, `--end` not after `--start`, or the rate exceeding int32 after reduction). ## See also diff --git a/ts/docs/commands/asset/participate.md b/ts/docs/commands/asset/participate.md index b89403a8d..5394b50b3 100644 --- a/ts/docs/commands/asset/participate.md +++ b/ts/docs/commands/asset/participate.md @@ -6,7 +6,7 @@ Buy into a TRC10's ICO with TRX. ``` wallet-cli asset participate --pay - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -18,7 +18,7 @@ Buys from a token's issuance inside its funding window, at the fixed rate set wh The acting account cannot be the token's own issuer. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. The Ledger TRON app cannot sign TRC10 issuance contract types. A Ledger account can dry-run or build unsigned hex; signing modes fail with `ledger_unsupported` before the device is touched. @@ -33,7 +33,8 @@ The Ledger TRON app cannot sign TRC10 issuance contract types. A Ledger account | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -45,7 +46,7 @@ In the examples, `$PW` is your master password (from an environment variable, pa Spend 100 TRX on a token issued at `1:100`: ```bash -echo "$PW" | wallet-cli asset participate 1000124 --pay 100 --network nile --wait --password-stdin +echo "$PW" | wallet-cli asset participate 1000124 --pay 100 --network nile --password-stdin ``` ```console @@ -62,7 +63,7 @@ echo "$PW" | wallet-cli asset participate 1000124 --pay 100 --network nile --wai ``` ```bash -echo "$PW" | wallet-cli asset participate 1000124 --pay 100 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli asset participate 1000124 --pay 100 --network nile --password-stdin -o json ``` ```json @@ -75,14 +76,14 @@ echo "$PW" | wallet-cli asset participate 1000124 --pay 100 --network nile --wai | Stage | Fields | |---|---| -| default (submit) | `kind: "asset-participate"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress`, `participantAddress`, `paidSun`, `receivedAmount`, `precision` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), and `failed` | +| `--no-wait` (submitted) | `kind: "asset-participate"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress`, `participantAddress`, `paidSun`, `receivedAmount`, `precision` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), and `failed` | `paidSun` is the TRX spent in sun; `receivedAmount` is the token amount in its smallest unit. Both are decimal strings, and `precision` is included so text and machine consumers can scale the token amount. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`asset_not_found` — no such token, `not_in_ico_window` — outside the funding window, `self_participation` — you issued this token, `transaction_rejected` — the node refused it, e.g. the balance cannot cover `--pay`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`missing_option` — no `--pay`; `invalid_amount` — `--pay` is not a decimal number, or has more than 6 decimal places; `invalid_value` — `--pay` ≤ 0, or too small to buy one unit). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`asset_not_found` — no such token, `not_in_ico_window` — outside the funding window, `self_participation` — you issued this token, `transaction_rejected` — the node refused it, e.g. the balance cannot cover `--pay`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`missing_option` — no `--pay`; `invalid_amount` — `--pay` is not a decimal number, or has more than 6 decimal places; `invalid_value` — `--pay` ≤ 0, or too small to buy one unit). ## See also diff --git a/ts/docs/commands/asset/unfreeze.md b/ts/docs/commands/asset/unfreeze.md index 08c15d73a..1cb2788f8 100644 --- a/ts/docs/commands/asset/unfreeze.md +++ b/ts/docs/commands/asset/unfreeze.md @@ -6,7 +6,7 @@ Release matured frozen supply of the TRC10 you issued. ``` wallet-cli asset unfreeze - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -20,7 +20,7 @@ A tranche matures at its issuance `--start` plus its `days`, not at the moment t This is unrelated to [`stake unfreeze`](../stake/unfreeze.md), which releases staked TRX; the only thing they share is the word. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. The Ledger TRON app cannot sign TRC10 issuance contract types. A Ledger account can dry-run or build unsigned hex; signing modes fail with `ledger_unsupported` before the device is touched. @@ -35,7 +35,8 @@ This command has no options of its own. | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -45,7 +46,7 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli asset unfreeze --network nile --wait --password-stdin +echo "$PW" | wallet-cli asset unfreeze --network nile --password-stdin ``` ```console @@ -61,7 +62,7 @@ echo "$PW" | wallet-cli asset unfreeze --network nile --wait --password-stdin ``` ```bash -echo "$PW" | wallet-cli asset unfreeze --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli asset unfreeze --network nile --password-stdin -o json ``` ```json @@ -74,14 +75,14 @@ echo "$PW" | wallet-cli asset unfreeze --network nile --wait --password-stdin -o | Stage | Fields | |---|---| -| default (submit) | `kind: "asset-unfreeze"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress`, `precision`, and `releasedAmount` / `stillFrozenAmount` — present here too, but as the amounts this command *intends* to release | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), and `failed`; `releasedAmount` is then taken from the receipt | +| `--no-wait` (submitted) | `kind: "asset-unfreeze"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress`, `precision`, and `releasedAmount` / `stillFrozenAmount` — present here too, but as the amounts this command *intends* to release | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), and `failed`; `releasedAmount` is then taken from the receipt | `releasedAmount` and `stillFrozenAmount` are raw decimal strings (smallest unit), with `precision` for scaling. Both are always present: before confirmation they are this command's own computation from the asset's frozen tranches, and only the confirmed `releasedAmount` is the receipt's number. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_an_issuer` — this account has not issued a TRC10, `no_frozen_supply`, `not_yet_unfreezable` — nothing has matured yet, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error. +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`not_an_issuer` — this account has not issued a TRC10, `no_frozen_supply`, `not_yet_unfreezable` — nothing has matured yet, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error. ## See also diff --git a/ts/docs/commands/asset/update.md b/ts/docs/commands/asset/update.md index 04d6fd9f6..9f1065262 100644 --- a/ts/docs/commands/asset/update.md +++ b/ts/docs/commands/asset/update.md @@ -7,7 +7,7 @@ Change the mutable fields of the TRC10 you issued. ``` wallet-cli asset update [--description ] [--url ] [--free-net-per-account ] [--public-free-net ] - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -19,7 +19,7 @@ There is no token argument: the command always targets the TRC10 issued by the s Pass only the fields you are changing. The others are read from chain and written back unchanged, so nothing is silently cleared; at least one field is required. The receipt shows all four as they now stand. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. The Ledger TRON app cannot sign TRC10 issuance contract types. A Ledger account can dry-run or build unsigned hex; signing modes fail with `ledger_unsupported` before the device is touched. @@ -36,7 +36,8 @@ The Ledger TRON app cannot sign TRC10 issuance contract types. A Ledger account | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -46,7 +47,7 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 --network nile --wait --password-stdin +echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 --network nile --password-stdin ``` ```console @@ -64,7 +65,7 @@ echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 --network nile ``` ```bash -echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 --network nile --password-stdin -o json ``` ```json @@ -77,14 +78,14 @@ echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 --network nile | Stage | Fields | |---|---| -| default (submit) | `kind: "asset-update"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress`, and the four fields as submitted | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), and `failed` | +| `--no-wait` (submitted) | `kind: "asset-update"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress`, and the four fields as submitted | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), and `failed` | The four fields are `url`, `description`, `freeAssetNetLimit`, and `publicFreeAssetNetLimit` — always all four, including the ones read back unchanged. Confirmation settlement fields are flat; there is no nested `resource` object. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_an_issuer` — this account has not issued a TRC10, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`invalid_value` — no field given at all, or a URL / description that is too long; the command has no required flag, so a bare call is `invalid_value`, not `missing_option`). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`not_an_issuer` — this account has not issued a TRC10, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`invalid_value` — no field given at all, or a URL / description that is too long; the command has no required flag, so a bare call is `invalid_value`, not `missing_option`). ## See also diff --git a/ts/docs/commands/backup.md b/ts/docs/commands/backup.md index e7162b441..4c042cd8b 100644 --- a/ts/docs/commands/backup.md +++ b/ts/docs/commands/backup.md @@ -28,7 +28,7 @@ Without `--keystore`, a fully interactive terminal asks which format to write, b When the password comes from `--password-stdin`, or the run is otherwise non-interactive, there is no prompt and the native format is written. -A native backup of a wallet created before 4.13.1 can print a warning: some of its TRON accounts use an old path that importing the recovery phrase will not bring back. The warning names each account and the `--keystore` command that saves its key. Run those before deleting the wallet — see [Recover addresses after `legacy_derivation`](../troubleshooting/legacy-derivation-recovery.md). +A native backup warns when a seed account uses `m/44'/195'/'/0/0` with index greater than zero: importing the recovery phrase uses the default path and will not recreate that TRON address. The warning names each account and the `--keystore` command that saves its key. Run those before deleting the wallet — see [Recover addresses after `legacy_derivation`](../troubleshooting/legacy-derivation-recovery.md). A seed derives a different key per chain family, and a keystore carries only one of them, so `--network` selects which family's key is written — falling back to `config.defaultNetwork` when omitted. The receipt names the family it wrote, and so does the export log. A private-key account has one key and ignores the selection; the native format covers every family at once, so it needs no choice and reports none. @@ -43,7 +43,7 @@ With `--records` and no account, nothing is exported: the command lists the **lo - `--keystore` and `--out` describe an export, so combining either with `--records` fails rather than being silently ignored. - `--from` / `--to` / `--limit` / `--offset` filter the log, so any of them **without** `--records` fails too. -Both are `invalid_value` at exit `2`, and the message names the offending flag — for example `invalid --offset: --offset filters the export log; it needs --records`. +The first is `invalid_option` and the second `invalid_value`, both at exit `2`, and the message names the offending flag — for example `invalid --offset: --offset filters the export log; it needs --records`. The positional account is the exception: it means different things in the two forms rather than conflicting with `--records`. `backup main` exports `main`'s secret; `backup main --records` lists `main`'s past exports, exactly as `--account main` would. @@ -182,7 +182,7 @@ Both forms are local and contact no node, but `backup` has an optional network d ## Exit status -`0` success · `1` execution failure (`not_exportable` — watch-only or Ledger; `auth_failed`; `io_error` — path not writable) · `2` usage error (`account_not_found` — no such account; `output_exists` — the target file already exists and is never overwritten; `invalid_value` — a record filter without `--records`, `--keystore` / `--out` with `--records`, or a bad time / limit / offset). +`0` success · `1` execution failure (`aborted` — Ctrl-C at a prompt; nothing is written; `not_exportable` — watch-only or Ledger; `auth_failed`; `io_error` — path not writable) · `2` usage error (`account_not_found` — no such account; `output_exists` — the target file already exists and is never overwritten; `invalid_option` — `--keystore` / `--out` with `--records`; `invalid_value` — a record filter without `--records`, or a bad time / limit / offset). ## See also diff --git a/ts/docs/commands/bai/recharge-orders.md b/ts/docs/commands/bai/recharge-orders.md index 684e5f247..4864bedc6 100644 --- a/ts/docs/commands/bai/recharge-orders.md +++ b/ts/docs/commands/bai/recharge-orders.md @@ -88,7 +88,7 @@ Paging is in `meta.pagination`: `offset`, `limit` (after any lowering to `100`), ## Exit status -`0` success · `1` execution failure (`bai_auth_failed`; `bai_rejected`; `provider_error`; `timeout`) · `2` usage error (`bai_credentials_missing`; `invalid_value` — `--limit` or `--offset` out of range). +`0` success · `1` execution failure (`bai_auth_failed`; `bai_rejected`; `provider_error`; `provider_rate_limited` — HTTP 429; `timeout`) · `2` usage error (`bai_credentials_missing`; `invalid_value` — `--limit` or `--offset` out of range). ## See also diff --git a/ts/docs/commands/bai/recharge.md b/ts/docs/commands/bai/recharge.md index bea8ddf03..d51b19760 100644 --- a/ts/docs/commands/bai/recharge.md +++ b/ts/docs/commands/bai/recharge.md @@ -43,7 +43,9 @@ Each of these fails with no order and no payment. The order is created only afte **If reporting fails after payment**, the command does not fail. It returns `creditStatus: "unconfirmed"` with the transaction hash and `retryPayment: false` — the payment went through, so **do not recharge again**. Report the same transaction with [`bai report-recharge`](report-recharge.md). The CLI waits about 15 seconds for the transaction to be indexed, then reports it once; it does not retry. An `unconfirmed` result with `code: "TX_NOT_FOUND_OR_INVALID"` (warning `B.AI has not indexed the transaction yet; report it again in a minute with \`bai report-recharge\``) or `provider_rate_limited` usually just means B.AI needs more time. -If the payment itself fails, the error carries the payment details (`paymentStatus`, `retryPayment: false`, the transaction hash when one is known) plus `chain`, `amount`, and for `--to` the resolved `rechargeTarget`. See [x402 and B.AI payment details](../../machine-interface.md#x402-and-bai-payment-details). +If the payment itself fails, the error carries the payment details (`paymentStatus`, `outcome: "unknown"` when the payment may have gone through, `retryPayment: false`, the transaction hash when one is known) plus `chain`, `amount`, for `--to` the resolved `rechargeTarget`, and `orderId`, B.AI's id of the order created before the payment, when B.AI returned one. A failure before the payment request starts — the local payment server cannot start, a payment limit check fails, or the time limit refuses the request — reports `paymentStatus: "not_sent"` without `outcome`. See [x402 and B.AI payment details](../../machine-interface.md#x402-and-bai-payment-details). + +If the reply to the order creation is lost (a `timeout`, a 5xx, a dropped connection, or a reply that does not parse), the error carries `outcome: "unknown"` and no payment details: nothing was paid, but B.AI may hold an unpaid order. Definite refusals (a business rejection, a 4xx, an authentication failure, a rate limit) do not carry it. Requires an account, the master password (via `--password-stdin`), and a stored B.AI API key. @@ -205,7 +207,7 @@ With `--dry-run`: ## Exit status -`0` paid (check `creditStatus`), or previewed with `--dry-run` · `1` execution failure (the payment errors of [`x402 pay`](../x402/pay.md#exit-status); `auth_required` — no `--password-stdin`, before any order is created; `provider_error` — including a `--to` recipient B.AI does not recognize, or a binding B.AI returns for a different address or chain; `bai_auth_failed` — B.AI rejected the stored key; `bai_rejected` — with `error.details.reason`) · `2` usage error (`bai_credentials_missing`; `unsupported_network_capability`; `invalid_value` — a token the network does not accept, or `exact_gasfree` outside TRON; `invalid_amount` — not a positive amount, below the minimum, or more than 6 decimal places; `invalid_option` — both GasFree fee caps, or `--wait` / `--wait-timeout`, which this command does not take). +`0` paid (check `creditStatus`), or previewed with `--dry-run` · `1` execution failure (the payment errors of [`x402 pay`](../x402/pay.md#exit-status); `auth_required` — no `--password-stdin`, before any order is created; `provider_error` — including a `--to` recipient B.AI does not recognize, or a binding B.AI returns for a different address or chain; `bai_auth_failed` — B.AI rejected the stored key; `bai_rejected` — with `error.details.reason`) · `2` usage error (`bai_credentials_missing`; `unsupported_network_capability`; `invalid_value` — a token the network does not accept, or `exact_gasfree` outside TRON; `invalid_amount` — not a positive amount, below the minimum, or more than 6 decimal places; `invalid_option` — both GasFree fee caps, or `--wait` / `--no-wait`, which this command does not take). ## See also diff --git a/ts/docs/commands/bai/report-recharge.md b/ts/docs/commands/bai/report-recharge.md index 20f7cf042..3b5320081 100644 --- a/ts/docs/commands/bai/report-recharge.md +++ b/ts/docs/commands/bai/report-recharge.md @@ -21,7 +21,7 @@ Give the values of the original recharge: The hash must be 64 hex characters on TRON and `0x` plus 64 hex characters on BSC and Base. -The transaction is reported once; the CLI does not retry. When B.AI cannot find the transaction yet, or is rate-limiting the client, the command still succeeds with `creditStatus: "unconfirmed"` and a `code` / `warning` (a rate limit also carries `error.details.retryAfterMs` when B.AI sends `Retry-After`); run it again later. Do not recharge again. Reporting a transaction that is already credited is safe: it returns `credited` again and adds nothing. +The transaction is reported once; the CLI does not retry. When B.AI cannot find the transaction yet, or is rate-limiting the client, the command still succeeds with `creditStatus: "unconfirmed"` and a `code` / `warning` (a rate limit also carries `error.details.retryAfterSeconds` when B.AI sends a usable `Retry-After`); run it again later. Do not recharge again. Reporting a transaction that is already credited is safe: it returns `credited` again and adds nothing. Requires a B.AI API key stored with [`config baiApiKey`](../config.md); see [`bai`](index.md#the-api-key). diff --git a/ts/docs/commands/bai/usage-records.md b/ts/docs/commands/bai/usage-records.md index babaa5683..e22c0f51e 100644 --- a/ts/docs/commands/bai/usage-records.md +++ b/ts/docs/commands/bai/usage-records.md @@ -106,7 +106,7 @@ Each record (a field B.AI does not supply is absent): ## Exit status -`0` success · `1` execution failure (`bai_auth_failed`; `bai_rejected`; `provider_error` — including a `--cursor` B.AI does not accept; `timeout`) · `2` usage error (`bai_credentials_missing`; `invalid_value` — `--offset` not a multiple of `--limit`, or out of range). +`0` success · `1` execution failure (`bai_auth_failed`; `bai_rejected`; `provider_error` — including a `--cursor` B.AI does not accept; `provider_rate_limited` — HTTP 429; `timeout`) · `2` usage error (`bai_credentials_missing`; `invalid_value` — `--offset` not a multiple of `--limit`, or out of range). ## See also diff --git a/ts/docs/commands/bai/usage-summary.md b/ts/docs/commands/bai/usage-summary.md index 64c8c8d9d..bcfdd499c 100644 --- a/ts/docs/commands/bai/usage-summary.md +++ b/ts/docs/commands/bai/usage-summary.md @@ -88,7 +88,7 @@ wallet-cli bai usage-summary -o json ## Exit status -`0` success · `1` execution failure (`bai_auth_failed` — B.AI rejected the key; `bai_rejected`; `provider_error`; `timeout`) · `2` usage error (`bai_credentials_missing`). +`0` success · `1` execution failure (`bai_auth_failed` — B.AI rejected the key; `bai_rejected`; `provider_error`; `provider_rate_limited` — HTTP 429; `timeout`) · `2` usage error (`bai_credentials_missing`). ## See also diff --git a/ts/docs/commands/block.md b/ts/docs/commands/block.md index 1d23ace36..c954c314f 100644 --- a/ts/docs/commands/block.md +++ b/ts/docs/commands/block.md @@ -37,7 +37,7 @@ wallet-cli block 69093315 --network nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"block","data":{"block":{"blockID":"0000000041e6a3c3…","block_header":{"raw_data":{"number":69093315,"txTrieRoot":"…","witness_address":"41…","parentHash":"…","version":31,"timestamp":1783783761000},"witness_signature":"…"},"transactions":[{}]}},"meta":{"durationMs":126,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"block","data":{"block":{"blockID":"0000000041e6a3c3…","block_header":{"raw_data":{"number":69093315,"txTrieRoot":"…","witness_address":"41…","parentHash":"…","version":31,"timestamp":1783783761000},"witness_signature":"…"},"transactions":[{}]},"number":69093315,"timestampMs":1783783761000,"transactionCount":212},"meta":{"durationMs":126,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` On an EVM network the text summary carries the gas and fee figures a block actually has: @@ -58,12 +58,15 @@ Base fee 1.119025 gwei ## Output -`data.block` is the raw block as returned by the node, unmodified — a TRON block object on TRON, an `eth_getBlockByNumber` result on EVM. Its exact shape follows the node's block structure, so **only the text summary is normalized across families**; the key fields are below (large hashes and the full transaction list are elided as `…` above). +`data.block` is the raw block as returned by the node, unmodified — a TRON block object on TRON, an `eth_getBlockByNumber` result on EVM. Its exact shape follows the node's block structure; the key fields are below (large hashes and the full transaction list are elided as `…` above). On EVM, `data` also carries the block's decoded fields beside `data.block`. -TRON: +TRON also reports a normalized summary beside `block`. A block without `block_header.raw_data` fails with `invalid_node_response`; within the header, an omitted `number` or `timestamp` reads as `0` (the genesis block): | Field | Type | Meaning | |---|---|---| +| `number` | number | Block height | +| `timestampMs` | number | Block time (ms since epoch, UTC) | +| `transactionCount` | number | Transactions in the block | | `block.blockID` | string | Block hash | | `block.block_header.raw_data.number` | number | Block height | | `block.block_header.raw_data.timestamp` | number | Block time (ms since epoch, UTC) | @@ -82,9 +85,20 @@ EVM — the node's own hex QUANTITY encoding throughout: | `block.miner` | string | Address credited with the block | | `block.transactions` | array | Transaction hashes, or full objects, as the node returns them | +EVM decoded fields, beside `data.block`. A block without a number or timestamp fails with `invalid_node_response`: + +| Field | Type | Meaning | +|---|---|---| +| `number` | number | Block height | +| `timestamp` | number | Block time, ms since epoch (UTC) | +| `hash` / `parentHash` | string | Block and parent hash; omitted when the node omits them | +| `transactionCount` | number | Transactions in the block; omitted when the node omits the list | +| `gasUsed` / `gasLimit` | string | Gas consumed and authorised, decimal; omitted when absent | +| `baseFeeWei` | string | Base fee per gas, decimal wei; omitted before EIP-1559 | + ## Exit status -`0` success · `1` execution failure · `2` usage error. See [machine-interface](../machine-interface.md). +`0` success · `1` execution failure (`invalid_node_response` — a block without its header or decoded fields) · `2` usage error (`invalid_value` — a block number that is not a non-negative integer, such as `abc`, `1.5` or `-1`; nothing is requested). See [machine-interface](../machine-interface.md). ## See also diff --git a/ts/docs/commands/chain/node.md b/ts/docs/commands/chain/node.md index b8700ff6a..c6eb5b01b 100644 --- a/ts/docs/commands/chain/node.md +++ b/ts/docs/commands/chain/node.md @@ -78,11 +78,11 @@ wallet-cli chain node --network sepolia -o json | `solidBlock` | object \| null | Solidified block on TRON, the finalized block on EVM — `{number}` | | `lagBlocks` | number \| null | Head − solid block gap | | `inSync` | boolean \| null | Whether the node is caught up. TRON: the head block is fresh (within 3 block intervals, i.e. ≤ 9 s). EVM: the node's own `eth_syncing` answer, inverted — `null` when it could not be read, which is not the same as out of sync | -| `peers` | object \| null | `{connected, active}`; `null` when the endpoint hides it. EVM reports one peer count, so both fields carry it | +| `peers` | object \| null | `{connected, active}`; `null` when either peer count is unavailable. EVM reports one peer count, so both fields carry it | ## Exit status -`0` success · `1` execution failure (`rpc_error`, `timeout`) · `2` usage error. +`0` success · `1` execution failure (`rpc_error`, `timeout`; `invalid_node_response` when a TRON node returns no head block number or timestamp) · `2` usage error. ## See also diff --git a/ts/docs/commands/chain/params.md b/ts/docs/commands/chain/params.md index 8432be2f1..0824b0ce8 100644 --- a/ts/docs/commands/chain/params.md +++ b/ts/docs/commands/chain/params.md @@ -12,17 +12,17 @@ wallet-cli chain params [--key ] [options] > **TRON only.** SR-governed system parameters have no EVM counterpart; on an EVM network this fails with `family_mismatch` before any node call. -Lists the chain's governance parameters — network-wide system settings changed by SR proposals (see [`proposal create`](../proposal/create.md)); this command only reads them. `--key` returns a single one. Keys pass through exactly as the chain returns them; text output adds thousands separators and units (SUN / ms) for known numeric keys, `-o json` keeps raw values. +Lists the chain's governance parameters — network-wide system settings changed by SR proposals (see [`proposal create`](../proposal/create.md)); this command only reads them. `--key` returns a single one. Keys pass through exactly as the chain returns them; each parameter carries its `unit` from wallet-cli's parameter catalog (empty when it has none); text output adds thousands separators and that unit, `-o json` keeps raw values. Frequently used keys: | Key | Meaning | |---|---| -| `getEnergyFee` | Energy unit price (SUN/energy) — the burn rate when you lack energy; core input to fee estimation | -| `getTransactionFee` | Bandwidth unit price (SUN/byte) — the burn rate once free bandwidth is spent | -| `getCreateAccountFee` | Burned (SUN) when creating an account and the payer lacks enough staked bandwidth for the transaction — otherwise staked bandwidth is used instead | -| `getWitnessPayPerBlock` | SR reward per block produced (SUN) — feeds the voting-reward pool | -| `getMaintenanceTimeInterval` | Maintenance cycle length (ms; 21,600,000 = 6 h) — the vote-tally / SR-ranking period | +| `getEnergyFee` | Energy unit price (sun/energy) — the burn rate when you lack energy; core input to fee estimation | +| `getTransactionFee` | Bandwidth unit price (sun/byte) — the burn rate once free bandwidth is spent | +| `getCreateAccountFee` | Burned (sun) when creating an account and the payer lacks enough staked bandwidth for the transaction — otherwise staked bandwidth is used instead | +| `getWitnessPayPerBlock` | SR reward per block produced (sun) — feeds the voting-reward pool | +| `getMaintenanceTimeInterval` | Maintenance cycle length (ms; 21,600,000 = 6 h on mainnet) — the vote-tally / SR-ranking period | For the fee-relevant prices in friendlier form, use [`chain prices`](prices.md). @@ -44,7 +44,7 @@ wallet-cli chain params --key getEnergyFee --network nile ```console Key getEnergyFee -Value 210 SUN +Value 100 sun ``` All parameters (excerpt): @@ -56,11 +56,11 @@ wallet-cli chain params --network nile ```console | Key | Value | | -------------------------- | -------------- | -| getEnergyFee | 210 SUN | -| getTransactionFee | 1,000 SUN | -| getCreateAccountFee | 100,000 SUN | -| getWitnessPayPerBlock | 16,000,000 SUN | -| getMaintenanceTimeInterval | 21,600,000 ms | +| getEnergyFee | 100 sun | +| getTransactionFee | 1,000 sun/byte | +| getCreateAccountFee | 100,000 sun | +| getWitnessPayPerBlock | 8,000,000 sun | +| getMaintenanceTimeInterval | 1,800,000 ms | ``` ```bash @@ -68,7 +68,7 @@ wallet-cli chain params --network nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"chain.params","data":{"params":[{"key":"getEnergyFee","value":210},{"key":"getTransactionFee","value":1000},{"key":"getCreateAccountFee","value":100000}]},"meta":{"durationMs":19,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"chain.params","data":{"params":[{"key":"getEnergyFee","value":100,"unit":"sun"},{"key":"getTransactionFee","value":1000,"unit":"sun/byte"},{"key":"getCreateAccountFee","value":100000,"unit":"sun"}]},"meta":{"durationMs":19,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` ## Output @@ -78,11 +78,16 @@ wallet-cli chain params --network nile -o json | Field | Type | Meaning | |---|---|---| | `key` | string | Parameter name, verbatim from the chain | -| `value` | number | Raw chain value, no unit suffix (text adds SUN / ms). Absent altogether for a parameter the node reports without one | +| `value` | number | Raw chain value, no unit suffix. Absent altogether for a parameter the node reports without one | +| `unit` | string | Unit of `value` (`sun`, `sun/byte`, `ms`); empty for a flag, a count, or a key wallet-cli does not know | + +With `--key`, `data` is that one entry — `key`, `value`, `unit` — not a `params` array. + +A parameter value that cannot be represented as a safe integer fails with `provider_error`. ## Exit status -`0` success · `1` execution failure (`rpc_error`) · `2` usage error (`not_found` — `--key` doesn't exist; `invalid_value`; `family_mismatch` on an EVM network). +`0` success · `1` execution failure (`rpc_error`) · `2` usage error (`unknown_parameter` — `--key` doesn't exist; `invalid_value`; `family_mismatch` on an EVM network). ## See also diff --git a/ts/docs/commands/chain/prices.md b/ts/docs/commands/chain/prices.md index 2cfd40eb3..1075baa47 100644 --- a/ts/docs/commands/chain/prices.md +++ b/ts/docs/commands/chain/prices.md @@ -30,8 +30,8 @@ wallet-cli chain prices --network nile ``` ```console -Energy price 100 SUN / unit (current) -Bandwidth price 1,000 SUN / unit (current) +Energy price 100 sun / unit (current) +Bandwidth price 1,000 sun / unit (current) Memo fee 1 TRX ``` @@ -92,7 +92,7 @@ EVM: ## Exit status -`0` success · `1` execution failure (`rpc_error`, `timeout`) · `2` usage error. +`0` success · `1` execution failure (`rpc_error`, `timeout`; `invalid_node_response` when a TRON price timeline is empty or malformed) · `2` usage error. ## See also diff --git a/ts/docs/commands/change-password.md b/ts/docs/commands/change-password.md index e71f59c68..56e9588c3 100644 --- a/ts/docs/commands/change-password.md +++ b/ts/docs/commands/change-password.md @@ -19,7 +19,7 @@ The flow: 1. **Verify** — enter the current master password; it must decrypt an existing keystore (`auth_failed` otherwise, nothing touched). 2. **Set** — enter the new password twice. A mismatch or a policy failure is rejected at the prompt and asked again. 3. **Confirm** — the command lists how many software wallets will be re-encrypted; `[y/N]` (skipped with `--yes`). Declining aborts with no changes. -4. **Re-encrypt atomically** — each keystore: decrypt with old → encrypt with new → write temp file → fsync; only after *all* succeed are files renamed into place. Any failure rolls everything back and reports `io_error` — the old keystores stay valid. +4. **Re-encrypt** — decrypt each keystore with the old password, encrypt with the new one, and stage the files with the password verifier. A journal records the commit point. Before it, a failure reports `io_error` and the old password remains valid. After it, the new password is in effect; an incomplete replacement reports `io_error`, and the next wallet command finishes it. Interrupted staging is cleaned up by the next wallet command. ## Options @@ -51,7 +51,7 @@ This command is interactive. In text mode the receipt lists the re-encrypted sof ## Exit status -`0` changed · `1` execution failure (`auth_failed`; `no_software_wallet` — nothing to re-encrypt; `invalid_value` — a referenced encrypted wallet blob is missing; `io_error` — write failed, rolled back) · `2` usage error (`tty_required` — no TTY for interactive input; `invalid_value` — the new password equals the current one; `aborted` — confirmation declined). +`0` changed · `1` execution failure (`aborted` — Ctrl-C at a prompt; nothing is changed; `auth_failed`; `no_software_wallet` — nothing to re-encrypt; `invalid_value` — a referenced encrypted wallet blob is missing; `io_error` — the message states whether the old or new password is in effect) · `2` usage error (`tty_required` — no TTY for interactive input; `invalid_value` — the new password equals the current one; `aborted` — confirmation declined). ## See also diff --git a/ts/docs/commands/config.md b/ts/docs/commands/config.md index 4f8b19b5d..b6093fd5a 100644 --- a/ts/docs/commands/config.md +++ b/ts/docs/commands/config.md @@ -17,7 +17,7 @@ wallet-cli config [] [] [options] | Option | Description | |---|---| -| `--api-key-stdin` | Read the B.AI API key from stdin (fd 0). Accepted only by `config baiApiKey` | +| `--api-key-stdin` | Read a credential value from stdin (fd 0). Accepted only with a credential key: `tronlinkSecretId`, `tronlinkSecretKey`, `gasfreeApiKey`, `gasfreeApiSecret`, `baiApiKey`, `networks..apiKey` | Plus [global options](index.md). @@ -29,26 +29,27 @@ Known keys: |---|---|---|---| | `defaultNetwork` | network id | `tron:728126428` | Network used when `--network` is omitted | | `defaultOutput` | `text` \| `json` | `text` | Output format when `-o` is omitted | -| `timeoutMs` | ms, any finite number > 0 | `60000` | Default per node, service, or device call timeout (`--timeout` overrides). Unlike `waitTimeoutMs` it need not be an integer | -| `waitTimeoutMs` | integer ms ≥ 0 | `60000` | Default `--wait` polling cap for broadcast commands | -| `gasfreeApiKey` | string | (unset) | GasFree API key ([`gasfree`](gasfree/index.md)). **Secret** | -| `gasfreeApiSecret` | string | (unset) | GasFree API secret. **Secret** | -| `tronlinkSecretId` | string | (unset) | TronLink multi-sig service secretId ([`tx multisig`](tx/multisig.md)). **Secret** | -| `tronlinkSecretKey` | string | (unset) | TronLink multi-sig service secretKey. **Secret** | +| `timeoutMs` | integer ms, 0–2147483647 | `90000` | Default deadline for the whole command; `0` = no limit (`--timeout` overrides). A value in `config.yaml` outside this range is ignored and the built-in default applies | +| `gasfreeApiKey` | string | (unset) | GasFree API key ([`gasfree`](gasfree/index.md)). **Secret** — set only through `--api-key-stdin` | +| `gasfreeApiSecret` | string | (unset) | GasFree API secret. **Secret** — set only through `--api-key-stdin` | +| `tronlinkSecretId` | string | (unset) | TronLink multi-sig service secretId ([`tx multisig`](tx/multisig.md)). **Secret** — set only through `--api-key-stdin` | +| `tronlinkSecretKey` | string | (unset) | TronLink multi-sig service secretKey. **Secret** — set only through `--api-key-stdin` | | `tronlinkChannel` | string | (unset) | TronLink multi-sig service channel | | `baiApiKey` | string | (unset) | B.AI personal API key, used by the `bai` commands. **Secret** — set only through `--api-key-stdin` | | `aliases` | — | — | Short name → canonical id map (read-only) | | `networks` | — | — | Known networks and their configurable fields (read-only as a whole) | | `networks.` | — | — | One network's configurable fields; `` may be a canonical id or an alias | -| `networks..httpEndpoint` | URL | per network | The node/RPC endpoint to use | -| `networks..apiKeyHeader` | header name | (unset) | Header a commercial endpoint authenticates by, e.g. `TRON-PRO-API-KEY` | -| `networks..apiKey` | string | (unset) | Credential sent in that header. **Secret** — always rendered masked | +| `networks..httpEndpoint` | absolute HTTP(S) URL | per network | The node/RPC endpoint to use; invalid values fail with `invalid_value` | +| `networks..apiKeyHeader` | header name | (unset) | HTTP header name, 1–64 letters, digits or ``!#$%&'*+-.^_`\|~``; surrounding whitespace is trimmed, e.g. `TRON-PRO-API-KEY` | +| `networks..apiKey` | string | (unset) | Credential sent in that header. **Secret** — always rendered masked, set only through `--api-key-stdin` | `networks` and `aliases` are read-only as a whole; the writable network settings are the three `networks..…` leaves. Any other sub-key is `invalid_value`, and the error names the ones that are supported. -Precedence for a value that has both a flag and a config key (highest first): command-line flag > config value > built-in default — e.g. `--timeout` > config `timeoutMs` > built-in 60000. +Precedence for a value that has both a flag and a config key (highest first): command-line flag > config value > built-in default — e.g. `--timeout` > config `timeoutMs` > built-in 90000. -**Secrets are never rendered in clear text.** `tronlinkSecretId`, `tronlinkSecretKey`, `gasfreeApiKey`, `gasfreeApiSecret`, `baiApiKey` and `networks..apiKey` come back as `********` from every read, and setting one echoes `value` and `input` as `"********"` too. +**Secrets are never rendered in clear text.** `tronlinkSecretId`, `tronlinkSecretKey`, `gasfreeApiKey`, `gasfreeApiSecret`, `baiApiKey` and `networks..apiKey` come back as `********` from every read, and setting one echoes `value` and `input` as `"********"` too. An error whose text from a node or service repeats one of them shows `********` in its place. + +**Secrets are read only from stdin.** None of the six is accepted as a command-line value — `config gasfreeApiKey ` fails with `invalid_option` — so a credential never lands in shell history or a process listing. Pipe it in with `--api-key-stdin`, one key per run. A `config.yaml` that holds any of them must be a regular file with mode `0600`, or every command fails with `insecure_config`. **Endpoints are trimmed in listings, full in named reads.** `config` and `config networks` show `httpEndpoint` as a host only, because a commercial endpoint may carry its key in the URL path. Naming one network (`config networks.tron:3448148188`) or its leaf (`config networks.tron:3448148188.httpEndpoint`) is the deliberate act that reveals the whole URL. @@ -56,9 +57,9 @@ An unset field is **absent** from the view rather than present and empty — the The external-service credentials are **per-environment**: the GasFree (`gasfreeApiKey` / `gasfreeApiSecret`) and TronLink (`tronlinkSecretId` / `tronlinkSecretKey` / `tronlinkChannel`) credentials must match the service environment of the current `--network` (mainnet vs testnet); a mismatch fails with `provider_error`, so swap them when you switch environments. When a key is unset, the commands that need it fail with a clear error — `gasfree_credentials_missing` for [`gasfree`](gasfree/index.md), `tronlink_credentials_missing` for [`tx multisig`](tx/multisig.md). -**`baiApiKey` is set differently from every other key.** It is never accepted as a command-line value — `config baiApiKey ` fails with `invalid_option` — and must be piped in with `--api-key-stdin`. Setting it only saves the key locally: it does not contact B.AI, needs no network, account or master password, and neither checks the key nor binds a wallet. [`bai recharge`](bai/recharge.md) checks the paying address's binding each time it runs and binds it first if needed. +**Setting `baiApiKey` only saves the key locally:** it does not contact B.AI, needs no network, account or master password, and neither checks the key nor binds a wallet. [`bai recharge`](bai/recharge.md) checks the paying address's binding each time it runs and binds it first if needed. -An invalid value returns `invalid_value` (exit 2). +An invalid value returns `invalid_value` (exit 2). Credential values must contain 1–256 characters without control characters. Network endpoints, header names and credentials are validated on load as well as on set. ## Examples @@ -71,8 +72,7 @@ wallet-cli config ```console defaultNetwork tron:728126428 defaultOutput text -timeoutMs 60000 -waitTimeoutMs 60000 +timeoutMs 90000 networks tron:728126428 httpEndpoint api.trongrid.io @@ -121,7 +121,7 @@ wallet-cli config timeoutMs ``` ```console -timeoutMs 60000 +timeoutMs 90000 ``` ```bash @@ -153,10 +153,16 @@ wallet-cli config networks.tron:728126428.apiKeyHeader TRON-PRO-API-KEY ``` ```bash -wallet-cli config networks.tron:728126428.apiKey +printf '%s\n' "$TRONGRID_KEY" | wallet-cli config networks.tron:728126428.apiKey --api-key-stdin +``` + +Store the GasFree credentials and a B.AI API key, each read from stdin: + +```bash +printf '%s\n' "$GASFREE_KEY" | wallet-cli config gasfreeApiKey --api-key-stdin +printf '%s\n' "$GASFREE_SECRET" | wallet-cli config gasfreeApiSecret --api-key-stdin ``` -Store a B.AI API key, read from stdin: ```bash printf '%s\n' "$BAI_KEY" | wallet-cli config baiApiKey --api-key-stdin @@ -187,13 +193,13 @@ wallet-cli config networks.tron:3448148188 -o json | Mode | `data` fields | |---|---| -| show all (no args) | one field per key: `defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`, `networks` (id → `{httpEndpoint, apiKeyHeader?, apiKey?}`, endpoints trimmed to hosts), `aliases` (alias → id), `tronlinkSecretId`, `tronlinkSecretKey`, `tronlinkChannel`, `gasfreeApiKey`, `gasfreeApiSecret`, `baiApiKey` — each only when set, secrets masked | +| show all (no args) | one field per key: `defaultNetwork`, `defaultOutput`, `timeoutMs`, `networks` (id → `{httpEndpoint, apiKeyHeader?, apiKey?}`, endpoints trimmed to hosts), `aliases` (alias → id), `tronlinkSecretId`, `tronlinkSecretKey`, `tronlinkChannel`, `gasfreeApiKey`, `gasfreeApiSecret`, `baiApiKey` — each only when set, secrets masked | | read (``) | `key`, `value`; only `key` when the key is unset | | set (` `) | `key`, `value`, `input` (the raw string as typed); both `"********"` when the key is a secret | ## Exit status -`0` success · `1` execution failure (`io_error` — an atomic config write failed) · `2` usage error (`invalid_config` — `config.yaml` is unreadable or not valid YAML; `insecure_config` — it holds service credentials but is a symlink or group/world-readable, so `chmod 600` it; `invalid_value` — unknown key, a read-only key given a value, or an unsupported `networks..`; `invalid_option` — `baiApiKey` given on the command line). See [machine-interface](../machine-interface.md). +`0` success · `1` execution failure (`io_error` — an atomic config write failed) · `2` usage error (`invalid_config` — `config.yaml` is unreadable or not valid YAML; `insecure_config` — it holds service credentials but is a symlink or group/world-readable, so `chmod 600` it; `invalid_value` — unknown key, a read-only key given a value, or an unsupported `networks..`; `invalid_option` — a credential key given a command-line value, or `--api-key-stdin` with a key that is not a credential). See [machine-interface](../machine-interface.md). ## See also diff --git a/ts/docs/commands/contact/add.md b/ts/docs/commands/contact/add.md index 2a612d519..4b6c0bef6 100644 --- a/ts/docs/commands/contact/add.md +++ b/ts/docs/commands/contact/add.md @@ -16,6 +16,8 @@ The name must be 1–64 characters and must not look like an address (so it can' A contact belongs to one chain family, inferred from the address itself — this command has no family or network selector. A malformed address, or one belonging to no supported family, is refused with `invalid_address`; family compatibility is checked later, when a chain command uses the contact. +Contact names are unique across both families, including concurrent additions. TRON base58 comparison preserves case. EVM address comparison ignores case. + ## Options | Option | Description | diff --git a/ts/docs/commands/contract/call.md b/ts/docs/commands/contract/call.md index 230b6040a..d8848c006 100644 --- a/ts/docs/commands/contract/call.md +++ b/ts/docs/commands/contract/call.md @@ -68,7 +68,7 @@ Result 0x0000000000000000000000000000000000000000000000000000000000000000 (raw) ## Exit status -`0` success · `1` execution failure (`rpc_error`, revert) · `2` usage error (`invalid_value` — bad signature or params JSON). +`0` success · `1` execution failure (`rpc_error`; `execution_reverted` — the call reverted, with `error.details.revertData` when the node returned any; `invalid_node_response` — a malformed reply) · `2` usage error (`invalid_value` — bad signature or params JSON). ## See also diff --git a/ts/docs/commands/contract/clear-abi.md b/ts/docs/commands/contract/clear-abi.md index 26d354abf..4cc9d1dd9 100644 --- a/ts/docs/commands/contract/clear-abi.md +++ b/ts/docs/commands/contract/clear-abi.md @@ -6,7 +6,7 @@ Clear the ABI a contract stores on chain. ``` wallet-cli contract clear-abi
- [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -20,7 +20,7 @@ What it does **not** touch: the bytecode and the contract's state are unaffected Only the contract's deployer can do this — the address the chain records as the contract's origin, visible in [`contract info`](info.md). Other accounts fail with `not_contract_deployer`. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. The Ledger TRON app cannot parse this governance contract type. A Ledger account can dry-run or build; signing modes fail with `ledger_unsupported` before the device is touched. @@ -34,7 +34,8 @@ The Ledger TRON app cannot parse this governance contract type. A Ledger account | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -44,7 +45,7 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV...4wRe --network nile --wait --password-stdin +echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV...4wRe --network nile --password-stdin ``` ```console @@ -58,7 +59,7 @@ echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV...4wRe --network nile --wait ``` ```bash -echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV...4wRe --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV...4wRe --network nile --password-stdin -o json ``` ```json @@ -71,12 +72,12 @@ echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV...4wRe --network nile --wait | Stage | Fields | |---|---| -| default (submit) | `kind: "contract-clear-abi"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | +| `--no-wait` (submitted) | `kind: "contract-clear-abi"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address; `family_mismatch` on an EVM network). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address; `family_mismatch` on an EVM network). ## See also diff --git a/ts/docs/commands/contract/create2.md b/ts/docs/commands/contract/create2.md index 91f2fc7e2..4f3bb6661 100644 --- a/ts/docs/commands/contract/create2.md +++ b/ts/docs/commands/contract/create2.md @@ -77,7 +77,7 @@ The command never contacts a node, but it is still a TRON chain command: the env ## Exit status -`0` success · `1` execution failure · `2` usage error (`missing_option` — no `--deployer` or `--salt`; `file_not_found` — `--code-file` does not exist; `invalid_value` — neither or both code sources, an unreadable code file, a malformed deployer address, non-hex code, or a salt outside the signed 64-bit range; `family_mismatch` on an EVM network). +`0` success · `1` execution failure · `2` usage error (`missing_option` — no `--deployer` or `--salt`; `file_not_found` — `--code-file` does not exist; `invalid_option` — neither or both code sources; `invalid_value` — an unreadable code file, a malformed deployer address, non-hex code, or a salt outside the signed 64-bit range; `family_mismatch` on an EVM network). ## See also diff --git a/ts/docs/commands/contract/deploy.md b/ts/docs/commands/contract/deploy.md index f3233ff4a..99efd552a 100644 --- a/ts/docs/commands/contract/deploy.md +++ b/ts/docs/commands/contract/deploy.md @@ -8,7 +8,7 @@ Deploy contract bytecode. wallet-cli contract deploy (--artifact | --code | --code-file ) [--constructor-args | --constructor-params ] [--constructor-signature ] - [--dry-run | --sign-only | --build-only | --wait [--wait-timeout ]] + [--dry-run | --sign-only | --build-only | --wait | --no-wait] [--abi ] [--fee-limit ] [--permission-id ] [--expiration ] [--gas-limit ] [--max-fee ] [--priority-fee ] [--nonce ] [options] @@ -18,7 +18,7 @@ wallet-cli contract deploy (--artifact | --code | --code-file ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | TRON only: | Option | Description | |---|---| -| `--abi ` | Contract ABI as a JSON array string; required unless `--artifact` supplies one | +| `--abi ` | Contract ABI as a JSON array string; on TRON, exactly one of `--abi` or `--artifact` | | `--fee-limit ` | Max energy fee to burn, in SUN (default 100000000) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | @@ -80,14 +81,14 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. -From a compiler artifact — the same command on either family, since the artifact carries the ABI: +From a compiler artifact — the same command on either family, since the artifact carries the ABI; with `--no-wait` it returns at submission: ```bash -echo "$PW" | wallet-cli contract deploy --artifact ./build/contracts/Token.json --constructor-args '["18","MyToken"]' --network nile --password-stdin +echo "$PW" | wallet-cli contract deploy --artifact ./build/contracts/Token.json --constructor-args '["18","MyToken"]' --network nile --no-wait --password-stdin ``` ```bash -echo "$PW" | wallet-cli contract deploy --artifact ./out/Token.sol/Token.json --constructor-args '["18","MyToken"]' --network sepolia --password-stdin +echo "$PW" | wallet-cli contract deploy --artifact ./out/Token.sol/Token.json --constructor-args '["18","MyToken"]' --network sepolia --no-wait --password-stdin ``` ```console @@ -118,7 +119,7 @@ wallet-cli contract deploy --code 0x60006000f3 --network sepolia --dry-run ``` ```bash -echo "$PW" | wallet-cli contract deploy --artifact ./build/contracts/Token.json --network nile --password-stdin -o json +echo "$PW" | wallet-cli contract deploy --artifact ./build/contracts/Token.json --network nile --no-wait --password-stdin -o json ``` ```json @@ -131,8 +132,8 @@ echo "$PW" | wallet-cli contract deploy --artifact ./build/contracts/Token.json | Stage | Fields | |---|---| -| default (submit) | `kind: "contract-deploy"`, `contractAddress`, `stage: "submitted"`, `txId` | -| `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | +| `--no-wait` (submitted) | `kind: "contract-deploy"`, `contractAddress`, `stage: "submitted"`, `txId` | +| default or `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | | `--dry-run` | `kind`, `mode: "dry-run"`, `contractAddress`, `fee`, the unsigned `tx` (plus `nonce` on EVM) | | `--sign-only` / `--build-only` | `kind`, `mode`, `hex`, `fee`, the transaction object | @@ -140,7 +141,7 @@ echo "$PW" | wallet-cli contract deploy --artifact ./build/contracts/Token.json ## Exit status -`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `ledger_unsupported` — TRON signing only, `auth_failed`, `nonce_too_low` — `--dry-run` with an already-mined `--nonce`, raised before the predicted contract address is computed, `rpc_error`, `timeout`) · `2` usage error — `file_not_found` (no artifact or bytecode file at that path), or `invalid_value` for: none or more than one of `--artifact` / `--code` / `--code-file`; `--constructor-args` with no type source; `--constructor-params` together with `--artifact`; an artifact with no creation bytecode; a TRON deploy with neither `--artifact` nor `--abi`, or with both; or an ABI constructor without a string `stateMutability`. +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed/dry-run in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `ledger_unsupported` — TRON signing only, `auth_failed`, `nonce_too_low` — `--dry-run` with an already-mined `--nonce`, raised before the predicted contract address is computed, `rpc_error`, `timeout`) · `2` usage error — `file_not_found` (no artifact or bytecode file at that path), `invalid_option` for none or more than one of `--artifact` / `--code` / `--code-file`, or a TRON deploy with neither `--artifact` nor `--abi`, or with both, or `--constructor-args` with `--constructor-params`; or `invalid_value` for: `--constructor-args` with no type source; `--constructor-params` together with `--artifact`; an artifact with no creation bytecode; or an ABI constructor without a string `stateMutability`. ## See also diff --git a/ts/docs/commands/contract/send.md b/ts/docs/commands/contract/send.md index b1e1d257d..8a4154319 100644 --- a/ts/docs/commands/contract/send.md +++ b/ts/docs/commands/contract/send.md @@ -6,7 +6,7 @@ State-changing contract call. ``` wallet-cli contract send --contract
--method [--params ] [--value ] - [--dry-run | --sign-only | --build-only | --wait [--wait-timeout ]] + [--dry-run | --sign-only | --build-only | --wait | --no-wait] [--fee-limit ] [--permission-id ] [--expiration ] [--gas-limit ] [--max-fee ] [--priority-fee ] [--nonce ] [options] @@ -22,7 +22,7 @@ Three early exits: `--dry-run` previews the cost without signing or broadcasting Fee flags follow the family — `--fee-limit` / `--permission-id` / `--expiration` on TRON, `--gas-limit` / `--max-fee` / `--priority-fee` / `--nonce` on EVM. Help tags each set, and using one on the other family is refused with `invalid_option`. -**By default the command returns at submission** (`stage: "submitted"`) — add `--wait` to block until confirmed/failed. With `--wait`, an on-chain execution failure comes back as `stage: "failed"` — with the `result` reason on TRON (revert / `OUT_OF_ENERGY`), and with the receipt's failed status on EVM. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). An on-chain execution failure comes back as `stage: "failed"` — with the `result` reason on TRON (revert / `OUT_OF_ENERGY`), and with the receipt's failed status on EVM. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. @@ -37,7 +37,8 @@ Requires an account. The master password (via `--password-stdin`) is needed only | `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only` | | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | TRON only: @@ -47,7 +48,7 @@ TRON only: | `--call-value-sun ` | **Deprecated**, removed next release — native TRX attached to the call, in SUN. Use `--value` | | `--fee-limit ` | Max energy fee to burn, in SUN (default 100000000) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s; [Ledger exception](../../guide/ledger.md#3-when-the-device-doesnt-respond)) | EVM only: @@ -64,10 +65,10 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. -Default — broadcasts and returns the **submitted** receipt: +With `--no-wait` — broadcasts and returns the **submitted** receipt: ```bash -echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --method "transfer(address,uint256)" --params '[{"type":"address","value":"TSx72ViULFepRGCS4PM5dP4FqD1d8qggCc"},{"type":"uint256","value":"1000000"}]' --network nile --password-stdin +echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --method "transfer(address,uint256)" --params '[{"type":"address","value":"TSx72ViULFepRGCS4PM5dP4FqD1d8qggCc"},{"type":"uint256","value":"1000000"}]' --network nile --no-wait --password-stdin ``` ```console @@ -79,17 +80,17 @@ echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkA ``` ```bash -echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --method "transfer(address,uint256)" --params '[...]' --network nile --password-stdin -o json +echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --method "transfer(address,uint256)" --params '[...]' --network nile --no-wait --password-stdin -o json ``` ```json {"schema":"wallet-cli.result.v1","success":true,"command":"contract.send","data":{"kind":"contract-send","stage":"submitted","txId":"c8d...","method":"transfer(address,uint256)","contract":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` -With `--wait`, blocks until confirmed — on success: +Without `--no-wait`, the command blocks until confirmed — on success: ```bash -echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --method "transfer(address,uint256)" --params '[...]' --network nile --wait --password-stdin +echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --method "transfer(address,uint256)" --params '[...]' --network nile --password-stdin ``` ```console @@ -105,7 +106,7 @@ echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkA An on-chain failure (e.g. out of energy) returns `stage: "failed"`: ```bash -echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --method "transfer(address,uint256)" --params '[...]' --network nile --wait --password-stdin +echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --method "transfer(address,uint256)" --params '[...]' --network nile --password-stdin ``` ```console @@ -125,8 +126,8 @@ echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkA | Mode | Fields | |---|---| -| default (submit) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract` | -| `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `result` (`SUCCESS`, `OUT_OF_ENERGY`, …) on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | +| `--no-wait` (submitted) | `kind: "contract-send"`, `stage: "submitted"`, `txId`, `method`, `contract` | +| default or `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `feeSun` / `energyUsed` / `result` (`SUCCESS`, `OUT_OF_ENERGY`, …) on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee`, unsigned `tx` | | `--sign-only` | `kind`, `mode: "sign-only"`, `hex` (signed transaction hex), `signed`, `address` (signer), `txId`, `fee`, `method`, `contract` | | `--build-only` | `kind`, `mode: "build-only"`, `hex` (**unsigned** transaction hex), unsigned `tx`, `fee`, `method`, `contract` | @@ -137,7 +138,7 @@ The `fee` object is shaped by the network's fee model: `tron-resource` reports t ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `nonce_too_low` — `--dry-run` with an already-mined `--nonce`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value`, conflicting modes; `invalid_option` when a fee or multi-sig flag is scoped to the other family). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`execution_reverted` — the call reverts when estimated, in every mode, `watch_only_no_signer`, `auth_failed`, `nonce_too_low` — `--dry-run` with an already-mined `--nonce`, `rpc_error`, `timeout` — on timeout the tx may still be in flight; check [`tx status`](../tx/status.md)) · `2` usage error (`invalid_value` — malformed values; `invalid_option` — conflicting modes, or a fee or multi-sig flag scoped to the other family). ## See also diff --git a/ts/docs/commands/contract/set-origin-energy-limit.md b/ts/docs/commands/contract/set-origin-energy-limit.md index 1d9550746..53467c83e 100644 --- a/ts/docs/commands/contract/set-origin-energy-limit.md +++ b/ts/docs/commands/contract/set-origin-energy-limit.md @@ -6,7 +6,7 @@ Set the energy the deployer will cover per call. ``` wallet-cli contract set-origin-energy-limit
- [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -22,7 +22,7 @@ It is not a cap on the contract, and not a cap on the caller. What the deployer Only the contract's deployer can do this; the current value is in [`contract info`](info.md). Settings take effect as soon as the transaction confirms. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. The Ledger TRON app cannot parse this governance contract type. A Ledger account can dry-run or build; signing modes fail with `ledger_unsupported` before the device is touched. @@ -37,7 +37,8 @@ The Ledger TRON app cannot parse this governance contract type. A Ledger account | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -47,7 +48,7 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV...4wRe 50000000 --network nile --wait --password-stdin +echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV...4wRe 50000000 --network nile --password-stdin ``` ```console @@ -62,7 +63,7 @@ echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV...4wRe 5000000 ``` ```bash -echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV...4wRe 50000000 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV...4wRe 50000000 --network nile --password-stdin -o json ``` ```json @@ -75,14 +76,14 @@ echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV...4wRe 5000000 | Stage | Fields | |---|---| -| default (submit) | `kind: "contract-set-origin-energy-limit"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress`, `originEnergyLimit` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | +| `--no-wait` (submitted) | `kind: "contract-set-origin-energy-limit"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress`, `originEnergyLimit` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | `originEnergyLimit` is the value now in effect. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address, or energy not an integer > 0; `family_mismatch` on an EVM network). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address, or energy not an integer > 0; `family_mismatch` on an EVM network). ## See also diff --git a/ts/docs/commands/contract/set-user-resource-percent.md b/ts/docs/commands/contract/set-user-resource-percent.md index f19966c00..f17afe11e 100644 --- a/ts/docs/commands/contract/set-user-resource-percent.md +++ b/ts/docs/commands/contract/set-user-resource-percent.md @@ -6,7 +6,7 @@ Set the share of a call's energy paid by the caller. ``` wallet-cli contract set-user-resource-percent
- [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -22,7 +22,7 @@ The number is the **caller's** share, matching the chain field's own direction; Only the contract's deployer can do this; the current value is in [`contract info`](info.md). Settings take effect as soon as the transaction confirms. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. The Ledger TRON app cannot parse this governance contract type. A Ledger account can dry-run or build; signing modes fail with `ledger_unsupported` before the device is touched. @@ -37,7 +37,8 @@ The Ledger TRON app cannot parse this governance contract type. A Ledger account | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -49,7 +50,7 @@ In the examples, `$PW` is your master password (from an environment variable, pa Callers pay the full energy cost: ```bash -echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV...4wRe 100 --network nile --wait --password-stdin +echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV...4wRe 100 --network nile --password-stdin ``` ```console @@ -64,7 +65,7 @@ echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV...4wRe 100 - ``` ```bash -echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV...4wRe 100 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV...4wRe 100 --network nile --password-stdin -o json ``` ```json @@ -77,14 +78,14 @@ echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV...4wRe 100 - | Stage | Fields | |---|---| -| default (submit) | `kind: "contract-set-user-resource-percent"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress`, `consumeUserResourcePercent` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | +| `--no-wait` (submitted) | `kind: "contract-set-user-resource-percent"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress`, `consumeUserResourcePercent` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | `consumeUserResourcePercent` is the value now in effect — the caller's share. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address, or percent outside 0–100; `family_mismatch` on an EVM network). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address, or percent outside 0–100; `family_mismatch` on an EVM network). ## See also diff --git a/ts/docs/commands/create.md b/ts/docs/commands/create.md index 99867eb05..86c4f5459 100644 --- a/ts/docs/commands/create.md +++ b/ts/docs/commands/create.md @@ -75,7 +75,7 @@ printf '%s' "$PW" | wallet-cli create --label main --password-stdin -o json ## Exit status -`0` created · `1` execution failure · `2` usage error (e.g. label already taken / invalid, `weak_password`). See [machine-interface](../machine-interface.md#exit-codes). +`0` created · `1` execution failure (`aborted` — Ctrl-C at a prompt; nothing is created) · `2` usage error (e.g. label already taken / invalid, `weak_password`). See [machine-interface](../machine-interface.md#exit-codes). ## See also diff --git a/ts/docs/commands/delete.md b/ts/docs/commands/delete.md index 5eb2d6928..e929ceda1 100644 --- a/ts/docs/commands/delete.md +++ b/ts/docs/commands/delete.md @@ -24,7 +24,7 @@ Plus [global options](index.md). Deleting an HD wallet cascades from the seed root — all derived accounts go with it. On-chain assets are untouched. Run [`backup`](backup.md) first and act on any warning it prints. Metadata-only — no master password needed. -**Deleting from an HD wallet that has TRON sub-accounts** (index 1 or higher) always prints a warning, before the confirmation. `delete` cannot tell, without the password, which derivation path created those addresses; if they came from an older version, re-importing the mnemonic will not recreate them. Back up and verify their keys first — see [Recover addresses after `legacy_derivation`](../troubleshooting/legacy-derivation-recovery.md). The warning does not stop the deletion. +**Deleting from an HD wallet that has TRON sub-accounts** (index 1 or higher) always prints a warning, before the confirmation. `delete` cannot tell, without the password, which derivation path created those addresses; if they use `m/44'/195'/'/0/0` with index greater than zero, re-importing the mnemonic will not recreate them. Back up and verify their keys first — see [Recover addresses after `legacy_derivation`](../troubleshooting/legacy-derivation-recovery.md). The warning does not stop the deletion. ## Examples @@ -80,7 +80,7 @@ Without `--yes`, the command asks you to type the account's label to confirm, an |---|---|---| | `accountId` | string | Id of the deleted account/wallet (`wlt_….N` for a sub-account, the wallet id `wlt_…` for a wallet) | | `scope` | string | `account` (only that account) or `wallet` (cascaded whole wallet) | -| `secretRemoved` | boolean | Whether encrypted secret material was removed. Deleting an HD sub-account keeps the seed, so `false`. Deleting a wallet reports whether that wallet held a secret at all: `true` for seed and private-key wallets, `false` for Ledger and watch-only ones, which never stored one | +| `secretRemoved` | boolean | Whether encrypted secret material was removed. Deleting an HD sub-account keeps the seed, so `false`. Deleting a wallet reports whether its secret was removed: `true` for seed and private-key wallets, `false` for Ledger and watch-only ones, which never stored one. It is also `false` when the encrypted secret file could not be deleted; the wallet is still deleted, and a `secret_file_not_removed` warning in `meta.warnings` names the file to delete yourself | | `newActive` | string \| null | New active account id after deletion; `null` if none remain | ## Exit status diff --git a/ts/docs/commands/derive.md b/ts/docs/commands/derive.md index 9de7a61ae..a660f435e 100644 --- a/ts/docs/commands/derive.md +++ b/ts/docs/commands/derive.md @@ -14,8 +14,8 @@ wallet-cli derive [--seed-id ] [--account ] [--index ] [--l |---|---| | `--seed-id ` | seed id of the HD wallet to derive from — the HD group header in `list`. Wins over `--account` when both are given | | `--account ` | accountId, label, or address of any account in the HD wallet; defaults to the active account | -| `--index ` | explicit HD account index; omit to use the next free index. An index that already exists is not re-derived — that account is made active, `status` comes back `"existing"`, and text output is headed `Selected existing account` instead of `Derived sub-account` | -| `--label ` | label for the new account, 1-64 chars; omit to auto-generate | +| `--index ` | explicit HD account index; omit to use the next free index. An index that already exists is not re-derived — that account is made active, `status` comes back `"existing"`, and text output is headed `Selected existing account` instead of `Derived sub-account`. With an existing index, `--label` must be omitted or match the account's label (ignoring case); a different label is refused with `invalid_value` and nothing is written — change a label with [`rename`](rename.md) | +| `--label ` | label for the new account, 1-64 chars; omit to auto-generate. A duplicate label is refused before an account is written | | `--password-stdin` | read the master password from stdin (fd 0) | Plus [global options](index.md). @@ -28,7 +28,7 @@ Each new account gets `m/44'/195'/0'/0/` for TRON and `m/44'/60'/0'/0/'/0/0` with index greater than zero. Deriving a new account from it fails with `legacy_derivation`. Naming one of those accounts with `--index` still works — it is made active with a warning. See [Recover addresses after `legacy_derivation`](../troubleshooting/legacy-derivation-recovery.md). If a stored address does not match the seed, `derive` fails with `derivation_mismatch`. @@ -70,7 +70,7 @@ To derive from another wallet, name any of its accounts with `--account`, or its |---|---|---| | `status` | string | `"created"` for a newly derived index, `"existing"` when `--index` names an index this wallet already holds — that account is simply made active again, and no new key is derived | | `accountId` | string | Stable id `.` | -| `label` | string | Account label (default `-`, e.g. `main-1`) | +| `label` | string | Account label (default `-`, e.g. `main-1`; the base name is truncated so the complete label fits 64 characters) | | `type` | string | Always `"seed"` | | `index` | number | HD derivation index | | `active` | boolean | Always `true` (the account is made active) | @@ -80,7 +80,7 @@ To derive from another wallet, name any of its accounts with `--account`, or its ## Exit status -`0` success · `1` execution failure, including `legacy_derivation` and `derivation_mismatch` · `2` usage error, including `seed_not_found`. See [machine-interface](../machine-interface.md). +`0` success · `1` execution failure, including `legacy_derivation` and `derivation_mismatch` · `2` usage error, including `seed_not_found` and `invalid_value` — a `--label` already used by another account, or a different `--label` for an existing `--index`. See [machine-interface](../machine-interface.md). ## See also diff --git a/ts/docs/commands/exchange/create.md b/ts/docs/commands/exchange/create.md index cdf670fcf..a1f3c44d2 100644 --- a/ts/docs/commands/exchange/create.md +++ b/ts/docs/commands/exchange/create.md @@ -7,7 +7,7 @@ Create a Bancor exchange pair and seed both sides. ``` wallet-cli exchange create --pair : (--amounts : | --raw-amounts :) - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -25,7 +25,7 @@ The creation fee is **burned** — the chain parameter `getExchangeCreateFee`, c `--amounts` is in whole tokens and is converted using each side's precision; `--raw-amounts` gives the same two numbers in minimal units. Exactly one of them is required. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -37,9 +37,10 @@ The creation fee is **burned** — the chain parameter `getExchangeCreateFee`, c | `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s; [Ledger exception](../../guide/ledger.md#3-when-the-device-doesnt-respond)) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -49,7 +50,7 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 --network nile --wait --password-stdin +echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 --network nile --password-stdin ``` ```console @@ -64,7 +65,7 @@ echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:50000 ``` ```bash -echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 --network nile --password-stdin -o json ``` ```json @@ -77,14 +78,14 @@ echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:50000 | Stage | Fields | |---|---| -| default (submit) | `kind: "exchange-create"`, `stage: "submitted"`, `txId`, `pair`, `creatorAddress`, and both sides' `…TokenId` / `…TokenQuant` / `…TokenLabel` / `…TokenDecimals` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `failed`, and `exchangeId` — assigned by the chain, so known only once confirmed | +| `--no-wait` (submitted) | `kind: "exchange-create"`, `stage: "submitted"`, `txId`, `pair`, `creatorAddress`, and both sides' `…TokenId` / `…TokenQuant` / `…TokenLabel` / `…TokenDecimals` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `failed`, and `exchangeId` — assigned by the chain, so known only once confirmed | `firstTokenId` / `secondTokenId` are on-chain ids, so TRX appears as `"_"`. The quantities are **strings** in each token's minimal unit; `…TokenLabel` and `…TokenDecimals` are what text uses to print them as whole tokens. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`same_token` — both sides identical, `asset_not_found` — no TRC10 with that id, `transaction_rejected` — the node refused it, for example for lack of balance or a reserve above `getExchangeBalanceLimit`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--pair`; `invalid_option` — both or neither of `--amounts` / `--raw-amounts`; `invalid_amount` — a side is not a decimal number, or has more decimal places than that token allows; `invalid_value` — a malformed `:`, or a side ≤ 0). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`same_token` — both sides identical, `asset_not_found` — no TRC10 with that id, `transaction_rejected` — the node refused it, for example for lack of balance or a reserve above `getExchangeBalanceLimit`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--pair`; `invalid_option` — both or neither of `--amounts` / `--raw-amounts`; `invalid_amount` — a side is not a decimal number, or has more decimal places than that token allows; `invalid_value` — a malformed `:`, or a side ≤ 0). ## See also diff --git a/ts/docs/commands/exchange/inject.md b/ts/docs/commands/exchange/inject.md index 946866d9a..f24887d20 100644 --- a/ts/docs/commands/exchange/inject.md +++ b/ts/docs/commands/exchange/inject.md @@ -7,7 +7,7 @@ Add liquidity to a pair, in proportion to its reserves. ``` wallet-cli exchange inject --token (--amount | --raw-amount ) - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -21,7 +21,7 @@ If the amount is so small that the computed other side rounds to zero, the chain **Tokens are named by id only** — `TRX` (or its on-chain id `_`) and a numeric TRC10 id; a TRC10 name may contain `:`. `--amount` is in whole tokens of the side you named and is converted using its precision; `--raw-amount` gives the same figure in minimal units. Exactly one of them is required. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -34,9 +34,10 @@ If the amount is so small that the computed other side rounds to zero, the chain | `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s; [Ledger exception](../../guide/ledger.md#3-when-the-device-doesnt-respond)) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -46,7 +47,7 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 --network nile --wait --password-stdin +echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 --network nile --password-stdin ``` ```console @@ -62,7 +63,7 @@ echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 --network n ``` ```bash -echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 --network nile --password-stdin -o json ``` ```json @@ -81,11 +82,11 @@ echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 --network n | `otherTokenId` / `otherTokenQuant` / `otherTokenLabel` / `otherTokenDecimals` | — | The same four for the side computed from the ratio | | `reserveAfter` / `otherReserveAfter` | string | The pair's balances after this injection, same order | -TRX is identified as `"_"`; every quantity is a **string** in minimal units. Before confirmation the other side and both reserves are this command's own exact arithmetic; once confirmed the receipt's figure replaces it. `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. +TRX is identified as `"_"`; every quantity is a **string** in minimal units. Before confirmation the other side and both reserves are this command's own exact arithmetic; once confirmed the receipt's figure replaces it. Confirmation adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `not_exchange_creator`, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `transaction_rejected` — the node refused it, for example for lack of balance, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--token`; `invalid_option` — both or neither of `--amount` / `--raw-amount`; `invalid_amount` — the amount is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or so small that the computed other side works out to zero). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `not_exchange_creator`, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `transaction_rejected` — the node refused it, for example for lack of balance, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--token`; `invalid_option` — both or neither of `--amount` / `--raw-amount`; `invalid_amount` — the amount is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or so small that the computed other side works out to zero). ## See also diff --git a/ts/docs/commands/exchange/trade.md b/ts/docs/commands/exchange/trade.md index 11cd450ba..3838d753f 100644 --- a/ts/docs/commands/exchange/trade.md +++ b/ts/docs/commands/exchange/trade.md @@ -8,7 +8,7 @@ Swap one side of a pair for the other. wallet-cli exchange trade --sell (--amount | --raw-amount ) [--min-received | --raw-min-received | --slippage ] - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -30,7 +30,7 @@ Slippage grows with trade size relative to the reserves — that is the curve, n > **Trading may be closed on the network you are on.** java-tron refuses `ExchangeTransactionContract` outright until the TIP-836 hardening proposal (`getAllowHardenExchangeCalculation`) is activated — it is unset on both mainnet and Nile, and the command then fails with `exchange_trading_disabled`. [`exchange create`](create.md), [`inject`](inject.md) and [`withdraw`](withdraw.md) are unaffected. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -46,9 +46,10 @@ Slippage grows with trade size relative to the reserves — that is the curve, n | `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s; [Ledger exception](../../guide/ledger.md#3-when-the-device-doesnt-respond)) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -60,7 +61,7 @@ In the examples, `$PW` is your master password (from an environment variable, pa With an explicit floor: ```bash -echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --min-received 4900 --network nile --wait --password-stdin +echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --min-received 4900 --network nile --password-stdin ``` ```console @@ -79,7 +80,7 @@ echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --min-received The same trade via `--slippage 1`: the CLI computes 4,950 from the current reserves, takes 1 % off, and sends 4,900.5 as the floor. The percentage is first converted to basis points, **rounded to the nearest** one — so `--slippage 1.006` tolerates 1.01 % — and the floor itself is then integer-divided, i.e. rounded down. ```bash -echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --slippage 1 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --slippage 1 --network nile --password-stdin -o json ``` ```json @@ -98,11 +99,11 @@ echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --slippage 1 - | `receivedQuant` | string | What the trade actually returned; **only once confirmed**, since it exists only in the receipt | | `minReceivedQuant` | string | The floor that went on chain — yours, the one `--slippage` derived, or `"1"` when no floor was given | -TRX is identified as `"_"`; every quantity is a **string** in minimal units. Before confirmation text shows `Estimated return` in place of `Received`. `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. +TRX is identified as `"_"`; every quantity is a **string** in minimal units. Before confirmation text shows `Estimated return` in place of `Received`. Confirmation adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `exchange_trading_disabled` — the network is not accepting Bancor trades, `slippage_exceeded` — the return fell below the floor, `transaction_rejected` — the node refused it, for example for lack of balance, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--sell`; `invalid_option` — both or neither of `--amount` / `--raw-amount`, or more than one floor flag; `invalid_amount` — the amount or `--min-received` is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or a `--slippage` that is not both greater than 0 and less than 100; `0` and `100` are themselves rejected). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `exchange_trading_disabled` — the network is not accepting Bancor trades, `slippage_exceeded` — the return fell below the floor, `transaction_rejected` — the node refused it, for example for lack of balance, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--sell`; `invalid_option` — both or neither of `--amount` / `--raw-amount`, or more than one floor flag; `invalid_amount` — the amount or `--min-received` is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or a `--slippage` that is not both greater than 0 and less than 100; `0` and `100` are themselves rejected). ## See also diff --git a/ts/docs/commands/exchange/withdraw.md b/ts/docs/commands/exchange/withdraw.md index 35fe04967..7cbed2f8b 100644 --- a/ts/docs/commands/exchange/withdraw.md +++ b/ts/docs/commands/exchange/withdraw.md @@ -7,7 +7,7 @@ Take liquidity out of a pair, in proportion to its reserves. ``` wallet-cli exchange withdraw --token (--amount | --raw-amount ) - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -19,7 +19,7 @@ The mirror of [`exchange inject`](inject.md): you name one side and its amount, **Tokens are named by id only** — `TRX` (or its on-chain id `_`) and a numeric TRC10 id; a TRC10 name may contain `:`. `--amount` is in whole tokens of the side you named; `--raw-amount` gives the same figure in minimal units. Exactly one of them is required. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -32,9 +32,10 @@ The mirror of [`exchange inject`](inject.md): you name one side and its amount, | `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s; [Ledger exception](../../guide/ledger.md#3-when-the-device-doesnt-respond)) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -44,7 +45,7 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 --network nile --wait --password-stdin +echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 --network nile --password-stdin ``` ```console @@ -60,7 +61,7 @@ echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 --network ``` ```bash -echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 --network nile --password-stdin -o json ``` ```json @@ -79,11 +80,11 @@ echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 --network | `otherTokenId` / `otherTokenQuant` / `otherTokenLabel` / `otherTokenDecimals` | — | The same four for the side computed from the ratio | | `reserveAfter` / `otherReserveAfter` | string | The pair's balances after this withdrawal, same order | -TRX is identified as `"_"`; every quantity is a **string** in minimal units. `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. +TRX is identified as `"_"`; every quantity is a **string** in minimal units. Confirmation adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `not_exchange_creator`, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `insufficient_reserve` — more than that side holds, `precision_loss` — the amount does not convert cleanly, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--token`; `invalid_option` — both or neither of `--amount` / `--raw-amount`; `invalid_amount` — the amount is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or so small that the computed other side works out to zero). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `not_exchange_creator`, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `insufficient_reserve` — more than that side holds, `precision_loss` — the amount does not convert cleanly, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--token`; `invalid_option` — both or neither of `--amount` / `--raw-amount`; `invalid_amount` — the amount is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or so small that the computed other side works out to zero). ## See also diff --git a/ts/docs/commands/gasfree/index.md b/ts/docs/commands/gasfree/index.md index 4bc991977..6ad9e1394 100644 --- a/ts/docs/commands/gasfree/index.md +++ b/ts/docs/commands/gasfree/index.md @@ -4,7 +4,7 @@ Gas-free token transfers via the GasFree service. `gasfree` moves tokens without holding any TRX: you sign a transfer with TIP-712 structured-data signing (TRON's EIP-712 analogue) and the GasFree service ([open.gasfree.io](https://open.gasfree.io)) puts it on-chain for you. The fee is charged in the transferred token itself — a per-transfer service fee, plus a one-time activation fee on your first transfer — so **no TRX is needed**. -> **TRON only.** Every command in this group implements a TRON protocol feature with no EVM counterpart; on an EVM network they fail with `family_mismatch` before any node call. +> **TRON only.** Every command in this group implements a TRON protocol feature with no EVM counterpart; on an EVM network they fail with `family_mismatch` before any node call. They run on `tron` and `nile`; on Shasta they fail with `unsupported_network_capability`. ## Synopsis diff --git a/ts/docs/commands/gasfree/info.md b/ts/docs/commands/gasfree/info.md index f8604056a..b1bafb0d6 100644 --- a/ts/docs/commands/gasfree/info.md +++ b/ts/docs/commands/gasfree/info.md @@ -57,7 +57,7 @@ wallet-cli gasfree info --account main --network nile -o json ## Exit status -`0` success · `1` execution failure (`gasfree_integrity` — the provider's fee metadata disagreed between the token list and the address response, `provider_error` — the service failed, answered with malformed or oversized JSON, returned a field this CLI will not act on, or returned any non-429 error status; `provider_rate_limited` — the service returned 429, with `details.retryAfter` when it sent one) · `2` usage error (`gasfree_credentials_missing`, `unsupported_network`, `invalid_value`). +`0` success · `1` execution failure (`gasfree_integrity` — the provider's fee metadata disagreed between the token list and the address response, `not_found` — the service returned 404 or no data, `gasfree_rejected` — the service declined the request or returned a 4xx status other than 401, 403, 404 and 429, `timeout` — the service did not answer within `--timeout`, `gasfree_auth_failed` — the service rejected the API credentials (HTTP 401 or 403), `provider_error` — the service could not be reached, returned a 5xx status, answered with malformed or oversized JSON, or returned a field this CLI will not act on; `provider_rate_limited` — the service returned 429, with `details.httpStatus: 429` and `details.retryAfterSeconds` when it sent a usable `Retry-After`) · `2` usage error (`gasfree_credentials_missing`, `unsupported_network`, `unsupported_network_capability` — Shasta, `invalid_value`, `invalid_config` — the network's GasFree `baseUrl` or `apiPrefix` is invalid). ## See also diff --git a/ts/docs/commands/gasfree/trace.md b/ts/docs/commands/gasfree/trace.md index f03d376fb..76f31e063 100644 --- a/ts/docs/commands/gasfree/trace.md +++ b/ts/docs/commands/gasfree/trace.md @@ -66,7 +66,7 @@ wallet-cli gasfree trace 7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527 --network nile -o ## Exit status -`0` success · `1` execution failure (`not_found` — no such trace id, `gasfree_integrity`, `provider_error` — the service failed, answered with malformed or oversized JSON, returned a field this CLI will not act on, or returned any non-429 error status; `provider_rate_limited` — the service returned 429, with `details.retryAfter` when it sent one) · `2` usage error (`gasfree_credentials_missing`, `unsupported_network`, `invalid_value`). +`0` success · `1` execution failure (`not_found` — no such trace id (HTTP 404, or the service returned no data), `gasfree_integrity`, `gasfree_rejected` — the service declined the request or returned a 4xx status other than 401, 403, 404 and 429, `timeout` — the service did not answer within `--timeout`, `gasfree_auth_failed` — the service rejected the API credentials (HTTP 401 or 403), `provider_error` — the service could not be reached, returned a 5xx status, answered with malformed or oversized JSON, or returned a field this CLI will not act on; `provider_rate_limited` — the service returned 429, with `details.httpStatus: 429` and `details.retryAfterSeconds` when it sent a usable `Retry-After`) · `2` usage error (`gasfree_credentials_missing`, `unsupported_network`, `unsupported_network_capability` — Shasta, `invalid_value`, `invalid_config` — the network's GasFree `baseUrl` or `apiPrefix` is invalid). A `FAILED` transfer is a successful query: the envelope stays `success: true` at exit `0`, and `data.failureReason` carries the provider's explanation. diff --git a/ts/docs/commands/gasfree/transfer.md b/ts/docs/commands/gasfree/transfer.md index 2d2e92066..e6f18d35f 100644 --- a/ts/docs/commands/gasfree/transfer.md +++ b/ts/docs/commands/gasfree/transfer.md @@ -6,14 +6,14 @@ Sign a gas-free transfer and submit it to the GasFree provider. ``` wallet-cli gasfree transfer --to --amount [--token ] - [--dry-run | --wait [--wait-timeout ]] [options] + [--dry-run | --wait | --no-wait] [options] ``` ## Description Signs a transfer with TIP-712 structured-data signing (TRON's EIP-712 analogue) and submits it to the GasFree provider, which puts it on-chain for you. No TRX is needed — the per-transfer service fee (plus a one-time activation fee on the first transfer) is deducted from the GasFree address's token balance, on top of the amount sent. -Submission returns a **`traceId`** (the provider's acceptance id); at that point the transfer is accepted but **not yet on-chain**. Add `--wait` to poll the provider to a terminal state (`SUCCEED` / `FAILED`), or follow it later with [`gasfree trace`](trace.md). On the first transfer, when the GasFree address isn't activated yet, this transfer carries the activation automatically and the total deducted is amount + service fee + activation fee (itemised in the receipt and in `--dry-run`). +Submission returns a **`traceId`** (the provider's acceptance id); at that point the transfer is accepted but **not yet on-chain**. By default the command polls the provider to a terminal state (`SUCCEED` / `FAILED`), or follow it later with [`gasfree trace`](trace.md). On the first transfer, when the GasFree address isn't activated yet, this transfer carries the activation automatically and the total deducted is amount + service fee + activation fee (itemised in the receipt and in `--dry-run`). There is no `--sign-only` / `--build-only`: the signed payload is bound to the provider's submission protocol, so offline distribution has no meaning. Requires an account and the provider credentials (`gasfreeApiKey` / `gasfreeApiSecret`, set with [`config`](../config.md)). The master password via `--password-stdin` is needed only when submitting — `--dry-run` neither unlocks nor signs. Watch-only accounts fail with `watch_only_no_signer` when submitting. @@ -25,7 +25,8 @@ There is no `--sign-only` / `--build-only`: the signed payload is bound to the p | `--amount ` | **Required.** Amount in token units (e.g. `25` = 25 USDT); fees are charged on top | | `--token ` | Token to transfer; must be supported by the provider (see `gasfree info`) — default `USDT` | | `--dry-run` | Fee breakdown and balance check only; no signature, no submission, no password | -| `--wait` / `--wait-timeout ` | Poll the provider until the transfer succeeds/fails (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll the provider until the transfer succeeds or fails, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the acceptance receipt without polling the provider | | `--password-stdin` | Master password from stdin | Plus the [global options](../index.md#global-options-every-command). @@ -34,10 +35,10 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password, fed on stdin via `--password-stdin`. -Default — submit and return the acceptance receipt (a `traceId`, not yet on-chain): +With `--no-wait` — submit and return the acceptance receipt (a `traceId`, not yet on-chain): ```bash -echo "$PW" | wallet-cli gasfree transfer --to TF9yB7bAL2oBbonYaMvGTqoXxExS14x73c --amount 25 --network nile --password-stdin +echo "$PW" | wallet-cli gasfree transfer --to TF9yB7bAL2oBbonYaMvGTqoXxExS14x73c --amount 25 --network nile --no-wait --password-stdin ``` ```console @@ -57,10 +58,10 @@ echo "$PW" | wallet-cli gasfree transfer --to TF9yB7bAL2oBbonYaMvGTqoXxExS14x73c {"schema":"wallet-cli.result.v1","success":true,"command":"gasfree.transfer","data":{"kind":"gasfree-transfer","stage":"submitted","traceId":"7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527","state":"WAITING","token":"USDT","tokenAddress":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","decimals":6,"amount":"25000000","serviceFee":"500000","activateFee":"0","authorizedMaxFee":"1500000","totalDeducted":"25500000","owner":"TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC","from":"TNER12mMVWruqopsW9FQtKxCGfZcEtb3ER","to":"TF9yB7bAL2oBbonYaMvGTqoXxExS14x73c","serviceProvider":"TKtWbdzEq5ss9vTS9kwRhBp5mXmBfBns3E","nonce":"8","deadline":"1700000060"},"meta":{"durationMs":650,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` -Add `--wait` to poll to a terminal state, with the on-chain txid and actual deduction: +Without `--no-wait`, the command polls to a terminal state, with the on-chain txid and actual deduction: ```bash -echo "$PW" | wallet-cli gasfree transfer --to TF9yB7bAL2oBbonYaMvGTqoXxExS14x73c --amount 25 --network nile --wait --password-stdin +echo "$PW" | wallet-cli gasfree transfer --to TF9yB7bAL2oBbonYaMvGTqoXxExS14x73c --amount 25 --network nile --password-stdin ``` ```console @@ -103,16 +104,18 @@ wallet-cli gasfree transfer --to TF9yB7bAL2oBbonYaMvGTqoXxExS14x73c --amount 25 | Mode | Fields | |---|---| -| default (submit) | `kind: "gasfree-transfer"`, `stage: "submitted"`, `traceId`, provider `state`, `token`, `tokenAddress`, `decimals`, `amount`, `serviceFee`, `activateFee`, `authorizedMaxFee`, `totalDeducted`, `owner`, `from`, `to`, `nonce`, `deadline`, `serviceProvider`, plus `toContact` when `--to` was a contact name | -| `--wait` (confirmed) | the above, but `stage: "confirmed"`, `state: "SUCCEED"`, and `txId` when the provider supplies it | -| `--wait` (failed) | the same fields, but `stage: "failed"`, `failed: true`, `state: "FAILED"`, and `failureReason` carrying the provider's explanation | +| `--no-wait` (submitted) | `kind: "gasfree-transfer"`, `stage: "submitted"`, `traceId`, provider `state`, `token`, `tokenAddress`, `decimals`, `amount`, `serviceFee`, `activateFee`, `authorizedMaxFee`, `totalDeducted`, `owner`, `from`, `to`, `nonce`, `deadline`, `serviceProvider`, plus `toContact` when `--to` was a contact name | +| default or `--wait` (confirmed) | the above, but `stage: "confirmed"`, `state: "SUCCEED"`, and `txId` when the provider supplies it | +| default or `--wait` (failed) | the same fields, but `stage: "failed"`, `state: "FAILED"`, and `failureReason` carrying the provider's explanation | | `--dry-run` | the default fields except `traceId` **and** `state` — both come from the provider, which is never called — with `stage: "dry-run"`; no signature, no submission. Text renders `Status not submitted` in place of the missing `state` | A provider-side failure still leaves the envelope at `success: true` and exit `0` — the command completed; the transfer did not. This view carries no `confirmed` or `failed` booleans; branch on `data.stage` / `data.state`, not on the exit code. See [script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed). ## Exit status -`0` submitted (or dry-run) · `1` execution failure (`insufficient_token_balance` — token balance < amount + service fee [+ activation fee], `gasfree_rejected` — the provider declined the authorization, `gasfree_integrity` — the provider's fee metadata disagreed with itself, `watch_only_no_signer`, `auth_failed`, `signing_rejected`, `provider_error` — the service failed, answered with malformed or oversized JSON, returned a field this CLI will not act on, or returned any non-429 error status; `provider_rate_limited` — the service returned 429, with `details.retryAfter` when it sent one) · `2` usage error (`gasfree_credentials_missing`, `unsupported_token`, `unsupported_network`, `invalid_value`, `invalid_amount`). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or dry-run) · `1` execution failure (`insufficient_token_balance` — token balance < amount + service fee [+ activation fee], `gasfree_rejected` — the provider declined the authorization, the GasFree address may not submit transfers, or the service returned a 4xx status other than 401, 403, 404 and 429, `gasfree_integrity` — the provider's fee metadata disagreed with itself, or its submit receipt or a polled record did not match the signed authorization; after submission `details.traceId` carries the id to follow with [`gasfree trace`](trace.md) and `details.outcome` is `"unknown"`, `not_found` — the service returned 404 or no data, `watch_only_no_signer`, `auth_failed`, `signing_rejected`, `timeout` — the service did not answer within `--timeout`, `gasfree_auth_failed` — the service rejected the API credentials (HTTP 401 or 403), `provider_error` — the service could not be reached, returned a 5xx status, answered with malformed or oversized JSON, or returned a field this CLI will not act on; `provider_rate_limited` — the service returned 429, with `details.httpStatus: 429` and `details.retryAfterSeconds` when it sent a usable `Retry-After`) · `2` usage error (`gasfree_credentials_missing`, `unsupported_token`, `unsupported_network`, `unsupported_network_capability` — Shasta, `invalid_value`, `invalid_amount`, `invalid_config` — the network's GasFree `baseUrl` or `apiPrefix` is invalid). + +A `timeout` or `provider_error` after the transfer was submitted carries `paymentStatus: "unknown"` and [`outcome: "unknown"`](../../machine-interface.md#unknown-outcome-errordetailsoutcome): the transfer may have been accepted. Compare the GasFree nonce with `details.nonce` before you transfer again; see [Out of time after a submission](../../machine-interface.md#out-of-time-after-a-submission). ## See also diff --git a/ts/docs/commands/import/keystore.md b/ts/docs/commands/import/keystore.md index 889fa5d40..15e735f88 100644 --- a/ts/docs/commands/import/keystore.md +++ b/ts/docs/commands/import/keystore.md @@ -77,7 +77,7 @@ wallet-cli import keystore ./tronlink-export.json --label imported -o json ## Exit status -`0` imported · `1` execution failure (`wrong_keystore_password`; `auth_failed`; `io_error`) · `2` usage error (`tty_required` — no TTY for interactive input, checked before anything else; `keystore_not_found` — no such file; `invalid_keystore` — not a valid keystore JSON; `invalid_value` — a duplicate or invalid label). +`0` imported · `1` execution failure (`aborted` — Ctrl-C at a prompt; nothing is imported; `wrong_keystore_password`; `auth_failed`; `io_error`) · `2` usage error (`tty_required` — no TTY for interactive input, checked before anything else; `keystore_not_found` — no such file; `invalid_keystore` — not a valid keystore JSON; `invalid_value` — a duplicate or invalid label). ## See also diff --git a/ts/docs/commands/import/ledger.md b/ts/docs/commands/import/ledger.md index b0458cb68..a51331802 100644 --- a/ts/docs/commands/import/ledger.md +++ b/ts/docs/commands/import/ledger.md @@ -9,6 +9,8 @@ wallet-cli import ledger --app [--index | --path | - [--scan-limit ] [--label ] [options] ``` +An EVM `--address` lookup ignores letter case. + ## Options | Option | Description | @@ -81,7 +83,7 @@ wallet-cli import ledger --app tron --index 0 --label cold -o json ## Exit status -`0` success · `1` execution failure · `2` usage error. See [machine-interface](../../machine-interface.md). +`0` success · `1` execution failure · `2` usage error (`invalid_option` — more than one of `--index`, `--path` and `--address`). See [machine-interface](../../machine-interface.md). ## See also diff --git a/ts/docs/commands/import/mnemonic.md b/ts/docs/commands/import/mnemonic.md index 539b9d329..b88adc0f9 100644 --- a/ts/docs/commands/import/mnemonic.md +++ b/ts/docs/commands/import/mnemonic.md @@ -80,7 +80,7 @@ wallet-cli import mnemonic --label restored -o json ## Exit status -`0` imported · `1` execution failure (`auth_failed` — the entered master password does not match an existing keystore; `invalid_mnemonic` — storage validation rejected the phrase; `io_error`) · `2` usage error (`tty_required` — no TTY for the hidden prompts; `invalid_value` — an invalid or duplicate label). An invalid phrase or a weak new password entered at a prompt is rejected there and asked again, not returned as a terminal error. +`0` imported · `1` execution failure (`aborted` — Ctrl-C at a prompt; nothing is imported; `auth_failed` — the entered master password does not match an existing keystore; `invalid_mnemonic` — storage validation rejected the phrase; `io_error`) · `2` usage error (`tty_required` — no TTY for the hidden prompts; `invalid_value` — an invalid or duplicate label). An invalid phrase or a weak new password entered at a prompt is rejected there and asked again, not returned as a terminal error. ## See also diff --git a/ts/docs/commands/import/private-key.md b/ts/docs/commands/import/private-key.md index 4e5464893..0193082a2 100644 --- a/ts/docs/commands/import/private-key.md +++ b/ts/docs/commands/import/private-key.md @@ -72,7 +72,7 @@ wallet-cli import private-key --label hot -o json ## Exit status -`0` imported · `1` execution failure (`auth_failed` — the entered master password does not match an existing keystore; `invalid_private_key` — storage validation rejected the key; `io_error`) · `2` usage error (`tty_required` — no TTY for the hidden prompts; `invalid_value` — an invalid or duplicate label). An invalid key or a weak new password entered at a prompt is rejected there and asked again, not returned as a terminal error. +`0` imported · `1` execution failure (`aborted` — Ctrl-C at a prompt; nothing is imported; `auth_failed` — the entered master password does not match an existing keystore; `invalid_private_key` — storage validation rejected the key; `io_error`) · `2` usage error (`tty_required` — no TTY for the hidden prompts; `invalid_value` — an invalid or duplicate label). An invalid key or a weak new password entered at a prompt is rejected there and asked again, not returned as a terminal error. ## See also diff --git a/ts/docs/commands/index.md b/ts/docs/commands/index.md index 47709db09..7c968845e 100644 --- a/ts/docs/commands/index.md +++ b/ts/docs/commands/index.md @@ -238,21 +238,22 @@ The catalog is the authority on all of this: `wallet-cli --json-schema` reports an address names one chain, so use accountId or label to cross families; an address matching several accounts that are not interchangeable signers for the family being acted on is `ambiguous_account` ---timeout per RPC/device call timeout, ms (default: config.timeoutMs, built-in 60000) +--timeout deadline for the whole command, ms; 0 = no limit + (default: config.timeoutMs, built-in 90000) -v, --verbose extra diagnostic output -h, --help / -V, --version ``` -Commands whose schema allows post-broadcast polling take `--wait` / `--wait-timeout ` (cap default: config `waitTimeoutMs`, built-in 60000). The early-exit modes are command-specific too: transaction-building commands may expose `--dry-run` / `--sign-only` / `--build-only`, while a submit-only command such as `tx broadcast` neither rebuilds nor signs and therefore has no `--sign-only` / `--build-only`. +Commands that submit a transaction wait for its confirmation within the command's `--timeout` (see [time limit](../machine-interface.md#time-limit---timeout)); `--no-wait` returns at submission, and `--wait` names the default. Elsewhere, and with an early-exit mode, both flags fail with `invalid_option`. The early-exit modes are command-specific too: transaction-building commands may expose `--dry-run` / `--sign-only` / `--build-only`, while a submit-only command such as `tx broadcast` neither rebuilds nor signs and therefore has no `--sign-only` / `--build-only`. Fee and multi-sig flags are **family-scoped**, so they are not global: | Flags | Family | Where | |---|---|---| | Permission group and expiry — see below | TRON | TRON transaction-building commands that sign or can emit unsigned hex; not `tx broadcast`, GasFree, or the `sunswap` and `sunpump` writes | -| `--fee-limit ` | TRON | the commands that spend energy: `tx send`, `contract send` / `deploy`, `sunswap swap` / `add-liquidity` / `remove-liquidity` / `collect-fees`, and `sunpump buy` / `sell` | +| `--fee-limit ` | TRON | the commands that spend energy: `tx send`, `contract send` / `deploy`, `sunswap swap` / `add-liquidity` / `remove-liquidity` / `collect-fees`, `sunpump buy` / `sell`, and the `8004` writes | | `--gas-limit ` / `--max-fee ` / `--priority-fee ` / `--nonce ` | EVM | `tx send`, `contract send` / `deploy` | `--permission-id` selects the permission group to sign under (0=owner, 1=witness, 2-9=active) and `--expiration` extends the window for collecting co-signatures. An EVM transaction carries exactly one signature, so neither has a counterpart there: on a portable command they are tagged `(TRON only)` and refused on EVM with `invalid_option`. -Where all three early-exit modes exist, they are mutually exclusive, and `--expiration` is accepted only alongside `--sign-only` or `--build-only`. Breaking either rule is a usage error at exit `2`. The code depends on where the check runs: on the `account`, `permission`, `contract`, `proposal` and `witness` writes it is `invalid_value`, and which field the message names depends on the rule. Mutual exclusion is attached to the whole object, so it reports `--input` rather than the flags you passed (`invalid --input: choose at most one of --dry-run, --sign-only, --build-only`); the `--expiration` rule is attached to its own field and names it (`invalid --expiration: only valid with --sign-only or --build-only`). Elsewhere the same conflict reports `invalid_option`. Branch on the exit code, not on the code string; see [machine interface](../machine-interface.md#error-codes). +Where two or more early-exit modes exist, they are mutually exclusive, and `--expiration` is accepted only alongside `--sign-only` or `--build-only`. Breaking either rule is a usage error at exit `2`. Passing two modes is `invalid_option` and names the first one given (`invalid --dry-run: cannot be combined with --sign-only`); `--json-schema` lists the modes as an `exclusive` group. For `--expiration` the code depends on where the check runs: on the `account`, `permission`, `contract`, `proposal`, `witness` and `8004` writes it is `invalid_value` (`invalid --expiration: only valid with --sign-only or --build-only`); elsewhere it is `invalid_option`. Branch on the exit code, not on the code string; see [machine interface](../machine-interface.md#error-codes). diff --git a/ts/docs/commands/list.md b/ts/docs/commands/list.md index 60229e7d8..cad852aa6 100644 --- a/ts/docs/commands/list.md +++ b/ts/docs/commands/list.md @@ -16,7 +16,7 @@ An account holds **one address per chain family** it can produce: seed and priva `--network` here is a **display selector**, not a target: it chooses which family's address the text listing prints. Accounts with no address in that family are omitted, and a warning names how many were left out. JSON output is unfiltered — it always lists every account with every address it has. -An account stored in a wallet format this build does not recognise — a registry written by a newer wallet-cli — is **skipped rather than fatal**: the listing still shows every account it can read, and a warning names the wallet ids it left out (`… use a wallet format this version does not understand and were skipped: wlt_… . Upgrade wallet-cli to use them.`). Naming such an account on any other command is a hard `encoding_error` (exit 1), raised before the operation starts. +An account stored with a storage kind this build does not recognise is **skipped rather than fatal**: the listing still shows every account it can read, and a warning names the wallet ids it left out (`… use a wallet format this version does not understand and were skipped: wlt_… . Upgrade wallet-cli to use them.`). Naming such an account on any other command is a hard `encoding_error` (exit 1), raised before the operation starts. ## Options @@ -72,7 +72,7 @@ The `chain` block echoes the network that was selected for display; the command ## Exit status -`0` · `2` usage error. See [machine-interface](../machine-interface.md#exit-codes). +`0` · `1` execution failure (`encoding_error` — the local wallet file could not be decoded) · `2` usage error. See [machine-interface](../machine-interface.md#exit-codes). ## See also diff --git a/ts/docs/commands/message/sign.md b/ts/docs/commands/message/sign.md index 96fd4745a..5760d3e3d 100644 --- a/ts/docs/commands/message/sign.md +++ b/ts/docs/commands/message/sign.md @@ -14,7 +14,7 @@ Signs a message with the active account's key (or `--account`) using the persona The selected network decides **which key signs and which address is reported**: an account has one key per family, so `--network nile` signs with its TRON key and reports its base58 address, `--network sepolia` signs with its EVM key and reports its `0x` address. `--network` is optional and falls back to `config.defaultNetwork`; either way the signing itself is fully offline. -**stdin has a single consumer (fd 0)**: `--message-stdin` and `--password-stdin` cannot both be used in one run (`secret_source_error`). In practice: +**stdin has a single consumer (fd 0)**: `--message-stdin` and `--password-stdin` cannot both be used in one run (`invalid_option`). In practice: - **Ledger accounts** confirm on the device and need no master password — fd 0 is free, so you can pipe the message via `--message-stdin`. - **Software accounts** need `--password-stdin` for non-interactive use — the message must then go inline via `--message`. @@ -77,7 +77,7 @@ echo "$PW" | wallet-cli message sign --message "hello" --network sepolia --passw ## Exit status -`0` signed · `1` execution failure (`watch_only_no_signer`, `auth_failed`; two stdin flags → `secret_source_error`) · `2` usage error (both or neither message source → `invalid_option` / `missing_option`). +`0` signed · `1` execution failure (`watch_only_no_signer`, `auth_failed`) · `2` usage error (both or neither message source → `invalid_option` / `missing_option`; two stdin flags → `invalid_option`). ## See also diff --git a/ts/docs/commands/permission/update.md b/ts/docs/commands/permission/update.md index df0164b41..bef398b19 100644 --- a/ts/docs/commands/permission/update.md +++ b/ts/docs/commands/permission/update.md @@ -6,7 +6,7 @@ Replace the account's permission structure. ``` wallet-cli permission update (--file | --json ) - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -46,7 +46,8 @@ Changing only `keys`, `threshold` or `name` needs no such deletion. | `--build-only` | Build and estimate, output the **unsigned** hex (feed [`tx multisig --create`](../tx/multisig.md) for service-relayed multi-sig). Excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` — changing permissions is owner-level, so normally `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | Plus the [global options](../index.md#global-options-every-command). @@ -66,10 +67,10 @@ wallet-cli permission show --network nile -o json | jq '.data' > perms.json $EDITOR perms.json ``` -Submit with `--wait`. The receipt is the transaction record plus the resulting on-chain structure (read back after confirmation, same cards as `permission show`). Warnings go to stderr ahead of it: +Submit and wait for confirmation (the default). The receipt is the transaction record plus the resulting on-chain structure (read back after confirmation, same cards as `permission show`). Warnings go to stderr ahead of it: ```bash -echo "$PW" | wallet-cli permission update --file perms.json --network nile --wait --password-stdin +echo "$PW" | wallet-cli permission update --file perms.json --network nile --password-stdin ``` ```console @@ -101,7 +102,7 @@ Authorized To Address Weight When present, the JSON receipt's `data.permissions` is **structurally identical** to `permission show`'s `data`, so you can diff it against the pre-change export. If the confirmed post-check cannot be read, the field is omitted and `meta.warnings` carries `permission_postcheck_unavailable`; the confirmed transaction is still `success: true`. The lockout warning is in `meta.warnings` too: ```bash -echo "$PW" | wallet-cli permission update --file perms.json --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli permission update --file perms.json --network nile --password-stdin -o json ``` ```json @@ -114,9 +115,9 @@ echo "$PW" | wallet-cli permission update --file perms.json --network nile --wai | Mode | Fields | |---|---| -| default (submit) | `kind: "permission-update"`, `stage: "submitted"`, `txId` | -| `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `feeSun`, `failed`, and `permissions` (same shape as `permission show` data, read back from chain) | -| `--dry-run` | `kind`, `mode: "dry-run"`, `tx`, `fee` (the account-permission fee), and `permissions` (the resulting structure); no `txId` | +| `--no-wait` (submitted) | `kind: "permission-update"`, `stage: "submitted"`, `txId` | +| default or `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `feeSun`, `failed`, and `permissions` (same shape as `permission show` data, read back from chain) | +| `--dry-run` | `kind`, `mode: "dry-run"`, `tx`, `fee` (the account-permission fee; `fee.feeSun` and `fee.balanceSun` are decimal SUN strings), and `permissions` (the resulting structure); no `txId` | | `--sign-only` | `kind`, `mode: "sign-only"`, `signed`, `hex` (signed tx hex — feed `tx broadcast --hex`), `fee`, `address`, `txId`, and `permissions` | | `--build-only` | `kind`, `mode: "build-only"`, `tx`, `hex` (unsigned tx hex — feed `tx multisig --create`), `fee`, and `permissions` | @@ -126,10 +127,14 @@ After confirmation, `permission_postcheck_unavailable` says the read-back failed ## Exit status -`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`not_authorized`, `watch_only_no_signer`, `auth_failed`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_permission` — malformed JSON or an invalid permission structure; `invalid_value`). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed/dry-run in early-exit modes) · `1` execution failure (`not_authorized`, `watch_only_no_signer`, `auth_failed`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_permission` — malformed JSON or an invalid permission structure; `invalid_value` — malformed values; `invalid_option` — missing or conflicting input sources, or conflicting modes). On a multi-sig account, a submission whose accumulated signature weight is below the permission threshold is refused **after signing and before broadcasting** with `not_authorized` (`signature threshold is not reached; missing N weight`) — nothing is sent and no fee is burned. Collect the remaining signatures through `--sign-only` + [`tx sign`](../tx/sign.md) and submit with [`tx broadcast`](../tx/broadcast.md) instead. `--sign-only` and `--build-only` still return a partial signature, which is how a co-signing flow starts. +## Known limitations + +**Ledger TRON app 0.7.7 – 0.8.1.** The device refuses some permission updates, and the command fails with `ledger_unsupported`. This happens when the update is just long enough to need one more transfer to the device. Shorten a permission `name` in the structure by a few characters and try again. + ## See also [`permission show`](show.md) · [`tx sign`](../tx/sign.md) · [`tx broadcast`](../tx/broadcast.md) · [`tx multisig`](../tx/multisig.md) · [Security](../../concepts/security.md) diff --git a/ts/docs/commands/proposal/approve.md b/ts/docs/commands/proposal/approve.md index 5f19b3fee..692c40ef7 100644 --- a/ts/docs/commands/proposal/approve.md +++ b/ts/docs/commands/proposal/approve.md @@ -6,7 +6,7 @@ Approve a proposal, or cancel your approval. ``` wallet-cli proposal approve [--cancel] - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -16,7 +16,7 @@ Adds your approval to a proposal; `--cancel` withdraws an approval you already c Only a registered witness can approve; other accounts fail with `not_a_witness`. The chain checks nothing beyond that, so a non-elected candidate's approval is accepted and lands on chain — but at tally only the approvals of the **top-27 active SRs** count toward the threshold, so it does not move the proposal any closer to passing. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -29,7 +29,8 @@ Only a registered witness can approve; other accounts fail with `not_a_witness`. | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -39,7 +40,7 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli proposal approve 47 --network nile --wait --password-stdin +echo "$PW" | wallet-cli proposal approve 47 --network nile --password-stdin ``` ```console @@ -56,7 +57,7 @@ echo "$PW" | wallet-cli proposal approve 47 --network nile --wait --password-std `--cancel` takes your own approval back off the proposal: ```bash -echo "$PW" | wallet-cli proposal approve 47 --cancel --network nile --wait --password-stdin +echo "$PW" | wallet-cli proposal approve 47 --cancel --network nile --password-stdin ``` ```console @@ -71,7 +72,7 @@ echo "$PW" | wallet-cli proposal approve 47 --cancel --network nile --wait --pas ``` ```bash -echo "$PW" | wallet-cli proposal approve 47 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli proposal approve 47 --network nile --password-stdin -o json ``` ```json @@ -84,12 +85,12 @@ echo "$PW" | wallet-cli proposal approve 47 --network nile --wait --password-std | Stage | Fields | |---|---| -| default (submit) | `kind: "proposal-approve"`, `stage: "submitted"`, `txId`, `proposalId`, `voterAddress`, `addApproval` (`false` with `--cancel`), `approvals`, and `approvalThreshold` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | +| `--no-wait` (submitted) | `kind: "proposal-approve"`, `stage: "submitted"`, `txId`, `proposalId`, `voterAddress`, `addApproval` (`false` with `--cancel`), `approvals`, and `approvalThreshold` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `proposal_not_found` — no such proposal, `already_approved` — you already approved it, `not_approved` — `--cancel` with no approval to withdraw, `proposal_expired`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — id not a number). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `proposal_not_found` — no such proposal, `already_approved` — you already approved it, `not_approved` — `--cancel` with no approval to withdraw, `proposal_expired`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — id not a number). ## See also diff --git a/ts/docs/commands/proposal/create.md b/ts/docs/commands/proposal/create.md index 5aa66b207..044b585f9 100644 --- a/ts/docs/commands/proposal/create.md +++ b/ts/docs/commands/proposal/create.md @@ -6,7 +6,7 @@ Create a governance proposal that changes chain parameters. ``` wallet-cli proposal create --set = [--set ...] - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -18,7 +18,7 @@ Submits a proposal carrying one or more chain-parameter changes for super repres Pass `--set` once per parameter. The receipt and `data.changes[]` order changes by parameter id, not by the order you typed them, so the same proposal always renders the same way. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -30,7 +30,8 @@ Pass `--set` once per parameter. The receipt and `data.changes[]` order changes | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -42,7 +43,7 @@ In the examples, `$PW` is your master password (from an environment variable, pa One parameter, waiting for confirmation: ```bash -echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --network nile --wait --password-stdin +echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --network nile --password-stdin ``` ```console @@ -60,7 +61,7 @@ echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --network nil Several parameters in one proposal — the receipt lists them by parameter id: ```bash -echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --set getCreateAccountFee=200000 --network nile --wait --password-stdin +echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --set getCreateAccountFee=200000 --network nile --password-stdin ``` ```console @@ -77,7 +78,7 @@ echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --set getCrea ``` ```bash -echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --network nile --password-stdin -o json ``` ```json @@ -90,8 +91,8 @@ echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --network nil | Stage | Fields | |---|---| -| default (submit) | `kind: "proposal-create"`, `stage: "submitted"`, `txId`, `proposerAddress`, `changes[]` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed`, and optional `proposalId` — the new proposal's id, known only once it is on chain | +| `--no-wait` (submitted) | `kind: "proposal-create"`, `stage: "submitted"`, `txId`, `proposerAddress`, `changes[]` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed`, and optional `proposalId` — the new proposal's id, known only once it is on chain | `changes[]` entries carry `id`, `name`, `currentValue`, `proposedValue`, and `unit`, ordered by `id`. @@ -99,7 +100,7 @@ echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --network nil ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--set` given; `unknown_parameter` — no such name or id; `invalid_value` — value out of range or not a number). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--set` given; `unknown_parameter` — no such name or id; `invalid_value` — value out of range or not a number). ## See also diff --git a/ts/docs/commands/proposal/delete.md b/ts/docs/commands/proposal/delete.md index 578954280..3f2b7e5ac 100644 --- a/ts/docs/commands/proposal/delete.md +++ b/ts/docs/commands/proposal/delete.md @@ -6,7 +6,7 @@ Delete a proposal you created. ``` wallet-cli proposal delete - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -18,7 +18,7 @@ This is a different action from [`proposal approve --cancel`](approve.md), which The chain records the result under its own name — after a successful delete, [`proposal show`](show.md) reports `State canceled`. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -30,7 +30,8 @@ The chain records the result under its own name — after a successful delete, [ | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -40,7 +41,7 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli proposal delete 48 --network nile --wait --password-stdin +echo "$PW" | wallet-cli proposal delete 48 --network nile --password-stdin ``` ```console @@ -54,7 +55,7 @@ echo "$PW" | wallet-cli proposal delete 48 --network nile --wait --password-stdi ``` ```bash -echo "$PW" | wallet-cli proposal delete 48 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli proposal delete 48 --network nile --password-stdin -o json ``` ```json @@ -67,12 +68,12 @@ echo "$PW" | wallet-cli proposal delete 48 --network nile --wait --password-stdi | Stage | Fields | |---|---| -| default (submit) | `kind: "proposal-delete"`, `stage: "submitted"`, `txId`, `proposalId`, `proposerAddress` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | +| `--no-wait` (submitted) | `kind: "proposal-delete"`, `stage: "submitted"`, `txId`, `proposalId`, `proposerAddress` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`proposal_not_found` — no such proposal, `not_proposal_owner` — you are not its creator, `proposal_expired`, `already_canceled`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — id not a number). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`proposal_not_found` — no such proposal, `not_proposal_owner` — you are not its creator, `proposal_expired`, `already_canceled`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — id not a number). ## See also diff --git a/ts/docs/commands/reward/balance.md b/ts/docs/commands/reward/balance.md index 0fed90d9d..7be3d075c 100644 --- a/ts/docs/commands/reward/balance.md +++ b/ts/docs/commands/reward/balance.md @@ -61,6 +61,8 @@ wallet-cli reward balance --account main --network nile -o json | `withdrawableNow` | boolean | Whether it can be withdrawn now | | `withdrawableAt` | number \| null | When it becomes withdrawable (epoch ms); `null` when already withdrawable | +Malformed TRON account data fails with `provider_error`. + ## Exit status `0` success · `1` execution failure (`rpc_error`) · `2` usage error (`invalid_value`). diff --git a/ts/docs/commands/reward/withdraw.md b/ts/docs/commands/reward/withdraw.md index 41c47ca52..a943dae07 100644 --- a/ts/docs/commands/reward/withdraw.md +++ b/ts/docs/commands/reward/withdraw.md @@ -5,7 +5,7 @@ Withdraw accrued rewards to balance. ## Synopsis ``` -wallet-cli reward withdraw [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] +wallet-cli reward withdraw [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -13,9 +13,9 @@ wallet-cli reward withdraw [--dry-run | (--sign-only | --build-only) [--expirati Moves your accumulated voting rewards (plus block rewards if you are an SR) into the account's available balance. The chain allows this **at most once every 24 hours** — check first with [`reward balance`](balance.md); an early attempt fails with `withdraw_too_frequent`, and an empty balance with `no_reward`. -`Amount` in the receipt: at the `submitted` stage it is the claimable amount read at broadcast time; the `--wait` confirmed receipt shows the actual on-chain amount (they differ only by seconds of accrual — negligible). +`Amount` in the receipt: at the `submitted` stage it is the claimable amount read at broadcast time; the confirmed receipt shows the actual on-chain amount (they differ only by seconds of accrual — negligible). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation**; `--no-wait` returns at submission. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -26,7 +26,8 @@ Moves your accumulated voting rewards (plus block rewards if you are an SR) into | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | Plus the [global options](../index.md#global-options-every-command). @@ -35,10 +36,10 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. -Default — broadcasts and returns the **submitted** receipt: +With `--no-wait` — broadcasts and returns the **submitted** receipt: ```bash -echo "$PW" | wallet-cli reward withdraw --network nile --password-stdin +echo "$PW" | wallet-cli reward withdraw --network nile --no-wait --password-stdin ``` ```console @@ -50,17 +51,17 @@ echo "$PW" | wallet-cli reward withdraw --network nile --password-stdin ``` ```bash -echo "$PW" | wallet-cli reward withdraw --network nile --password-stdin -o json +echo "$PW" | wallet-cli reward withdraw --network nile --no-wait --password-stdin -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"reward.withdraw","data":{"kind":"reward-withdraw","stage":"submitted","txId":"a1b...","rewardSun":"123456789"},"meta":{"durationMs":17,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"reward.withdraw","data":{"kind":"reward-withdraw","stage":"submitted","txId":"a1b...","rewardSun":"123456789","withdrawIntervalMs":86400000},"meta":{"durationMs":17,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` -Add `--wait` to block until confirmed (adds real block / fee): +Without `--no-wait`, the command blocks until confirmed (adds real block / fee): ```bash -echo "$PW" | wallet-cli reward withdraw --network nile --wait --password-stdin +echo "$PW" | wallet-cli reward withdraw --network nile --password-stdin ``` ```console @@ -78,8 +79,8 @@ echo "$PW" | wallet-cli reward withdraw --network nile --wait --password-stdin | Stage | Fields | |---|---| -| default (submit) | `kind: "reward-withdraw"`, `stage: "submitted"`, `txId`, `rewardSun` (string) | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed` | +| `--no-wait` (submitted) | `kind: "reward-withdraw"`, `stage: "submitted"`, `txId`, `rewardSun` (string), `withdrawIntervalMs` (minimum time between withdrawals, in ms) | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed` | ## Exit status diff --git a/ts/docs/commands/stake/cancel-unfreeze.md b/ts/docs/commands/stake/cancel-unfreeze.md index 9b23f9ed4..d8cb766ee 100644 --- a/ts/docs/commands/stake/cancel-unfreeze.md +++ b/ts/docs/commands/stake/cancel-unfreeze.md @@ -5,7 +5,7 @@ Cancel all pending unstakes (roll back to frozen). ## Synopsis ``` -wallet-cli stake cancel-unfreeze [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] +wallet-cli stake cancel-unfreeze [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -13,7 +13,7 @@ wallet-cli stake cancel-unfreeze [--dry-run | (--sign-only | --build-only) [--ex Cancels **every** unstake still in its waiting period and rolls those amounts back to staked — resource allowance and voting power return accordingly. It is all-or-nothing: TRON has no per-entry cancel. Any entries that have already expired are withdrawn to balance as part of the same transaction. -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation**; `--no-wait` returns at submission. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. The Ledger TRON app cannot sign `CancelAllUnfreezeV2`. A Ledger account can dry-run or build; signing modes fail with `ledger_unsupported` before the device is touched. @@ -26,7 +26,8 @@ The Ledger TRON app cannot sign `CancelAllUnfreezeV2`. A Ledger account can dry- | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | Plus the [global options](../index.md#global-options-every-command). @@ -35,10 +36,10 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. -Default — returns the **submitted** receipt: +With `--no-wait` — returns the **submitted** receipt: ```bash -echo "$PW" | wallet-cli stake cancel-unfreeze --network nile --password-stdin +echo "$PW" | wallet-cli stake cancel-unfreeze --network nile --no-wait --password-stdin ``` ```console @@ -49,17 +50,17 @@ echo "$PW" | wallet-cli stake cancel-unfreeze --network nile --password-stdin ``` ```bash -echo "$PW" | wallet-cli stake cancel-unfreeze --network nile --password-stdin -o json +echo "$PW" | wallet-cli stake cancel-unfreeze --network nile --no-wait --password-stdin -o json ``` ```json {"schema":"wallet-cli.result.v1","success":true,"command":"stake.cancel-unfreeze","data":{"kind":"stake-cancel","stage":"submitted","txId":"9ec..."},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` -Add `--wait` to block until confirmed: +Without `--no-wait`, the command blocks until confirmed: ```bash -echo "$PW" | wallet-cli stake cancel-unfreeze --network nile --wait --password-stdin +echo "$PW" | wallet-cli stake cancel-unfreeze --network nile --password-stdin ``` ```console @@ -76,12 +77,12 @@ echo "$PW" | wallet-cli stake cancel-unfreeze --network nile --wait --password-s | Stage | Fields | |---|---| -| default (submit) | `kind: "stake-cancel"`, `stage: "submitted"`, `txId` | -| `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | +| `--no-wait` (submitted) | `kind: "stake-cancel"`, `stage: "submitted"`, `txId` | +| default or `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `ledger_unsupported`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error. +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `ledger_unsupported`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error. ## See also diff --git a/ts/docs/commands/stake/delegate.md b/ts/docs/commands/stake/delegate.md index 979498634..671d4e9d4 100644 --- a/ts/docs/commands/stake/delegate.md +++ b/ts/docs/commands/stake/delegate.md @@ -7,7 +7,7 @@ Delegate resource to another address. ``` wallet-cli stake delegate --receiver
--amount-sun [--resource energy|bandwidth] [--lock [--lock-period ]] - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` ## Description @@ -18,7 +18,7 @@ Lends the resource backed by your staked TRX to another address — the receiver Check how much you can still delegate with [`stake delegated`](delegated.md) (`Max delegatable`). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation**; `--no-wait` returns at submission. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -34,7 +34,8 @@ Check how much you can still delegate with [`stake delegated`](delegated.md) (`M | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | Plus the [global options](../index.md#global-options-every-command). @@ -43,10 +44,10 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. -Default — returns the **submitted** receipt: +With `--no-wait` — returns the **submitted** receipt: ```bash -echo "$PW" | wallet-cli stake delegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx --amount-sun 1000000000 --resource energy --network nile --password-stdin +echo "$PW" | wallet-cli stake delegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx --amount-sun 1000000000 --resource energy --network nile --no-wait --password-stdin ``` ```console @@ -58,7 +59,7 @@ echo "$PW" | wallet-cli stake delegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDKtj ``` ```bash -echo "$PW" | wallet-cli stake delegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx --amount-sun 1000000000 --resource energy --network nile --password-stdin -o json +echo "$PW" | wallet-cli stake delegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx --amount-sun 1000000000 --resource energy --network nile --no-wait --password-stdin -o json ``` ```json @@ -71,12 +72,12 @@ echo "$PW" | wallet-cli stake delegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDKtj | Stage | Fields | |---|---| -| default (submit) | `kind: "stake-delegate"`, `stage: "submitted"`, `txId`, `amountSun` (string), `resource`, `receiver` | -| `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | +| `--no-wait` (submitted) | `kind: "stake-delegate"`, `stage: "submitted"`, `txId`, `amountSun` (string), `resource`, `receiver` | +| default or `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value` — receiver = owner, `--lock-period` without `--lock`). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value` — receiver = owner, `--lock-period` without `--lock`). ## See also diff --git a/ts/docs/commands/stake/delegated.md b/ts/docs/commands/stake/delegated.md index 9e8477d55..6fba8ec26 100644 --- a/ts/docs/commands/stake/delegated.md +++ b/ts/docs/commands/stake/delegated.md @@ -81,7 +81,7 @@ Delegations (1) |---|---|---| | `address` | string | Queried account | | `direction` | string | `out` / `in` | -| `canDelegateMaxSun` | object | `{energy, bandwidth}` SUN strings — max still delegatable (out only) | +| `canDelegateMaxSun` | object | `{energy, bandwidth}` SUN strings — max still delegatable (out only); with `--resource`, only that resource's key | | `delegations[]` | array | Each: `receiver` (out) or `from` (in), `resource`, `amountSun` (string), `lockedUntil` (epoch ms or `null`) | ## Exit status diff --git a/ts/docs/commands/stake/freeze.md b/ts/docs/commands/stake/freeze.md index dfdb982d4..84879d6f1 100644 --- a/ts/docs/commands/stake/freeze.md +++ b/ts/docs/commands/stake/freeze.md @@ -6,7 +6,7 @@ Stake TRX for energy/bandwidth. ``` wallet-cli stake freeze --amount-sun [--resource energy|bandwidth] - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` ## Description @@ -15,7 +15,7 @@ Stakes TRX from the active account's balance (Stake 2.0) in exchange for a stead Amount is in SUN (1 TRX = 1,000,000 SUN). Staked TRX stays yours; to get it back, [`stake unfreeze`](unfreeze.md) and, after the waiting period, [`stake withdraw`](withdraw.md). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation**; `--no-wait` returns at submission. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -28,7 +28,8 @@ Amount is in SUN (1 TRX = 1,000,000 SUN). Staked TRX stays yours; to get it back | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | Plus the [global options](../index.md#global-options-every-command). @@ -37,10 +38,10 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. -Default — stake 1,000 TRX for energy, returns the **submitted** receipt: +With `--no-wait` — stake 1,000 TRX for energy, returns the **submitted** receipt: ```bash -echo "$PW" | wallet-cli stake freeze --amount-sun 1000000000 --resource energy --network nile --password-stdin +echo "$PW" | wallet-cli stake freeze --amount-sun 1000000000 --resource energy --network nile --no-wait --password-stdin ``` ```console @@ -51,17 +52,17 @@ echo "$PW" | wallet-cli stake freeze --amount-sun 1000000000 --resource energy - ``` ```bash -echo "$PW" | wallet-cli stake freeze --amount-sun 1000000000 --resource energy --network nile --password-stdin -o json +echo "$PW" | wallet-cli stake freeze --amount-sun 1000000000 --resource energy --network nile --no-wait --password-stdin -o json ``` ```json {"schema":"wallet-cli.result.v1","success":true,"command":"stake.freeze","data":{"kind":"stake-freeze","stage":"submitted","txId":"c3d...","amountSun":"1000000000","resource":"energy"},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` -Add `--wait` to block until confirmed: +Without `--no-wait`, the command blocks until confirmed: ```bash -echo "$PW" | wallet-cli stake freeze --amount-sun 1000000000 --resource energy --network nile --wait --password-stdin +echo "$PW" | wallet-cli stake freeze --amount-sun 1000000000 --resource energy --network nile --password-stdin ``` ```console @@ -77,12 +78,12 @@ echo "$PW" | wallet-cli stake freeze --amount-sun 1000000000 --resource energy - | Stage | Fields | |---|---| -| default (submit) | `kind: "stake-freeze"`, `stage: "submitted"`, `txId`, `amountSun` (string), `resource` | -| `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | +| `--no-wait` (submitted) | `kind: "stake-freeze"`, `stage: "submitted"`, `txId`, `amountSun` (string), `resource` | +| default or `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error. +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`insufficient_balance` — amount exceeds balance; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error. ## See also diff --git a/ts/docs/commands/stake/info.md b/ts/docs/commands/stake/info.md index 741f60adf..1454c6958 100644 --- a/ts/docs/commands/stake/info.md +++ b/ts/docs/commands/stake/info.md @@ -14,7 +14,7 @@ One read-only screen of the account's staking state: staked amounts, voting powe Reading the fields: -- **Staked** — the parenthesis splits the staked TRX by resource direction (TRX staked toward energy vs. bandwidth) — *not* the resource units obtained. The actual allowances are the `Energy` / `Bandwidth` limits (dynamic, network-wide conversion). +- **Staked** — the parenthesis splits the staked TRX by resource direction (energy, bandwidth and TRON Power) — *not* the resource units obtained. The actual allowances are the `Energy` / `Bandwidth` limits (dynamic, network-wide conversion). - **Voting power** — same source as [`vote status`](../vote/status.md): 1 TP = 1 staked TRX. Shown here to bridge stake → vote. - **Unfreezing** — Stake 2.0 allows max **32 concurrent pending unstakes**; `N more allowed` is the chain's remaining count. When full, withdraw expired entries before unstaking more. - **Withdrawable** — what [`stake withdraw`](withdraw.md) would claim right now. @@ -46,7 +46,7 @@ wallet-cli stake info --account main --network nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"stake.info","data":{"address":"TP2Zs9qKScTMs8jDYV3SAHQ5pqgKY1NQ5V","staked":{"energySun":"1000000000","bandwidthSun":"500000000"},"votingPower":{"total":1500,"used":1000,"available":500},"resource":{"energy":{"used":12000,"limit":65000},"bandwidth":{"used":600,"limit":1500}},"unfreezing":[{"amountSun":"500000000","withdrawableAt":1784073600000},{"amountSun":"300000000","withdrawableAt":1784160000000}],"withdrawableSun":"0","unfreeze":{"used":2,"max":32,"remaining":30}},"meta":{"durationMs":22,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"stake.info","data":{"address":"TP2Zs9qKScTMs8jDYV3SAHQ5pqgKY1NQ5V","staked":{"energySun":"1000000000","bandwidthSun":"500000000","tronPowerSun":"0","totalSun":"1500000000"},"votingPower":{"total":1500,"used":1000,"available":500},"resource":{"energy":{"used":12000,"limit":65000},"bandwidth":{"used":600,"limit":1500}},"unfreezing":[{"amountSun":"500000000","withdrawableAt":1784073600000},{"amountSun":"300000000","withdrawableAt":1784160000000}],"withdrawableSun":"0","unfreeze":{"used":2,"max":32,"remaining":30}},"meta":{"durationMs":22,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` ## Output @@ -55,12 +55,16 @@ wallet-cli stake info --account main --network nile -o json |---|---|---| | `address` | string | Queried account | | `staked.energySun` / `.bandwidthSun` | string | Staked TRX by direction, in SUN (not resource units) | +| `staked.tronPowerSun` | string | Staked TRX that yields voting power only, in SUN; text shows it only when non-zero | +| `staked.totalSun` | string | Sum of the three, in SUN | | `votingPower.total` / `.used` / `.available` | number | TP total / spent / spendable | | `resource.energy` / `.bandwidth` | object | `{used, limit}` in resource units | | `unfreezing[]` | array | Pending unstakes: `{amountSun, withdrawableAt (epoch ms)}` | | `withdrawableSun` | string | Currently withdrawable TRX, in SUN | | `unfreeze` | object | Slot usage `{used, max, remaining}` | +Stake totals count `BANDWIDTH`, `ENERGY` and `TRON_POWER` entries; an omitted type means bandwidth, and unrecognized types are excluded. Malformed account quantities fail with `provider_error`. + ## Exit status `0` success · `1` execution failure (`rpc_error`) · `2` usage error (`invalid_value`). diff --git a/ts/docs/commands/stake/undelegate.md b/ts/docs/commands/stake/undelegate.md index 87c36ddf6..45174c439 100644 --- a/ts/docs/commands/stake/undelegate.md +++ b/ts/docs/commands/stake/undelegate.md @@ -7,7 +7,7 @@ Reclaim delegated resource. ``` wallet-cli stake undelegate --receiver
--amount-sun [--resource energy|bandwidth] - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` ## Description @@ -16,7 +16,7 @@ Takes back resource you previously delegated with [`stake delegate`](delegate.md Reclaiming is immediate (no waiting period — the TRX was staked all along, only the resource moves back). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation**; `--no-wait` returns at submission. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -30,7 +30,8 @@ Reclaiming is immediate (no waiting period — the TRX was staked all along, onl | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | Plus the [global options](../index.md#global-options-every-command). @@ -39,10 +40,10 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. -Default — returns the **submitted** receipt: +With `--no-wait` — returns the **submitted** receipt: ```bash -echo "$PW" | wallet-cli stake undelegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx --amount-sun 1000000000 --resource energy --network nile --password-stdin +echo "$PW" | wallet-cli stake undelegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx --amount-sun 1000000000 --resource energy --network nile --no-wait --password-stdin ``` ```console @@ -54,7 +55,7 @@ echo "$PW" | wallet-cli stake undelegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDK ``` ```bash -echo "$PW" | wallet-cli stake undelegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx --amount-sun 1000000000 --resource energy --network nile --password-stdin -o json +echo "$PW" | wallet-cli stake undelegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx --amount-sun 1000000000 --resource energy --network nile --no-wait --password-stdin -o json ``` ```json @@ -67,12 +68,12 @@ echo "$PW" | wallet-cli stake undelegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDK | Stage | Fields | |---|---| -| default (submit) | `kind: "stake-undelegate"`, `stage: "submitted"`, `txId`, `amountSun` (string), `resource`, `receiver` | -| `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | +| `--no-wait` (submitted) | `kind: "stake-undelegate"`, `stage: "submitted"`, `txId`, `amountSun` (string), `resource`, `receiver` | +| default or `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`insufficient_stake` — the amount is above what is delegated to the receiver for that resource, refused before signing in every mode including `--dry-run`; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`). ## See also diff --git a/ts/docs/commands/stake/unfreeze.md b/ts/docs/commands/stake/unfreeze.md index 1c90635ac..be7332351 100644 --- a/ts/docs/commands/stake/unfreeze.md +++ b/ts/docs/commands/stake/unfreeze.md @@ -6,7 +6,7 @@ Unstake TRX. ``` wallet-cli stake unfreeze --amount-sun [--resource energy|bandwidth] - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` ## Description @@ -15,7 +15,7 @@ Starts unstaking: the amount leaves the staked pool and enters a **redemption wa Stake 2.0 allows at most **32 pending unstakes** per account at a time; check remaining slots with [`stake info`](info.md). A pending unstake can be rolled back with [`stake cancel-unfreeze`](cancel-unfreeze.md). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation**; `--no-wait` returns at submission. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -28,7 +28,8 @@ Stake 2.0 allows at most **32 pending unstakes** per account at a time; check re | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | Plus the [global options](../index.md#global-options-every-command). @@ -37,10 +38,10 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. -Default — returns the **submitted** receipt: +With `--no-wait` — returns the **submitted** receipt: ```bash -echo "$PW" | wallet-cli stake unfreeze --amount-sun 1000000000 --resource energy --network nile --password-stdin +echo "$PW" | wallet-cli stake unfreeze --amount-sun 1000000000 --resource energy --network nile --no-wait --password-stdin ``` ```console @@ -51,17 +52,17 @@ echo "$PW" | wallet-cli stake unfreeze --amount-sun 1000000000 --resource energy ``` ```bash -echo "$PW" | wallet-cli stake unfreeze --amount-sun 1000000000 --resource energy --network nile --password-stdin -o json +echo "$PW" | wallet-cli stake unfreeze --amount-sun 1000000000 --resource energy --network nile --no-wait --password-stdin -o json ``` ```json {"schema":"wallet-cli.result.v1","success":true,"command":"stake.unfreeze","data":{"kind":"stake-unfreeze","stage":"submitted","txId":"d4e...","amountSun":"1000000000","resource":"energy"},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` -Add `--wait` to block until confirmed: +Without `--no-wait`, the command blocks until confirmed: ```bash -echo "$PW" | wallet-cli stake unfreeze --amount-sun 1000000000 --resource energy --network nile --wait --password-stdin +echo "$PW" | wallet-cli stake unfreeze --amount-sun 1000000000 --resource energy --network nile --password-stdin ``` ```console @@ -79,12 +80,12 @@ echo "$PW" | wallet-cli stake unfreeze --amount-sun 1000000000 --resource energy | Stage | Fields | |---|---| -| default (submit) | `kind: "stake-unfreeze"`, `stage: "submitted"`, `txId`, `amountSun` (string), `resource` | -| `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | +| `--no-wait` (submitted) | `kind: "stake-unfreeze"`, `stage: "submitted"`, `txId`, `amountSun` (string), `resource` | +| default or `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error. +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`insufficient_stake` — amount exceeds stake for this resource; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error. ## See also diff --git a/ts/docs/commands/stake/withdraw.md b/ts/docs/commands/stake/withdraw.md index dc247bc98..115ac840f 100644 --- a/ts/docs/commands/stake/withdraw.md +++ b/ts/docs/commands/stake/withdraw.md @@ -5,7 +5,7 @@ Withdraw expired unfrozen TRX. ## Synopsis ``` -wallet-cli stake withdraw [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] +wallet-cli stake withdraw [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -15,7 +15,7 @@ Claims every pending unstake whose waiting period has expired, moving the TRX ba Withdrawing also frees up unstake slots (max 32 pending unstakes per account). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation**; `--no-wait` returns at submission. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -26,7 +26,8 @@ Withdrawing also frees up unstake slots (max 32 pending unstakes per account). | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | Plus the [global options](../index.md#global-options-every-command). @@ -35,10 +36,10 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. -Default — returns the **submitted** receipt: +With `--no-wait` — returns the **submitted** receipt: ```bash -echo "$PW" | wallet-cli stake withdraw --network nile --password-stdin +echo "$PW" | wallet-cli stake withdraw --network nile --no-wait --password-stdin ``` ```console @@ -49,17 +50,17 @@ echo "$PW" | wallet-cli stake withdraw --network nile --password-stdin ``` ```bash -echo "$PW" | wallet-cli stake withdraw --network nile --password-stdin -o json +echo "$PW" | wallet-cli stake withdraw --network nile --no-wait --password-stdin -o json ``` ```json {"schema":"wallet-cli.result.v1","success":true,"command":"stake.withdraw","data":{"kind":"stake-withdraw","stage":"submitted","txId":"e5f..."},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` -Add `--wait` to block until confirmed: +Without `--no-wait`, the command blocks until confirmed: ```bash -echo "$PW" | wallet-cli stake withdraw --network nile --wait --password-stdin +echo "$PW" | wallet-cli stake withdraw --network nile --password-stdin ``` ```console @@ -75,12 +76,12 @@ echo "$PW" | wallet-cli stake withdraw --network nile --wait --password-stdin | Stage | Fields | |---|---| -| default (submit) | `kind: "stake-withdraw"`, `stage: "submitted"`, `txId` | -| `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | +| `--no-wait` (submitted) | `kind: "stake-withdraw"`, `stage: "submitted"`, `txId` | +| default or `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error. +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`nothing_to_withdraw` — no expired unstake to claim; `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error. ## See also diff --git a/ts/docs/commands/sunpump/buy.md b/ts/docs/commands/sunpump/buy.md index 2660bad38..0019967f8 100644 --- a/ts/docs/commands/sunpump/buy.md +++ b/ts/docs/commands/sunpump/buy.md @@ -6,7 +6,7 @@ Spend TRX to buy a token on its SunPump bonding curve. ``` wallet-cli sunpump buy
--trx - [--quote | --dry-run | --build-only | --wait [--wait-timeout ]] + [--quote | --dry-run | --build-only | --wait | --no-wait] [--slippage | --min-out ] [--fee-limit ] [options] ``` @@ -18,7 +18,7 @@ The TRX travels as the call's **value**, so **nothing is approved**: a buy is on Before anything is built, the account is checked: one the chain has no record of fails with `account_not_active`, one holding less than `--trx` with `insufficient_balance`. The energy fee is not part of that check. -`--quote` needs no account. `--dry-run` and `--build-only` need an account but no password, and work for a watch-only account. The default mode signs and sends, and returns at submission unless `--wait`. +`--quote` needs no account. `--dry-run` and `--build-only` need an account but no password, and work for a watch-only account. The default mode signs and sends, and waits for confirmation unless `--no-wait`. ## Options @@ -26,13 +26,14 @@ Before anything is built, the account is checked: one the chain has no record of |---|---| | `
` | **Required.** The SunPump token's contract address | | `--trx ` | **Required.** Whole TRX to spend, platform fee included | -| `--quote` | Price only — no account, no password, no transaction. Excludes `--dry-run`, `--build-only`, `--slippage`, `--min-out` and `--wait` | +| `--quote` | Price only — no account, no password, no transaction. Excludes `--dry-run`, `--build-only`, `--slippage`, `--min-out`, `--wait` and `--no-wait` | | `--slippage ` | Tolerance, e.g. `0.05`; **default `0.05`** (5%). Excludes `--min-out` | | `--min-out ` | Least to accept, in the token's smallest unit. Excludes `--slippage` | | `--fee-limit ` | Maximum energy fee to burn, in SUN; default `100000000`. The estimate is a lower bound | | `--dry-run` | Validate and estimate only — no password, no signature, no broadcast | | `--build-only` | Emit the unsigned transaction | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--account ` | Account to buy with; default the active account. Not used by `--quote` | | `--password-stdin` | Master password from stdin; needed only when sending | @@ -66,7 +67,7 @@ One TRX pays the **0.01 TRX minimum** fee — the fee is 1% only above 1 TRX. Buying with 1 TRX. A buy is one transaction; `Received` is what actually arrived: ```bash -echo "$PW" | wallet-cli sunpump buy TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --trx 1 --wait --password-stdin --account lp --network tron +echo "$PW" | wallet-cli sunpump buy TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --trx 1 --password-stdin --account lp --network tron ``` ```console @@ -85,7 +86,7 @@ echo "$PW" | wallet-cli sunpump buy TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --trx 1 - ``` ```bash -echo "$PW" | wallet-cli sunpump buy TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --trx 1 --wait --password-stdin --account lp --network tron -o json +echo "$PW" | wallet-cli sunpump buy TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --trx 1 --password-stdin --account lp --network tron -o json ``` ```json @@ -100,18 +101,20 @@ echo "$PW" | wallet-cli sunpump buy TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --trx 1 - |---|---|---| | `tokenAddress` / `tokenSymbol` / `tokenDecimals` | — | The token, read from the contract | | `trxIn` | string | SUN spent, platform fee included | -| `platformFee` / `platformFeePercent` | string | SunPump's fee in SUN, and the rate it worked out to (only when the 0.01 TRX minimum pushed it above 1%) | +| `platformFee` / `platformFeePercent` / `platformFeeMinimum` | string | SunPump's fee in SUN, and the rate it worked out to with the minimum fee in SUN (the last two only when the 0.01 TRX minimum pushed it above 1%) | | `tokensOutExpected` | string | Base units the curve quoted | | `tokensOutMinimum` / `slippage` | string | The enforced floor and the tolerance it came from; absent from a quote | | `account` | string | The buying address; absent from a quote | | `fee` / `feeCovers` | — | The chain's energy estimate, separate from `platformFee`; always `feeCovers: "all"` | | `tokensOut` | string | Confirmed receipts only: the tokens that actually arrived, read from the transaction (`amountsEstimated: false`). If the receipt could not be read, `tokensOutExpected` stands, `amountsEstimated` is `true` and a warning says why | -The default mode returns at submission (`stage: "submitted"`, `txId`); `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`. +The command waits for confirmation by default and reports `stage: "confirmed"` (or `"failed"`), `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`; with `--no-wait` it returns at submission (`stage: "submitted"`, `txId`). + +A non-integer transaction call value built by the SunPump SDK fails with `provider_error`. ## Exit status -`0` success (submitted, or quoted/built/estimated) · `1` execution failure (`launchpad_token_not_found`, `launchpad_trading_closed`, `account_not_active`, `insufficient_balance`, `slippage_exceeded`, `watch_only_no_signer`, `auth_failed`, `transaction_rejected`, `provider_error`, `timeout`) · `2` usage error (`missing_option` — no `
` or `--trx`; `invalid_option` — `--quote` with another mode, `--slippage`, `--min-out` or `--wait`, or `--slippage` with `--min-out`; `invalid_amount`; `invalid_value` — a `--slippage` out of range; `invalid_address`; `account_not_found`; `unsupported_network_capability`; `family_mismatch`). +`0` success (submitted, or quoted/built/estimated) · `1` execution failure (`launchpad_token_not_found`, `launchpad_trading_closed`, `account_not_active`, `insufficient_balance`, `slippage_exceeded`, `watch_only_no_signer`, `auth_failed`, `transaction_rejected`, `provider_error`, `timeout`) · `2` usage error (`missing_option` — no `
` or `--trx`; `invalid_option` — `--quote` with another mode, `--slippage`, `--min-out`, `--wait` or `--no-wait`, or `--slippage` with `--min-out`; `invalid_amount`; `invalid_value` — a `--slippage` out of range; `invalid_address`; `account_not_found`; `unsupported_network_capability`; `family_mismatch`). ## See also diff --git a/ts/docs/commands/sunpump/index.md b/ts/docs/commands/sunpump/index.md index 698686498..2b2faa796 100644 --- a/ts/docs/commands/sunpump/index.md +++ b/ts/docs/commands/sunpump/index.md @@ -46,7 +46,7 @@ The two closed states get different messages: one is a wait, the other is a redi ## The platform fee is not the chain's fee -SunPump charges **1% of the TRX, with a 0.01 TRX minimum**, on both sides. The minimum is what matters: a small trade pays far more than 1%. When it does, the `--quote` and `--dry-run` output warn with the rate it actually worked out to, and JSON carries it as `platformFeePercent`: +SunPump charges **1% of the TRX, with a 0.01 TRX minimum**, on both sides. The minimum is what matters: a small trade pays far more than 1%. When it does, the `--quote` and `--dry-run` output warn with the rate it actually worked out to, and JSON carries it as `platformFeePercent`, with the minimum in SUN as `platformFeeMinimum`: ``` ⚠️ Platform fee is 25.38% of this sell (0.01 TRX minimum). @@ -60,7 +60,7 @@ Default slippage is **5%**, ten times [`sunswap swap`](../sunswap/swap.md)'s 0.5 ## `--quote` costs nothing -`--quote` prices the trade from contract reads alone: **no account, no password, no transaction**. It publishes **no minimum and no slippage**, and refuses `--slippage`, `--min-out` and `--wait` — a quote sends no transaction, so a floor would never be enforced. +`--quote` prices the trade from contract reads alone: **no account, no password, no transaction**. It publishes **no minimum and no slippage**, and refuses `--slippage`, `--min-out`, `--wait` and `--no-wait` — a quote sends no transaction, so a floor would never be enforced. **Ledger** accounts need **Custom contracts** and **Sign by Hash** allowed in the TRON app — see [TRON app settings](../../guide/ledger.md#tron-app-settings). diff --git a/ts/docs/commands/sunpump/launch.md b/ts/docs/commands/sunpump/launch.md index 99fe4330e..39f039442 100644 --- a/ts/docs/commands/sunpump/launch.md +++ b/ts/docs/commands/sunpump/launch.md @@ -23,7 +23,7 @@ The token starts in status `CREATED`, on the curve. It moves to SunSwap only onc **A launch is real and permanent.** There is no undo and no testnet: the group is mainnet only. Use `--dry-run` first. -**A launch that does not answer may still have happened.** SunPump deploys the token before it replies, so once the request may have reached it, every failure short of SunPump saying no — no reply within `--timeout`, a 5xx, a dropped connection, a reply that does not parse or names no token — fails with `launch_outcome_unknown` rather than `timeout` or `provider_error`. The token may exist. Run `sunpump token-search ` and check for it before you launch again: a second launch is a second, permanent token. +**A launch that does not answer may still have happened.** SunPump deploys the token before it replies, so once the request may have reached it, every failure short of SunPump saying no — no reply within `--timeout`, a 5xx, a dropped connection, a reply that does not parse or names no token — fails with `launch_outcome_unknown` rather than `timeout` or `provider_error`, with [`error.details.outcome: "unknown"`](../../machine-interface.md#unknown-outcome-errordetailsoutcome). The token may exist. Run `sunpump token-search ` and check for it before you launch again: a second launch is a second, permanent token. `--dry-run` sends nothing to the create endpoint. It validates the options, reads the logo file, and prints the request that would be made. Because the creator is not known until the service picks one, the preview shows the fact instead of an address. @@ -43,7 +43,7 @@ The token starts in status `CREATED`, on the curve. It moves to SunSwap only onc A logo is optional; without one the token is created without one, and the dry run says so. An option left out is omitted from the request, not sent empty. -Plus the [global options](../index.md#global-options-every-command). No `--account`, `--wait` or `--wait-timeout`: nothing is signed locally and no transaction comes back to wait for, so they are refused with `invalid_option`. +Plus the [global options](../index.md#global-options-every-command). No `--account`, `--wait`, or `--no-wait`: nothing is signed locally and no transaction comes back to wait for, so they are refused with `invalid_option`. ## Examples @@ -88,7 +88,7 @@ The token can be read back with [`sunpump token-info`](token-info.md), which sho ## Exit status -`0` success · `1` execution failure (`provider_error` — the service refused the launch, in its reply or with an HTTP 4xx, including a name or symbol it would not take; `launch_outcome_unknown` — any other failure once the request may have reached the service, so the token may exist; `provider_rate_limited`) · `2` usage error (`missing_option`; `invalid_option` — `--account`, `--wait`, or both `--image` and `--image-base64`; `file_not_found`; `invalid_value` — a URL that does not start with `http://` or `https://`, or an image file that cannot be read; `unsupported_network_capability` off mainnet; `family_mismatch` on an EVM network). +`0` success · `1` execution failure (`provider_error` — the service refused the launch, in its reply or with an HTTP 4xx, including a name or symbol it would not take; `launch_outcome_unknown` — any other failure once the request may have reached the service, so the token may exist; `provider_rate_limited`; `timeout` — the time limit ran out before the request was sent) · `2` usage error (`missing_option`; `invalid_option` — `--account`, `--wait`, `--no-wait`, or both `--image` and `--image-base64`; `file_not_found`; `invalid_value` — a URL that does not start with `http://` or `https://`, or an image file that cannot be read; `unsupported_network_capability` off mainnet; `family_mismatch` on an EVM network). ## See also diff --git a/ts/docs/commands/sunpump/sell.md b/ts/docs/commands/sunpump/sell.md index e00150e1a..487da4331 100644 --- a/ts/docs/commands/sunpump/sell.md +++ b/ts/docs/commands/sunpump/sell.md @@ -6,7 +6,7 @@ Sell a token back to its SunPump bonding curve for TRX. ``` wallet-cli sunpump sell
--amount - [--quote | --dry-run | --build-only | --wait [--wait-timeout ]] + [--quote | --dry-run | --build-only | --wait | --no-wait] [--slippage | --min-out ] [--fee-limit ] [options] ``` @@ -37,13 +37,14 @@ Before anything is approved, the account is checked: one the chain has no record |---|---| | `
` | **Required.** The SunPump token's contract address | | `--amount ` | **Required.** Whole tokens to sell | -| `--quote` | Price only — no account, no password, no transaction. Excludes `--dry-run`, `--build-only`, `--slippage`, `--min-out` and `--wait` | +| `--quote` | Price only — no account, no password, no transaction. Excludes `--dry-run`, `--build-only`, `--slippage`, `--min-out`, `--wait` and `--no-wait` | | `--slippage ` | Tolerance, e.g. `0.05`; **default `0.05`** (5%). Excludes `--min-out` | | `--min-out ` | Least TRX to accept, in SUN. Excludes `--slippage` | | `--fee-limit ` | Maximum energy fee to burn, in SUN; default `100000000` | | `--dry-run` | Validate and estimate only — no password, no signature, no broadcast | | `--build-only` | Emit the unsigned transaction(s) in execution order — the approval first when one is needed | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--account ` | Account to sell from; default the active account. Not used by `--quote` | | `--password-stdin` | Master password from stdin; needed only when sending | @@ -71,7 +72,7 @@ wallet-cli sunpump sell TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --amount 1000 --quote ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"sunpump.sell","data":{"kind":"sunpump-sell","tokenAddress":"TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E","tokenSymbol":"Justin","tokenDecimals":18,"platformFee":"10000","platformFeePercent":"25.38","tokensIn":"1000000000000000000000","mode":"quote","trxOutExpected":"29401"},"meta":{"durationMs":2395,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"sunpump.sell","data":{"kind":"sunpump-sell","tokenAddress":"TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E","tokenSymbol":"Justin","tokenDecimals":18,"platformFee":"10000","platformFeePercent":"25.38","platformFeeMinimum":"10000","tokensIn":"1000000000000000000000","mode":"quote","trxOutExpected":"29401"},"meta":{"durationMs":2395,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} ``` The point of the warning: 1,000 tokens gross 0.039401 TRX, the fee floor takes 0.01 of it, and a quarter of the sale goes to the platform. A sale large enough that the fee is the plain 1% shows no rate and no warning. @@ -79,7 +80,7 @@ The point of the warning: 1,000 tokens gross 0.039401 TRX, the fee floor takes 0 Selling 1,000 Justin. As the first sale of this token from this account, the unlimited approval went first (`Approval tx`), then the sale: ```bash -echo "$PW" | wallet-cli sunpump sell TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --amount 1000 --wait --password-stdin --account jh --network tron +echo "$PW" | wallet-cli sunpump sell TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --amount 1000 --password-stdin --account jh --network tron ``` ```console @@ -99,11 +100,11 @@ echo "$PW" | wallet-cli sunpump sell TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --amount ``` ```bash -echo "$PW" | wallet-cli sunpump sell TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --amount 1000 --wait --password-stdin --account jh --network tron -o json +echo "$PW" | wallet-cli sunpump sell TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --amount 1000 --password-stdin --account jh --network tron -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"sunpump.sell","data":{"kind":"sunpump-sell","tokenAddress":"TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E","tokenSymbol":"Justin","tokenDecimals":18,"slippage":"0.05","platformFee":"10000","platformFeePercent":"25.38","tokensIn":"1000000000000000000000","account":"TANkYWRT6NZ2YLtWkFfKYBEGJroMa6MTwn","trxOutExpected":"29401","trxOutMinimum":"27930","approvalTxIds":["9e4b1c7a2d5f8e03b6c9a4d1f7e2b5c8a0d3f6e9b2c5a8d1e4f7b0c3a6d9e2f5"],"stage":"confirmed","txId":"c5f2a8d1e7b40c9f3a6d2e8b5c1f7a04d9e3b6c2a8f5d1e7c4b0a9f3e6d2c8b1","confirmed":true,"blockNumber":86922988,"feeSun":7528400,"energyUsed":71834,"energyFeeSun":7183400,"netFeeSun":345000,"result":"SUCCESS","failed":false,"amountsEstimated":false,"trxOut":"29401"},"meta":{"durationMs":19841,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"sunpump.sell","data":{"kind":"sunpump-sell","tokenAddress":"TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E","tokenSymbol":"Justin","tokenDecimals":18,"slippage":"0.05","platformFee":"10000","platformFeePercent":"25.38","platformFeeMinimum":"10000","tokensIn":"1000000000000000000000","account":"TANkYWRT6NZ2YLtWkFfKYBEGJroMa6MTwn","trxOutExpected":"29401","trxOutMinimum":"27930","approvalTxIds":["9e4b1c7a2d5f8e03b6c9a4d1f7e2b5c8a0d3f6e9b2c5a8d1e4f7b0c3a6d9e2f5"],"stage":"confirmed","txId":"c5f2a8d1e7b40c9f3a6d2e8b5c1f7a04d9e3b6c2a8f5d1e7c4b0a9f3e6d2c8b1","confirmed":true,"blockNumber":86922988,"feeSun":7528400,"energyUsed":71834,"energyFeeSun":7183400,"netFeeSun":345000,"result":"SUCCESS","failed":false,"amountsEstimated":false,"trxOut":"29401"},"meta":{"durationMs":19841,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} ``` ## Output @@ -116,18 +117,20 @@ echo "$PW" | wallet-cli sunpump sell TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E --amount | `tokensIn` | string | Base units sold | | `trxOutExpected` | string | SUN **net** of the platform fee — what arrives | | `trxOutMinimum` / `slippage` | string | The floor on that net figure and the tolerance it came from; absent from a quote | -| `platformFee` / `platformFeePercent` | string | SunPump's fee in SUN, and its share of the gross (`trxOutExpected + platformFee`) — only when the 0.01 TRX minimum pushed it above 1% | +| `platformFee` / `platformFeePercent` / `platformFeeMinimum` | string | SunPump's fee in SUN, and its share of the gross (`trxOutExpected + platformFee`) with the minimum fee in SUN — the last two only when the 0.01 TRX minimum pushed it above 1% | | `account` | string | The selling address; absent from a quote | -| `approvals[]` | array | The unlimited grant (`amount: "unlimited"`) with its spender, when one is needed | +| `approvals[]` | array | The unlimited grant (`amount: "unlimited"`, `isUnlimited: true`) with its spender, when one is needed | | `fee` / `feeCovers` | — | The chain's energy estimate and what it covers (`approvals` while the grant is pending, else `all`) | | `approvalTxIds[]` | string[] | Once sent: the approval, beside the sale's own `txId` | | `trxOut` | string | Confirmed receipts only: the TRX that actually arrived, read from the transaction (`amountsEstimated: false`); otherwise `trxOutExpected` stands with `amountsEstimated: true` and a warning | -The default mode returns at submission (`stage: "submitted"`, `txId`); `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`. +The command waits for confirmation by default and reports `stage: "confirmed"` (or `"failed"`), `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`; with `--no-wait` it returns at submission (`stage: "submitted"`, `txId`). + +A non-integer transaction call value built by the SunPump SDK fails with `provider_error`. ## Exit status -`0` success (submitted, or quoted/built/estimated) · `1` execution failure (`launchpad_token_not_found`, `launchpad_trading_closed`, `account_not_active`, `insufficient_token_balance`, `slippage_exceeded`, `watch_only_no_signer`, `auth_failed`, `transaction_rejected`, `provider_error`, `timeout`) · `2` usage error (`missing_option` — no `
` or `--amount`; `invalid_option` — `--quote` with another mode, `--slippage`, `--min-out` or `--wait`, or `--slippage` with `--min-out`; `invalid_amount` — not a positive number, or a sale too small to pay anything; `invalid_value` — a `--slippage` out of range; `invalid_address`; `account_not_found`; `unsupported_network_capability`; `family_mismatch`). +`0` success (submitted, or quoted/built/estimated) · `1` execution failure (`launchpad_token_not_found`, `launchpad_trading_closed`, `account_not_active`, `insufficient_token_balance`, `slippage_exceeded`, `watch_only_no_signer`, `auth_failed`, `transaction_rejected`, `provider_error`, `timeout`) · `2` usage error (`missing_option` — no `
` or `--amount`; `invalid_option` — `--quote` with another mode, `--slippage`, `--min-out`, `--wait` or `--no-wait`, or `--slippage` with `--min-out`; `invalid_amount` — not a positive number, or a sale too small to pay anything; `invalid_value` — a `--slippage` out of range; `invalid_address`; `account_not_found`; `unsupported_network_capability`; `family_mismatch`). ## See also diff --git a/ts/docs/commands/sunpump/token-list.md b/ts/docs/commands/sunpump/token-list.md index 8058ced07..0addf0922 100644 --- a/ts/docs/commands/sunpump/token-list.md +++ b/ts/docs/commands/sunpump/token-list.md @@ -6,7 +6,7 @@ List tokens on the SunPump launchpad, ranked, or filtered to one creator. ``` wallet-cli sunpump token-list [--order-by ] [--sort ] - [--owner
| --contract
] + [--owner
] [--contract
] [--limit ] [--offset ] ``` diff --git a/ts/docs/commands/sunswap/add-liquidity.md b/ts/docs/commands/sunswap/add-liquidity.md index 643ed4c8f..cf01f259e 100644 --- a/ts/docs/commands/sunswap/add-liquidity.md +++ b/ts/docs/commands/sunswap/add-liquidity.md @@ -12,7 +12,7 @@ wallet-cli sunswap add-liquidity --protocol [--tick-spacing ] [--hooks
] [--slippage ] [--create-pool --sqrt-price ] [--recipient
] [--deadline ] [--fee-limit ] - [--dry-run | --build-only | --wait [--wait-timeout ]] [options] + [--dry-run | --build-only | --wait | --no-wait] [options] ``` ## Description @@ -27,7 +27,7 @@ Works on **`tron` and `nile`**; on Shasta it fails with `unsupported_network_cap **Approvals come first.** On V2 and V3, each token side is approved for **exactly the amount this deposit needs**; the approval is sent, confirmed and re-read before the deposit follows. The router consumes it, so a repeat deposit needs a fresh one. V4 is different — see [Approvals on V4](#approvals-on-v4). A TRX side never needs approving. If an approval went out and a later step failed, its ID is in `error.details.approvalTxIds` and the approval stays on chain. -**A new position's id exists only in the confirmed receipt.** The contract assigns it during execution, so pass `--wait` to learn it ([`position-list`](position-list.md) is mainnet only). Once you have it, [`position-info`](position-info.md) reads the position on either network. +**A new position's id exists only in the confirmed receipt.** The contract assigns it during execution, so it is reported unless you pass `--no-wait` ([`position-list`](position-list.md) is mainnet only). Once you have it, [`position-info`](position-info.md) reads the position on either network. **A V3 deposit that rounds to zero liquidity is refused.** The command checks the computed liquidity, not the pool's trading history, and fails with `invalid_value` before estimating or sending. At the minimum or maximum representable tick, the message identifies the pool's price-bound state; otherwise it asks for a wider range or a larger deposit. @@ -87,7 +87,8 @@ Because the deposit carries its signed grant, **`--build-only` is refused** on a | `--fee-limit ` | Maximum energy fee to burn, in SUN; default `100000000`. The estimate is a lower bound, so a limit set from it can fail | | `--dry-run` | Validate and estimate only — no password, no signature, no broadcast | | `--build-only` | Emit the unsigned transactions in execution order (`data.transactions[]` as `{purpose, tx, hex}` when there are approvals); they share a one-hour lifetime. Send each approval and wait for it to confirm before the deposit | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--account ` | Account to deposit from; default the active account | | `--password-stdin` | Master password from stdin; needed only by the modes that sign | @@ -101,7 +102,7 @@ In the examples, `$PW` is your master password, fed on stdin via `--password-std ```bash echo "$PW" | wallet-cli sunswap add-liquidity --protocol V2 --token0 USDT --token1 TRX --amount0 1 \ - --wait --password-stdin --network nile + --password-stdin --network nile ``` ```console @@ -122,7 +123,7 @@ echo "$PW" | wallet-cli sunswap add-liquidity --protocol V2 --token0 USDT --toke ```bash echo "$PW" | wallet-cli sunswap add-liquidity --protocol V2 --token0 USDT --token1 TRX --amount0 1 \ - --wait --password-stdin --network nile -o json + --password-stdin --network nile -o json ``` ```json @@ -135,7 +136,7 @@ echo "$PW" | wallet-cli sunswap add-liquidity --protocol V2 --token0 USDT --toke ```bash echo "$PW" | wallet-cli sunswap add-liquidity --protocol V3 --token0 USDT --token1 WTRX --fee 500 --amount0 1 \ - --wait --password-stdin --network nile + --password-stdin --network nile ``` ```console @@ -159,7 +160,7 @@ echo "$PW" | wallet-cli sunswap add-liquidity --protocol V3 --token0 USDT --toke ```bash echo "$PW" | wallet-cli sunswap add-liquidity --protocol V3 --token0 USDT --token1 WTRX --fee 500 --amount0 1 \ - --wait --password-stdin --network nile -o json + --password-stdin --network nile -o json ``` ```json @@ -170,7 +171,7 @@ Adding to position #701 — the pair, fee and range come from the position. The ```bash echo "$PW" | wallet-cli sunswap add-liquidity --protocol V3 --position-id 701 --amount0 0.5 \ - --wait --password-stdin --network nile + --password-stdin --network nile ``` ```console @@ -196,7 +197,7 @@ echo "$PW" | wallet-cli sunswap add-liquidity --protocol V3 --position-id 701 -- ```bash echo "$PW" | wallet-cli sunswap add-liquidity --protocol V4 --token0 TRX --token1 USDT --fee 500 --tick-spacing 10 \ - --amount0 1 --wait --password-stdin --network nile + --amount0 1 --password-stdin --network nile ``` ```console @@ -221,7 +222,7 @@ echo "$PW" | wallet-cli sunswap add-liquidity --protocol V4 --token0 TRX --token ```bash echo "$PW" | wallet-cli sunswap add-liquidity --protocol V4 --token0 TRX --token1 USDT --fee 500 --tick-spacing 10 \ - --amount0 1 --wait --password-stdin --network nile -o json + --amount0 1 --password-stdin --network nile -o json ``` ```json @@ -234,7 +235,7 @@ Adding to V4 position #188 with a 1% ceiling. The tokens are checked against the ```bash echo "$PW" | wallet-cli sunswap add-liquidity --protocol V4 --position-id 188 --token0 TRX --token1 USDT \ - --amount0 1 --slippage 0.01 --wait --password-stdin --network nile + --amount0 1 --slippage 0.01 --password-stdin --network nile ``` ```console @@ -266,7 +267,7 @@ echo "$PW" | wallet-cli sunswap add-liquidity --protocol V4 --position-id 188 -- | `mode` | string | `dry-run` or `build-only`; absent once sent (`stage` instead) | | `account` / `recipient` / `deadline` | string / string / number | Who deposits, who receives, and the deadline (Unix seconds) | | `protocol` | string | `V2`, `V3` or `V4` | -| `token0` / `token1` | object | `{address, symbol, decimals, amount}` in base units, plus `amountMinimum` (V2 / V3) | +| `token0` / `token1` | object | `{address, symbol, decimals, amount}` in base units, plus `amountMinimum` (V2 / V3) and `acceptsAnyAmount: true` on a side whose `amountMinimum` is `0` | | `router` (V2) / `positionManager` (V3, V4) | string | The contract the deposit goes through | | `lpAmountExpected` → `lpAmount`, `lpDecimals` | string / number | V2: LP tokens expected, then received | | `pool`, `reservesAfter` | string / object | V2, once confirmed: the pair contract, and its reserves read back after the deposit | @@ -276,13 +277,13 @@ echo "$PW" | wallet-cli sunswap add-liquidity --protocol V4 --position-id 188 -- | `poolId`, `tickSpacing`, `hooks` | string / number / string | V4: the pool key and the id it hashes to | | `amount0Max` / `amount1Max`, `nativeLocked` | string | V4: the ceiling (only when `--slippage` raised it) and the TRX locked as the call's value | | `poolCreated`, `initialSqrtPriceX96` | boolean / string | V4 with `--create-pool` | -| `approvals[]` | array | `{token, symbol, decimals, spender, amount, currentAllowance}` — the approvals to be sent first; `amount` is `"unlimited"` on V4 | +| `approvals[]` | array | `{token, symbol, decimals, spender, amount, isUnlimited, currentAllowance}` — the approvals to be sent first; on V4 `amount` is `"unlimited"` and `isUnlimited` is `true` | | `permits[]` | array | V4: `{token, amount, expiration}` — the Permit2 grants the deposit will sign | | `fee` / `feeCovers` / `feeUnavailableReason` | — | The estimate and what it covers (`all`, `approvals` or `none`) | | `approvalTxIds[]` | string[] | Once sent: the approvals, in the order sent, beside the deposit's own `txId` | | `amountsEstimated` | boolean | Once sent: `false` when amounts were read from this transaction's receipt, `true` when they are still the pre-transaction estimate (text then labels them `(est)`) | -`fee` is always the estimated network cost; the V3 / V4 pool tier is the separate key `feeTier`. The default mode returns at submission (`stage: "submitted"`, `txId`); `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`. +`fee` is always the estimated network cost; the V3 / V4 pool tier is the separate key `feeTier`. The command waits for confirmation by default and reports `stage: "confirmed"` (or `"failed"`), `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`; with `--no-wait` it returns at submission (`stage: "submitted"`, `txId`). ## Exit status diff --git a/ts/docs/commands/sunswap/collect-fees.md b/ts/docs/commands/sunswap/collect-fees.md index 084e451af..c33f3b9d9 100644 --- a/ts/docs/commands/sunswap/collect-fees.md +++ b/ts/docs/commands/sunswap/collect-fees.md @@ -9,7 +9,7 @@ wallet-cli sunswap collect-fees --protocol --position-id [--recipient
] [--token0 --token1 [--fee ]] [--deadline ] [--fee-limit ] - [--dry-run | --build-only | --wait [--wait-timeout ]] [options] + [--dry-run | --build-only | --wait | --no-wait] [options] ``` ## Description @@ -41,7 +41,8 @@ Ownership is checked in the dry run **and again immediately before sending**. `- | `--fee-limit ` | Maximum energy fee to burn, in SUN; default `100000000` | | `--dry-run` | Validate and estimate only — no password, no signature, no broadcast | | `--build-only` | Emit the unsigned transaction | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--account ` | Account holding the position; default the active account | | `--password-stdin` | Master password from stdin; needed only by the modes that sign | @@ -54,7 +55,7 @@ In the examples, `$PW` is your master password, fed on stdin via `--password-std Collecting everything position #690 is owed. `Collected` is what the transaction's `Collect` event says arrived; `tokensAuto: true` in JSON means the pair was read from the position, not given: ```bash -echo "$PW" | wallet-cli sunswap collect-fees --protocol V3 --position-id 690 --wait --password-stdin --account led --network nile +echo "$PW" | wallet-cli sunswap collect-fees --protocol V3 --position-id 690 --password-stdin --account led --network nile ``` ```console @@ -72,7 +73,7 @@ echo "$PW" | wallet-cli sunswap collect-fees --protocol V3 --position-id 690 --w ``` ```bash -echo "$PW" | wallet-cli sunswap collect-fees --protocol V3 --position-id 690 --wait --password-stdin --account led --network nile -o json +echo "$PW" | wallet-cli sunswap collect-fees --protocol V3 --position-id 690 --password-stdin --account led --network nile -o json ``` ```json @@ -93,11 +94,11 @@ echo "$PW" | wallet-cli sunswap collect-fees --protocol V3 --position-id 690 --w | `poolId`, `feeTier`, `tickSpacing`, `hooks` | — | V4: the position's pool key | | `fee` / `feeCovers` | — | The estimated network cost; always `feeCovers: "all"`, since nothing is approved | -The default mode returns at submission (`stage: "submitted"`, `txId`); `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`. +The command waits for confirmation by default and reports `stage: "confirmed"` (or `"failed"`), `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`; with `--no-wait` it returns at submission (`stage: "submitted"`, `txId`). ## Exit status -`0` success (submitted, or built/estimated) · `1` execution failure (`position_not_found`, `watch_only_no_signer`, `auth_failed`, `transaction_rejected`, `tx_expired`) · `2` usage error (`missing_option` — no `--protocol` or `--position-id`; `invalid_value` — `V2` or another protocol, a position owed nothing, a position held by another account, a pair or fee that does not match the position; `invalid_option` — a flag outside its protocol, such as `--recipient` on V4; `invalid_address`; `unsupported_network_capability`; `family_mismatch`). +`0` success (submitted, or built/estimated) · `1` execution failure (`position_not_found`, `watch_only_no_signer`, `auth_failed`, `transaction_rejected`, `tx_expired`) · `2` usage error (`missing_option` — no `--protocol` or `--position-id`, only one of `--token0` / `--token1`, or `--fee` without them; `invalid_value` — `V2` or another protocol, a position owed nothing, a position held by another account, a pair or fee that does not match the position; `invalid_option` — a flag outside its protocol, such as `--recipient` on V4; `invalid_address`; `unsupported_network_capability`; `family_mismatch`). ## See also diff --git a/ts/docs/commands/sunswap/index.md b/ts/docs/commands/sunswap/index.md index 0c4d6d81f..ac4551ecd 100644 --- a/ts/docs/commands/sunswap/index.md +++ b/ts/docs/commands/sunswap/index.md @@ -60,7 +60,7 @@ wallet-cli sunswap COMMAND **Most approvals are exact, and are consumed.** On V2 and V3, each approval is for exactly the amount the call needs, and the contract spends it, so the next call of the same shape needs a fresh one. A router swap that spends a token approves exactly the trade too. The exception is a **V4 deposit**: the token's allowance to Permit2 is **unlimited**, while each Permit2 grant it signs is exact and lasts one hour. The dry run shows which you are getting (`Allowance unlimited`). [`sunpump sell`](../sunpump/sell.md) also grants an unlimited allowance. -**A command may send several transactions.** Approvals are sent and confirmed before the call that spends them — always, even without `--wait`; each approval waits up to `--wait-timeout`, so several can lengthen the run. If an approval does not confirm in time the command stops with `timeout` before anything depends on it. If a later step fails, the approvals stay on chain and their IDs are listed in `error.details.approvalTxIds` (and in the text error); check them before retrying. Re-running is safe: an allowance that already suffices is not approved again. +**A command may send several transactions.** Approvals are sent and confirmed before the call that spends them — always, also with `--no-wait`. The approvals and the main call share the command's `--timeout`. If an approval does not confirm in time the command stops with `timeout` before anything depends on it. If a later step fails, the approvals stay on chain and their IDs are listed in `error.details.approvalTxIds` (and in the text error); check them before retrying. Re-running is safe: an allowance that already suffices is not approved again. **A dry run before an approval prices the approval only.** The deposit, withdrawal or swap itself cannot be simulated until its allowance is on chain, so the dry run says `Fee (est, approvals only)` and JSON carries `feeCovers: "approvals"`. See [fee is an estimate](../../machine-interface.md#fee-is-an-estimate-and-feecovers-says-what-it-covers). diff --git a/ts/docs/commands/sunswap/pool-list.md b/ts/docs/commands/sunswap/pool-list.md index d7a5b63e9..a041cd863 100644 --- a/ts/docs/commands/sunswap/pool-list.md +++ b/ts/docs/commands/sunswap/pool-list.md @@ -47,7 +47,7 @@ The service exposes only the first 1000 rows of any ordering, so a scan never re - **Any other order or direction** — the scan reads that order until the window is filled or the pools run out. Exact. If it reaches row 1000 first, the CLI reads the complete qualifying set by TVL instead and sorts it locally (exact decimal comparison, ties by `poolAddress`). Exact as long as that set fits in 1000 rows, which holds for any threshold above the TVL of the 1000th pool. - **More than 1000 pools qualify** and the requested order did not fill the window within its first 1000 rows: the complete set is unreachable. The pools that were found are returned, `hasMore` is `true`, and `meta.warnings` carries `{code: "sunswap_scan_truncated"}` (text mode prints it on stderr). Raise `--min-tvl` or use the default order for an exact answer. -`meta.pagination.total` stays `null`. `hasMore` is always present with `--min-tvl`: `true` when a qualifying pool beyond the window was seen, or when the scan was truncated. A larger threshold or a sparser order means more pages, each bound by `--timeout`. +`meta.pagination.total` stays `null`. `hasMore` is always present with `--min-tvl`: `true` when a qualifying pool beyond the window was seen, or when the scan was truncated. A larger threshold or a sparser order means more pages, all within the command's `--timeout`. ## Examples diff --git a/ts/docs/commands/sunswap/price.md b/ts/docs/commands/sunswap/price.md index 694eb2a79..10282c849 100644 --- a/ts/docs/commands/sunswap/price.md +++ b/ts/docs/commands/sunswap/price.md @@ -70,7 +70,7 @@ A price at or above a cent shows four decimals; below a cent it switches to four | `prices[].priceUsd` | string | USD price, the service's exact digits; `"0"` means "not indexed" | | `prices[].quotedAt` | string | UTC minute the service answered. **Not** a freshness claim about the price | -Rows come back in the service's order, which need not match the order given; match on `address`. Duplicate addresses are deduplicated before the request, so there may be fewer rows than addresses. +Rows come back in the service's order, which need not match the order given; match on `address`. Duplicate addresses are deduplicated before the request, so there may be fewer rows than addresses. When the service returns no rows, `prices` is `[]` and the text output reads `(none)`. ## Exit status diff --git a/ts/docs/commands/sunswap/remove-liquidity.md b/ts/docs/commands/sunswap/remove-liquidity.md index 8d3577303..82fdb0cfc 100644 --- a/ts/docs/commands/sunswap/remove-liquidity.md +++ b/ts/docs/commands/sunswap/remove-liquidity.md @@ -9,7 +9,7 @@ wallet-cli sunswap remove-liquidity --protocol --liquidity [--token0 --token1 ] [--position-id ] [--fee ] [--min0 ] [--min1 ] [--slippage ] [--recipient
] [--deadline ] [--fee-limit ] - [--dry-run | --build-only | --wait [--wait-timeout ]] [options] + [--dry-run | --build-only | --wait | --no-wait] [options] ``` ## Description @@ -36,7 +36,7 @@ It is not a mirror image of adding: ### V3 and V4 pay out the fees too -A V3 or V4 withdrawal also pays out **every fee the position has accrued**, in the same transaction, so more arrives than the principal the estimate quotes — on an older position, the fees can be most of it. The dry run warns about this. In the confirmed receipt, `Received` is the total; JSON splits it into `amount` (principal) and `feeAmount` (fees), and V4 adds `receivedAmount`, the net amount that actually reached you. +A V3 or V4 withdrawal also pays out **every fee the position has accrued**, in the same transaction, so more arrives than the principal the estimate quotes — on an older position, the fees can be most of it. The dry run warns about this. In the confirmed receipt, `Received` is the total; JSON splits it into `amount` (principal) and `feeAmount` (fees), and `receivedAmount` is their total; on V4 it is the measured balance change. To take the fees **without** touching the principal, use [`sunswap collect-fees`](collect-fees.md). @@ -62,7 +62,8 @@ A V4 withdrawal needs **both** `--position-id` and `--token0` / `--token1`. The | `--fee-limit ` | Maximum energy fee to burn, in SUN; default `100000000` | | `--dry-run` | Validate and estimate only — no password, no signature, no broadcast | | `--build-only` | Emit the unsigned transactions in execution order | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--account ` | Account to withdraw for; default the active account | | `--password-stdin` | Master password from stdin; needed only by the modes that sign | @@ -76,7 +77,7 @@ In the examples, `$PW` is your master password, fed on stdin via `--password-std ```bash echo "$PW" | wallet-cli sunswap remove-liquidity --protocol V3 --position-id 701 --liquidity 1000000 \ - --wait --password-stdin --network nile + --password-stdin --network nile ``` ```console @@ -97,18 +98,18 @@ echo "$PW" | wallet-cli sunswap remove-liquidity --protocol V3 --position-id 701 ```bash echo "$PW" | wallet-cli sunswap remove-liquidity --protocol V3 --position-id 702 --liquidity 1000000 \ - --wait --password-stdin --network nile -o json + --password-stdin --network nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"sunswap.remove-liquidity","data":{"kind":"sunswap-remove-liquidity","account":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","protocol":"V3","positionManager":"TPQzqHbCzQfoVdAV6bLwGDos8Lk2UjXz2R","nftTokenId":"702","liquidity":"1000000","recipient":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","token0":{"address":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"amount":"55917","feeAmount":"0"},"token1":{"address":"TYsbWxNnyTgsZaTFaue9hqpxkU3Fkco94a","symbol":"WTRX","decimals":6,"amount":"42532","feeAmount":"0"},"stage":"confirmed","txId":"110ac4aa0de576d58f7ddcca0a24b46d23783d80d30089ecdcaa0046cdb42e02","confirmed":true,"blockNumber":71636717,"feeSun":30506600,"energyUsed":296797,"energyFeeSun":29679600,"netFeeSun":827000,"result":"SUCCESS","failed":false,"amountsEstimated":false,"liquidityAfter":"16883571"},"meta":{"durationMs":12819,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"sunswap.remove-liquidity","data":{"kind":"sunswap-remove-liquidity","account":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","protocol":"V3","positionManager":"TPQzqHbCzQfoVdAV6bLwGDos8Lk2UjXz2R","nftTokenId":"702","liquidity":"1000000","recipient":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","collectsFees":true,"token0":{"address":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"amount":"55917","feeAmount":"0","receivedAmount":"55917"},"token1":{"address":"TYsbWxNnyTgsZaTFaue9hqpxkU3Fkco94a","symbol":"WTRX","decimals":6,"amount":"42532","feeAmount":"0","receivedAmount":"42532"},"stage":"confirmed","txId":"110ac4aa0de576d58f7ddcca0a24b46d23783d80d30089ecdcaa0046cdb42e02","confirmed":true,"blockNumber":71636717,"feeSun":30506600,"energyUsed":296797,"energyFeeSun":29679600,"netFeeSun":827000,"result":"SUCCESS","failed":false,"amountsEstimated":false,"liquidityAfter":"16883571"},"meta":{"durationMs":12819,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` -**V4**, naming the pair as a check on position #188. JSON adds `receivedAmount`, the net amount that reached the account: +**V4**, naming the pair as a check on position #188. JSON reports `receivedAmount` as the measured net amount that reached the account: ```bash echo "$PW" | wallet-cli sunswap remove-liquidity --protocol V4 --position-id 188 --token0 TRX --token1 USDT \ - --liquidity 1000000 --wait --password-stdin --network nile + --liquidity 1000000 --password-stdin --network nile ``` ```console @@ -134,18 +135,18 @@ echo "$PW" | wallet-cli sunswap remove-liquidity --protocol V4 --position-id 188 ```bash echo "$PW" | wallet-cli sunswap remove-liquidity --protocol V4 --position-id 189 --token0 TRX --token1 USDT \ - --liquidity 1000000 --wait --password-stdin --network nile -o json + --liquidity 1000000 --password-stdin --network nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"sunswap.remove-liquidity","data":{"kind":"sunswap-remove-liquidity","account":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","protocol":"V4","recipient":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","deadline":1791441544,"nftTokenId":"189","liquidity":"1000000","token0":{"address":"T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb","symbol":"TRX","decimals":6,"amount":"81451","feeAmount":"0","receivedAmount":"81451"},"token1":{"address":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"amount":"29198","feeAmount":"0","receivedAmount":"29198"},"poolId":"977d6ad6be3a3206f7ca881434bb8a08ecaf1abe4690eed6ee23e3e7e0ae9b6a","feeTier":500,"tickSpacing":10,"hooks":"none","tickLower":-11160,"tickUpper":-9160,"positionManager":"TMTQ1BYo15aGgZXHcsBWXyae8bVaAdgfLP","stage":"confirmed","txId":"07d11ba027d860f61656439dc97efb7900ec75c931569d7f54c6ae08ac75e7e5","confirmed":true,"blockNumber":71636733,"feeSun":9541700,"energyUsed":85868,"energyFeeSun":8586700,"netFeeSun":955000,"result":"SUCCESS","failed":false,"amountsEstimated":false,"liquidityAfter":"11277290"},"meta":{"durationMs":14166,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"sunswap.remove-liquidity","data":{"kind":"sunswap-remove-liquidity","account":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","protocol":"V4","recipient":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","deadline":1791441544,"nftTokenId":"189","liquidity":"1000000","token0":{"address":"T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb","symbol":"TRX","decimals":6,"amount":"81451","feeAmount":"0","receivedAmount":"81451"},"token1":{"address":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"amount":"29198","feeAmount":"0","receivedAmount":"29198"},"poolId":"977d6ad6be3a3206f7ca881434bb8a08ecaf1abe4690eed6ee23e3e7e0ae9b6a","feeTier":500,"tickSpacing":10,"hooks":"none","tickLower":-11160,"tickUpper":-9160,"collectsFees":true,"positionManager":"TMTQ1BYo15aGgZXHcsBWXyae8bVaAdgfLP","stage":"confirmed","txId":"07d11ba027d860f61656439dc97efb7900ec75c931569d7f54c6ae08ac75e7e5","confirmed":true,"blockNumber":71636733,"feeSun":9541700,"energyUsed":85868,"energyFeeSun":8586700,"netFeeSun":955000,"result":"SUCCESS","failed":false,"amountsEstimated":false,"liquidityAfter":"11277290"},"meta":{"durationMs":14166,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` **V2**, burning the LP tokens one [`add-liquidity`](add-liquidity.md) deposit minted (`lpAmount` 770874 at 18 decimals). The LP token is approved first: ```bash echo "$PW" | wallet-cli sunswap remove-liquidity --protocol V2 --token0 USDT --token1 TRX \ - --liquidity 0.000000000000770874 --wait --password-stdin --network nile + --liquidity 0.000000000000770874 --password-stdin --network nile ``` ```console @@ -166,11 +167,11 @@ echo "$PW" | wallet-cli sunswap remove-liquidity --protocol V2 --token0 USDT --t ```bash echo "$PW" | wallet-cli sunswap remove-liquidity --protocol V2 --token0 USDT --token1 TRX \ - --liquidity 0.000000000000770874 --wait --password-stdin --network nile -o json + --liquidity 0.000000000000770874 --password-stdin --network nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"sunswap.remove-liquidity","data":{"kind":"sunswap-remove-liquidity","account":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","protocol":"V2","router":"TMn1qrmYUMSTXo9babrJLzepKZoPC7M6Sy","recipient":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","lpAmount":"770874","lpDecimals":18,"token0":{"address":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"amount":"999999"},"token1":{"address":"T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb","symbol":"TRX","decimals":6,"amount":"693728"},"approvalTxIds":["e061d881aa560ce802e10e203c834f6c11da82d6498f7b6c0ca0067529c1757a"],"stage":"confirmed","txId":"7dbf85fc0927522eb150564a06dd3bcdd18b22be5ab28911a120692e5d68f840","confirmed":true,"blockNumber":71636747,"feeSun":20073600,"energyUsed":195987,"energyFeeSun":19598600,"netFeeSun":475000,"result":"SUCCESS","failed":false,"amountsEstimated":false,"reservesAfter":{"token0":"8920608853348","token1":"6188486049459"}},"meta":{"durationMs":21567,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"sunswap.remove-liquidity","data":{"kind":"sunswap-remove-liquidity","account":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","protocol":"V2","router":"TMn1qrmYUMSTXo9babrJLzepKZoPC7M6Sy","recipient":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","lpAmount":"770874","lpDecimals":18,"token0":{"address":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"amount":"999999","receivedAmount":"999999"},"token1":{"address":"T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb","symbol":"TRX","decimals":6,"amount":"693728","receivedAmount":"693728"},"approvalTxIds":["e061d881aa560ce802e10e203c834f6c11da82d6498f7b6c0ca0067529c1757a"],"stage":"confirmed","txId":"7dbf85fc0927522eb150564a06dd3bcdd18b22be5ab28911a120692e5d68f840","confirmed":true,"blockNumber":71636747,"feeSun":20073600,"energyUsed":195987,"energyFeeSun":19598600,"netFeeSun":475000,"result":"SUCCESS","failed":false,"amountsEstimated":false,"reservesAfter":{"token0":"8920608853348","token1":"6188486049459"}},"meta":{"durationMs":21567,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` ## Output @@ -182,7 +183,8 @@ echo "$PW" | wallet-cli sunswap remove-liquidity --protocol V2 --token0 USDT --t | `account` / `recipient` / `deadline` | — | As in [`add-liquidity`](add-liquidity.md#output) | | `lpAmount` / `lpDecimals` | string / number | V2: LP tokens burned, base units | | `nftTokenId`, `liquidity` | string | V3 / V4: the position and the liquidity burned; after confirmation, `liquidityAfter` is what it still holds. No decimals | -| `token0` / `token1` | object | `{address, symbol, decimals, amount, amountMinimum}`. Before sending, `amount` is what current reserves say is coming back; once confirmed, what arrived — with `feeAmount` beside it on V3 / V4, and `receivedAmount` on V4 | +| `token0` / `token1` | object | `{address, symbol, decimals, amount, amountMinimum, receivedAmount}`. Before sending, `amount` is what current reserves say is coming back; once confirmed, what arrived — with `feeAmount` beside it on V3 / V4. `receivedAmount` is what reaches the recipient: the principal plus any fees (on a confirmed V4 receipt, the measured balance change). `acceptsAnyAmount: true` marks a side whose `amountMinimum` is `0` | +| `collectsFees` | boolean | `true` on V3 / V4: the withdrawal also collects the position's accrued fees, so more than the estimate may arrive | | `poolId`, `feeTier`, `tickSpacing`, `hooks`, `tickLower`, `tickUpper` | — | V4: the position's pool key and range | | `router` (V2) / `positionManager` (V3, V4) | string | The contract the withdrawal goes through | | `approvals[]` | array | V2: the LP-token approval to be sent first | @@ -190,7 +192,7 @@ echo "$PW" | wallet-cli sunswap remove-liquidity --protocol V2 --token0 USDT --t | `fee` / `feeCovers` | — | The estimate and what it covers | | `approvalTxIds[]`, `amountsEstimated` | — | Once sent, as in [`add-liquidity`](add-liquidity.md#output) | -The default mode returns at submission (`stage: "submitted"`, `txId`); `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`. +The command waits for confirmation by default and reports `stage: "confirmed"` (or `"failed"`), `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`; with `--no-wait` it returns at submission (`stage: "submitted"`, `txId`). ## Exit status diff --git a/ts/docs/commands/sunswap/swap.md b/ts/docs/commands/sunswap/swap.md index 9c8f3b1f3..228631050 100644 --- a/ts/docs/commands/sunswap/swap.md +++ b/ts/docs/commands/sunswap/swap.md @@ -6,8 +6,8 @@ Exchange one token for another. ``` wallet-cli sunswap swap - [--quote [--all] | --dry-run | --build-only | --wait [--wait-timeout ]] - [--slippage ] [--fee-limit ] [options] + [--quote [--all] | --dry-run | --build-only | --wait | --no-wait] + [--slippage | --min-out ] [--fee-limit ] [options] ``` ## Description @@ -59,14 +59,18 @@ In every mode except `--quote`, the account must hold what the swap spends: an a `--slippage` (default `0.005`, i.e. 0.5%; from `0.0001` to `0.5`, in steps no finer than `0.0001`) sets the floor `Min received`: below it the trade reverts on chain. The floor is computed by the CLI from the chosen route and read back out of the encoded call before sending, so it is the one actually enforced. +`--min-out` sets the floor directly instead, in the smallest unit of ``; it excludes `--slippage`. If the chosen route or the curve already quotes less than `--min-out`, the command stops with `slippage_exceeded` (exit 1) before anything is approved, signed or sent, in every mode, with both amounts in `details.amountOutExpected` and `details.amountOutMinimum`. + +A route that the route service quotes at zero output is not offered; when no other route is left, the command stops with `no_matching_route`. + ### Modes | Mode | Needs | What you get | |---|---|---| -| `--quote` | nothing — no account, no password | The best route (highest output, then lowest fee, then fewest hops); `--all` lists every candidate. No floor is published — `--quote` refuses `--slippage` | +| `--quote` | nothing — no account, no password | The best route (highest output, then lowest fee, then fewest hops); `--all` lists every candidate. No floor is published — `--quote` refuses `--slippage` and `--min-out` | | `--dry-run` | an account (watch-only works), no password | The chosen route, the floor, the grants it would ask for, and a fee estimate | | `--build-only` | an account, no password | The unsigned transaction. **Refused for a router swap that spends a token**: the transaction embeds the Permit2 signature, which does not exist yet | -| default / `--wait` | a signing account (software password via stdin/TTY, or Ledger approval) | Sends. Returns at submission unless `--wait` | +| default / `--wait` / `--no-wait` | a signing account (software password via stdin/TTY, or Ledger approval) | Sends. Waits for confirmation unless `--no-wait` | For a swap spending **TRX** it prices the swap itself. For a swap spending a **token**, what it can price depends on what is already on chain, and `feeCovers` says which case applies: @@ -81,13 +85,15 @@ For a swap spending **TRX** it prices the swap itself. For a swap spending a **t | `` | **Required.** Token to spend, symbol or contract address | | `` | **Required.** Token to receive, symbol or contract address | | `` | **Required.** Amount of `` to spend, in whole tokens, > 0 | -| `--quote` | Price only. Excludes `--dry-run`, `--build-only`, `--wait` and `--slippage` | +| `--quote` | Price only. Excludes `--dry-run`, `--build-only`, `--wait`, `--no-wait`, `--slippage` and `--min-out` | | `--all` | List every candidate route instead of the best one; only with `--quote` | | `--dry-run` | Validate and estimate only — no password, no signature, no broadcast | | `--build-only` | Emit the unsigned transaction(s) without signing them | -| `--slippage ` | Tolerance, e.g. `0.005` for 0.5%; default `0.005` | +| `--slippage ` | Tolerance, e.g. `0.005` for 0.5%; default `0.005`. Excludes `--min-out` | +| `--min-out ` | Least to accept, in ``'s smallest unit. Excludes `--slippage` | | `--fee-limit ` | Maximum energy fee to burn, in SUN; default `100000000`. The dry run's estimate is a lower bound, so a limit set from it can fail | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--account ` | Account to trade from; default the active account. `--quote` reads the same account's token book | | `--password-stdin` | Master password from stdin (fd 0); needed only by the modes that sign | @@ -159,10 +165,10 @@ Token out TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E | TRX → Justin | 10 TRX | 251,195.108099 Justin | 0.1 TRX | ``` -Swapping 10 TRX for USDT. `--wait` returns the confirmed receipt, whose `Received` is what actually arrived, read from the transaction: +Swapping 10 TRX for USDT. The command returns the confirmed receipt, whose `Received` is what actually arrived, read from the transaction: ```bash -echo "$PW" | wallet-cli sunswap swap TRX USDT 10 --wait --password-stdin --account lp --network tron +echo "$PW" | wallet-cli sunswap swap TRX USDT 10 --password-stdin --account lp --network tron ``` ```console @@ -184,7 +190,7 @@ echo "$PW" | wallet-cli sunswap swap TRX USDT 10 --wait --password-stdin --accou ``` ```bash -echo "$PW" | wallet-cli sunswap swap TRX USDT 10 --wait --password-stdin --account lp --network tron -o json +echo "$PW" | wallet-cli sunswap swap TRX USDT 10 --password-stdin --account lp --network tron -o json ``` ```json @@ -194,7 +200,7 @@ echo "$PW" | wallet-cli sunswap swap TRX USDT 10 --wait --password-stdin --accou Swapping 1 USDT for TRX. The Permit2 grant is signed locally and travels inside the swap; no approval transaction was needed, because the allowance to Permit2 already covered the trade: ```bash -echo "$PW" | wallet-cli sunswap swap USDT TRX 1 --wait --password-stdin --account lp --network tron +echo "$PW" | wallet-cli sunswap swap USDT TRX 1 --password-stdin --account lp --network tron ``` ```console @@ -219,11 +225,11 @@ echo "$PW" | wallet-cli sunswap swap USDT TRX 1 --wait --password-stdin --accoun ``` ```bash -echo "$PW" | wallet-cli sunswap swap USDT TRX 1 --wait --password-stdin --account lp --network tron -o json +echo "$PW" | wallet-cli sunswap swap USDT TRX 1 --password-stdin --account lp --network tron -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"sunswap.swap","data":{"kind":"sunswap-swap","market":"sunswap","account":"TT2T17KZhoDu47i2E4FWxfG79zdkEWkU9N","amountIn":"1000000","tokenIn":{"address":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","decimals":6,"symbol":"USDT"},"tokenOut":{"address":"T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb","symbol":"TRX","decimals":6},"route":{"path":[{"address":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","symbol":"USDT","decimals":6},{"address":"TCFLL5dx5ZJdKnWuesXxi1VPwjLVmWZZy9","symbol":"JST"},{"address":"T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb","symbol":"TRX","decimals":6}],"protocols":["V4","V4"],"poolFees":["500","3000","0"]},"tradingFee":"3498","amountOutExpected":"3013006","priceImpactPercent":"-0.002370","priceImpactEstimated":true,"amountOutMinimum":"2997940","slippage":"0.005","approvals":[],"permit":{"permit2":"TTJxU3P8rHycAyFY4kVtGNfmnMH4ezcuM9","spender":"TQqgNg13s2DjvXhW1ky4v6TsR8wZGvb7Y4","amount":"1000000","expiration":"1791438367"},"stage":"confirmed","txId":"b83e0d5a9c17f42e6b0a8d3c5f91e27d4a6c0b8e3f5d2a9c7e1b4f60d8a3c25e","confirmed":true,"blockNumber":86922815,"feeSun":28988700,"energyUsed":286437,"energyFeeSun":28643700,"netFeeSun":345000,"result":"SUCCESS","failed":false,"amountsEstimated":false,"amountOut":"3012871"},"meta":{"durationMs":14327,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"sunswap.swap","data":{"kind":"sunswap-swap","market":"sunswap","account":"TT2T17KZhoDu47i2E4FWxfG79zdkEWkU9N","amountIn":"1000000","tokenIn":{"address":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","decimals":6,"symbol":"USDT"},"tokenOut":{"address":"T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb","symbol":"TRX","decimals":6},"route":{"path":[{"address":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","symbol":"USDT","decimals":6},{"address":"TCFLL5dx5ZJdKnWuesXxi1VPwjLVmWZZy9","symbol":"JST"},{"address":"T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb","symbol":"TRX","decimals":6}],"protocols":["V4","V4"],"poolFees":["500","3000","0"]},"tradingFee":"3498","amountOutExpected":"3013006","priceImpactPercent":"-0.002370","priceImpactEstimated":true,"amountOutMinimum":"2997940","slippage":"0.005","approvals":[],"permit":{"permit2":"TTJxU3P8rHycAyFY4kVtGNfmnMH4ezcuM9","spender":"TQqgNg13s2DjvXhW1ky4v6TsR8wZGvb7Y4","amount":"1000000","expiration":"1791438367","ttlSeconds":3600},"stage":"confirmed","txId":"b83e0d5a9c17f42e6b0a8d3c5f91e27d4a6c0b8e3f5d2a9c7e1b4f60d8a3c25e","confirmed":true,"blockNumber":86922815,"feeSun":28988700,"energyUsed":286437,"energyFeeSun":28643700,"netFeeSun":345000,"result":"SUCCESS","failed":false,"amountsEstimated":false,"amountOut":"3012871"},"meta":{"durationMs":14327,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} ``` ## Output @@ -251,19 +257,20 @@ echo "$PW" | wallet-cli sunswap swap USDT TRX 1 --wait --password-stdin --accoun | `amountIn` | string | Base units of `tokenIn` | | `tokenIn` / `tokenOut` | object | `{address, symbol, decimals}` | | `amountOutExpected` / `amountOutMinimum` | string | Expected output and the enforced floor, base units of `tokenOut` | -| `slippage` | string | The tolerance the floor was computed from | +| `slippage` | string | The tolerance the floor was computed from; absent with `--min-out` | | `tradingFee`, `priceImpactPercent` | string | Quoted figures; `priceImpactEstimated: true` marks the router's impact as a quote, not a measurement | -| `approvals[]` | array | The TRC20 approval to Permit2, `{token, spender, amount, …}`; empty or absent when none is needed | -| `permit` | object | `{permit2, spender, amount, expiration}` — the Permit2 grant (expiration in Unix seconds). Absent when spending TRX | +| `approvals[]` | array | The TRC20 approval to Permit2, `{token, spender, amount, isUnlimited, …}`; empty or absent when none is needed. On the curve, the launchpad grant, `isUnlimited: true` | +| `permit` | object | `{permit2, spender, amount, expiration, ttlSeconds}` — the Permit2 grant (expiration in Unix seconds) and the lifetime it was requested for. Absent when spending TRX | +| `approvalRemains` | boolean | `true` on a swap with a Permit2 grant that failed on chain: the TRC20 approval to Permit2 stays in place and does not expire | | `fee` / `feeCovers` / `feeUnavailableReason` | — | The estimate and what it covers (`all`, `approvals` or `none`) | | `approvalTxIds[]` | string[] | Once sent: the approvals, in the order sent, beside the swap's own `txId` | | `amountOut` | string | Confirmed receipts only: what actually arrived, read from the transaction. `amountsEstimated: false` marks it as measured; if the receipt could not be read, `amountOutExpected` stands, `amountsEstimated` is `true` and a warning says why | -The default mode returns at submission (`stage: "submitted"`, `txId`); `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`. +The command waits for confirmation by default and reports `stage: "confirmed"` (or `"failed"`), `confirmed`, `blockNumber`, `feeSun` (with its parts `energyUsed`, `energyFeeSun`, `netFeeSun`), `result` and `failed`; with `--no-wait` it returns at submission (`stage: "submitted"`, `txId`). ## Exit status -`0` success (submitted, or quoted/built/estimated) · `1` execution failure (`account_not_active`, `insufficient_balance`, `insufficient_token_balance`, `same_token` — the two sides are the same token, `no_matching_route` — no path for that pair (retry later), `launchpad_trading_closed`, `permit_mismatch`, `router_call_mismatch`, `slippage_exceeded`, `watch_only_no_signer` — a watch-only account in a mode that signs, `auth_failed`, `provider_error`, `provider_rate_limited` — the route service returned HTTP 429, with `details.retryAfterSeconds` when sent, `timeout`) · `2` usage error (`invalid_amount` — `` not a positive number; `invalid_value` — a `--slippage` outside 0.0001–0.5 or finer than 0.0001; `invalid_option` — two modes together, `--wait` with `--quote` / `--dry-run` / `--build-only`, `--slippage` with `--quote`, `--all` without `--quote`, `--build-only` for a token swap; `account_not_found`; `unsupported_token`, `ambiguous_token_symbol`; `unsupported_network_capability`; `family_mismatch`). +`0` success (submitted, or quoted/built/estimated) · `1` execution failure (`account_not_active`, `insufficient_balance`, `insufficient_token_balance`, `same_token` — the two sides are the same token, `no_matching_route` — no path for that pair (retry later), `launchpad_trading_closed`, `permit_mismatch`, `router_call_mismatch`, `slippage_exceeded`, `watch_only_no_signer` — a watch-only account in a mode that signs, `auth_failed`, `provider_error`, `provider_rate_limited` — the route service returned HTTP 429, with `details.retryAfterSeconds` when sent, `timeout`) · `2` usage error (`invalid_amount` — `` not a positive number, or `--min-out` not a whole number; `invalid_value` — a `--slippage` outside 0.0001–0.5 or finer than 0.0001; `invalid_option` — two modes together, `--wait` or `--no-wait` with `--quote` / `--dry-run` / `--build-only`, `--slippage` or `--min-out` with `--quote`, `--slippage` with `--min-out`, `--all` without `--quote`, `--build-only` for a token swap; `account_not_found`; `unsupported_token`, `ambiguous_token_symbol`; `unsupported_network_capability`; `family_mismatch`). ## See also diff --git a/ts/docs/commands/token/add.md b/ts/docs/commands/token/add.md index 59d26f7c5..1b1b5a24a 100644 --- a/ts/docs/commands/token/add.md +++ b/ts/docs/commands/token/add.md @@ -14,6 +14,8 @@ Fetches the token's name, symbol and decimals from the chain and adds it to the Once added, the token can be used by symbol elsewhere — e.g. `tx send --token USDT`. The book has two layers: **official** (bundled, read-only) and **user** (the ones you add). If the token is already bundled in the official layer, it fails with `token_already_listed` (no need to add it again); if you have already added it before, adding it again does not error — it re-fetches the token's metadata (symbol/decimals/name) and updates it, returning `action: refreshed`. +EVM contract addresses are stored and returned in checksum form. Address comparison ignores case, so adding the same user token with different casing refreshes its existing entry. TRON token names are returned as text, including names that resemble hexadecimal strings. + ## Options | Option | Description | @@ -67,7 +69,7 @@ wallet-cli token add --contract 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 --net ## Exit status -`0` added · `1` execution failure (`token_metadata_unavailable` — metadata could not be fetched, nothing is stored; `encoding_error` / `io_error` — the local token book could not be decoded or written) · `2` usage error (`token_already_listed` — already in the official layer; `invalid_value`; `invalid_option` — `--asset-id` on an EVM network). +`0` added · `1` execution failure (`token_metadata_unavailable` — metadata could not be fetched, nothing is stored; `encoding_error` / `io_error` — the local token book could not be decoded or written) · `2` usage error (`token_already_listed` — already in the official layer; `invalid_value`; `invalid_option` — missing or conflicting selectors on TRON, or `--asset-id` on an EVM network; `missing_option` — a missing `--contract` on EVM). ## See also diff --git a/ts/docs/commands/token/balance.md b/ts/docs/commands/token/balance.md index 844602fcb..e5270d25a 100644 --- a/ts/docs/commands/token/balance.md +++ b/ts/docs/commands/token/balance.md @@ -66,7 +66,7 @@ wallet-cli token balance --contract 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 - ## Exit status -`0` success · `1` execution failure (`rpc_error`, `timeout`) · `2` usage error (`invalid_value` — missing or conflicting selector; `invalid_option` — `--asset-id` on an EVM network). +`0` success · `1` execution failure (`rpc_error`, `timeout`) · `2` usage error (`invalid_value` — malformed values; `missing_option` — missing `--contract` on EVM; `invalid_option` — missing or conflicting selector on TRON, or `--asset-id` on an EVM network). ## See also diff --git a/ts/docs/commands/token/info.md b/ts/docs/commands/token/info.md index b0e48463f..a3a0fa23e 100644 --- a/ts/docs/commands/token/info.md +++ b/ts/docs/commands/token/info.md @@ -94,7 +94,7 @@ The TRC10 shape carries no normalized `contract`, `symbol`, or `decimals` keys. ## Exit status -`0` success · `1` execution failure (`token_metadata_unavailable` — the contract does not expose ERC20-style metadata; `rpc_error`) · `2` usage error (`invalid_value`; `invalid_option` — `--asset-id` on an EVM network). +`0` success · `1` execution failure (`token_metadata_unavailable` — the contract does not expose ERC20-style metadata; `rpc_error`) · `2` usage error (`invalid_value`; `invalid_option` — missing or conflicting selectors on TRON, or `--asset-id` on an EVM network; `missing_option` — a missing `--contract` on EVM). ## See also diff --git a/ts/docs/commands/token/remove.md b/ts/docs/commands/token/remove.md index 92600c54f..ad2acf0b5 100644 --- a/ts/docs/commands/token/remove.md +++ b/ts/docs/commands/token/remove.md @@ -12,6 +12,8 @@ wallet-cli token remove (--contract
| --asset-id ) [options] Removes a token you added from the user layer of the address book (same network + account scope as [`token add`](add.md)). Official-layer tokens cannot be removed — trying returns `token_is_official`. +EVM contract addresses are compared without case sensitivity. + Purely local — the token and your balance on-chain are unaffected; only the local symbol mapping goes away. ## Options @@ -55,7 +57,7 @@ wallet-cli token remove --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --network ## Exit status -`0` removed · `1` execution failure (`encoding_error` / `io_error` — the local token book could not be decoded or written) · `2` usage error (`token_is_official` — official-layer tokens can't be removed; `token_not_in_book` — not in the user layer; `invalid_value`; `invalid_option` — `--asset-id` on an EVM network). +`0` removed · `1` execution failure (`encoding_error` / `io_error` — the local token book could not be decoded or written) · `2` usage error (`token_is_official` — official-layer tokens can't be removed; `token_not_in_book` — not in the user layer; `invalid_value`; `invalid_option` — missing or conflicting selectors on TRON, or `--asset-id` on an EVM network; `missing_option` — a missing `--contract` on EVM). ## See also diff --git a/ts/docs/commands/tx/approvals.md b/ts/docs/commands/tx/approvals.md index f3f070dff..77a838f92 100644 --- a/ts/docs/commands/tx/approvals.md +++ b/ts/docs/commands/tx/approvals.md @@ -14,7 +14,7 @@ wallet-cli tx approvals (--hex | --file ) [options] A read-only view of a transaction hex's co-signing progress: the permission group and threshold it uses, the accumulated weight so far, the list of signers who have already approved, how much weight is still missing, and the expiration. It's the "look before you sign" companion to [`tx sign`](sign.md) — same information, no signature, no account or password needed. -It needs a node (`--network`), because approval state — which signatures count, and for how much weight — is the chain's answer, not something derivable from the artifact alone. Files are read with a size cap of just over 1 MiB and must be regular files, not symlinks. +It needs a node (`--network`), because approval state — which signatures count, and for how much weight — is the chain's answer, not something derivable from the artifact alone. The transaction must be for that network: its reference block is checked first, and one the node does not hold is `chain_id_mismatch` (one newer than the node's head is `rpc_error`; retry once the node has caught up), so no other chain's permissions are reported. Files are read with a size cap of just over 1 MiB and must be regular files, not symlinks. An expired transaction is still queryable (no error): the text `Expires` line shows the time followed by ` [EXPIRED]`, with a `!` hint to re-initiate, and the JSON `expired` field is `true`. @@ -75,7 +75,7 @@ wallet-cli tx approvals --file tx.hex --network nile -o json ## Exit status -`0` success · `1` execution failure (`invalid_transaction`, `rpc_error`) · `2` usage error (`invalid_value`). +`0` success · `1` execution failure (`invalid_transaction`, `chain_id_mismatch`, `rpc_error`) · `2` usage error (`invalid_value` — malformed values; `invalid_option` — missing or conflicting input sources). ## See also diff --git a/ts/docs/commands/tx/broadcast.md b/ts/docs/commands/tx/broadcast.md index acb18455c..5a3fef420 100644 --- a/ts/docs/commands/tx/broadcast.md +++ b/ts/docs/commands/tx/broadcast.md @@ -22,6 +22,8 @@ Broadcasting is not blind. Whatever form the transaction arrives in, it is decod **TRON:** - **expired** → `tx_expired` +- **built for another chain** — its reference block is not on the selected network → `chain_id_mismatch` +- a **reference block newer than the node's head** → `rpc_error`; retry once the node has caught up, or use a node that is in sync - **insufficient signature weight** → `not_authorized`, naming the missing weight That check is what makes this safe as the last step of a multi-signature workflow: a transaction that has not yet reached its permission threshold never reaches the node. @@ -46,17 +48,18 @@ The reported `txId` is derived from the transaction's own bytes, never taken fro | `--file ` | File containing the signed transaction hex (size-capped at just over 1 MiB) | | `--transaction ` | **TRON only.** Signed TRON transaction JSON inline | | `--tx-stdin` | **TRON only.** Read the signed transaction JSON from stdin (fd 0) | -| `--dry-run` | Validate **without broadcasting** — signatures, threshold, expiration and the dynamic multi-sig fee on TRON; signature, chain id, nonce and balance on EVM. Cannot be combined with `--wait` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default 60000) | +| `--dry-run` | Validate **without broadcasting** — signatures, threshold, expiration and the dynamic multi-sig fee on TRON; signature, chain id, nonce and balance on EVM. Cannot be combined with `--wait` or `--no-wait` | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | Plus the [global options](../index.md#global-options-every-command). ## Examples -Broadcast a signed hex from a file: +Broadcast a signed hex from a file, returning at submission with `--no-wait`: ```bash -wallet-cli tx broadcast --file tx.signed.hex --network nile +wallet-cli tx broadcast --file tx.signed.hex --network nile --no-wait ``` ```console @@ -70,7 +73,7 @@ wallet-cli tx broadcast --file tx.signed.hex --network nile Or inline hex, and the JSON receipt: ```bash -wallet-cli tx broadcast --hex 0a02...9f31 --network nile -o json +wallet-cli tx broadcast --hex 0a02...9f31 --network nile --no-wait -o json ``` ```json @@ -83,21 +86,23 @@ wallet-cli tx broadcast --hex 0a02...9f31 --network nile -o json | Stage | Fields | |---|---| -| default (submit, TRON) | `kind`, `stage: "submitted"`, `txId`, `transaction` (the approval view), `multiSignFeeSun` | -| default (submit, EVM) | `kind`, `stage: "submitted"`, `txId`, and `alreadyKnown: true` when the node had already seen the transaction | +| `--no-wait` (submitted, TRON) | `kind`, `stage: "submitted"`, `txId`, `transaction` (the approval view), `multiSignFeeSun`, and `alreadyKnown: true` when the node already held the transaction | +| `--no-wait` (submitted, EVM) | `kind`, `stage: "submitted"`, `txId`, and `alreadyKnown: true` when the node had already seen the transaction | | `--dry-run` (TRON) | `kind`, `mode: "dry-run"`, `transaction` (the approval view), `multiSignFeeSun` | -| `--wait` (confirmed/failed) | the submit fields, plus `confirmed`, `blockNumber`, `failed`, and result fields — `netUsed` / `feeSun` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | +| default or `--wait` (confirmed/failed) | the submit fields, plus `confirmed`, `blockNumber`, `failed`, and result fields — `netUsed` / `feeSun` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | | `--dry-run` (EVM) | `kind`, `mode: "dry-run"`, `txId`, `hash`, `address` (recovered signer), `to`, `rawAmount`, `fee` (`feeModel`, `maxCostWei`, `gasLimit`, `maxPerGasWei`), `tx`, and `checks[]` (`name` — `signature` / `chainId` / `nonce` / `balance` — plus `status` (`ok` / `warning` / `skipped`) and `detail`) | On TRON `multiSignFeeSun` is always present — `0` for a single-signature transaction, since the fee applies only from the second signature on — so the text receipt always carries a `Multi-sign fee` row ahead of `TxID`. -If the node cannot be reached, the EVM nonce and balance checks degrade to `status: "skipped"` with a warning rather than failing the command. On EVM a node that already knows the transaction sets `alreadyKnown: true` on the submitted receipt rather than failing. +If the node cannot be reached, the EVM nonce and balance checks degrade to `status: "skipped"` with a warning rather than failing the command. A node that already holds the transaction sets `alreadyKnown: true` on the receipt rather than failing: on EVM an "already known" reply, on TRON a `DUP_TRANSACTION_ERROR` reply that names the transaction sent. The command then waits for confirmation as usual, so a transaction that failed on chain is still reported as `failed`. Rebroadcasting the same signed transaction is therefore a safe way to reconcile an [unknown outcome](../../machine-interface.md#unknown-outcome-errordetailsoutcome). -As with `tx send`, the default return point is **submission** — confirm via `--wait` or [`tx status`](status.md). +As with `tx send`, the command waits for confirmation by default; with `--no-wait` it returns at **submission** — confirm later with [`tx status`](status.md). ## Exit status -`0` submitted · `1` execution failure (node rejected the tx, timeout; `tx_expired` / `not_authorized` on TRON; `invalid_transaction`, `chain_id_mismatch`, `nonce_too_low`, `insufficient_balance` on EVM) · `2` usage error (more than one, or none, of the input sources; `invalid_option` — `--transaction` / `--tx-stdin` on an EVM network). +`0` submitted · `1` execution failure (node rejected the tx, `timeout`, `rpc_error`, `invalid_node_response`; `chain_id_mismatch`; `tx_expired` / `not_authorized` on TRON; `invalid_transaction`, `nonce_too_low`, `insufficient_balance` on EVM) · `2` usage error (more than one, or none, of the input sources; `invalid_option` — `--transaction` / `--tx-stdin` on an EVM network). + +A `timeout`, `rpc_error` or `invalid_node_response` from submission includes the locally computed signed transaction id in `error.details.txId` and [`error.details.outcome: "unknown"`](../../machine-interface.md#unknown-outcome-errordetailsoutcome); text output shows the id too. Check it with [`tx status`](status.md) before sending again. An EVM reply with no RPC error and no valid transaction hash, or a TRON reply without explicit acceptance or a rejection reason, is `invalid_node_response`: it does not prove rejection. For the same reason an EVM JSON-RPC error is `rpc_error` unless its message names a known rejection (such as `nonce too low`) or its code says the node refused the transaction (`-32003`) or did not process the request; a proxy timeout (`-32002`), an internal error (`-32603`) and any other `-32000` reply count as unknown. So does a TRON reply coded `OTHER_ERROR` or `NOT_ENOUGH_EFFECTIVE_CONNECTION`, and a `DUP_TRANSACTION_ERROR` that names no transaction or another one. Definite rejections and failures before submission gain neither `details.txId` nor `details.outcome`. Note that `--dry-run` exits **non-zero** on a transaction that would fail — that is the answer a script is asking for. diff --git a/ts/docs/commands/tx/info.md b/ts/docs/commands/tx/info.md index aa6e84ce0..85c54672f 100644 --- a/ts/docs/commands/tx/info.md +++ b/ts/docs/commands/tx/info.md @@ -14,7 +14,7 @@ Fetches the complete transaction object and its execution receipt, on TRON and E The top-level summary is normalized across families; the nested raw objects are not. On TRON they are `transaction` (the node's tx object) and `info` (its execution receipt); on EVM they are `transaction` (the `eth_getTransactionByHash` result) and `receipt`. -Note the failure-mode difference: where `tx status` answers `not_found` with exit 0, `tx info` on an unknown txid is a plain **error** — there is no detail to show. The families differ in both code and exit code: TRON surfaces the node's own refusal as `rpc_error` at exit **1**, while EVM raises `not_found` at exit **2**, treating the lookup as a bad call since the hash addresses nothing. Branch on the exit code first, then on `error.code`. +Note the failure-mode difference: where `tx status` answers `not_found` with exit 0, `tx info` on an unknown txid is a plain **error** — there is no detail to show. The families differ in the code: TRON surfaces the node's own refusal as `rpc_error`, while EVM raises `not_found`; both exit **1**. Branch on the exit code first, then on `error.code`. ## Options @@ -55,7 +55,7 @@ wallet-cli tx info --txid 0x55b0068ef31bce39bbf5b06d456eaef307fd77f96d85ea291f48 {"schema":"wallet-cli.result.v1","success":true,"command":"tx.info","data":{"txid":"0x55b0068ef31bce39bbf5b06d456eaef307fd77f96d85ea291f48c1ae4b900d80","type":"contract-call","from":"0x88878d9250e68C574912f5618ad3b43f675B8888","nonce":342,"to":"0x1b853EbA683A7C0b691256124392F3b922771869","status":"success","blockNumber":11576586,"confirmations":0,"gasUsed":"46231","feeWei":"43850186538","effectiveGasPriceWei":"948493","transaction":{},"receipt":{}},"meta":{"durationMs":455,"warnings":[]},"chain":{"family":"evm","network":"eip155:11155111","chainId":"11155111"}} ``` -An unknown txid errors out — unlike `tx status`'s `not_found` (exit 0). The code and exit code differ by family (TRON `rpc_error`, exit 1; EVM `not_found`, exit 2): +An unknown txid errors out — unlike `tx status`'s `not_found` (exit 0). The code differs by family (TRON `rpc_error`; EVM `not_found`), and both exit 1: ```json {"schema":"wallet-cli.result.v1","success":false,"command":"tx.info","error":{"code":"rpc_error","message":"TRON getTransaction failed: Transaction not found"},"meta":{"durationMs":1033,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} @@ -75,10 +75,10 @@ Shared summary: |---|---|---| | `txid` | string | Transaction id | | `from` | string | Sender address | -| `to` | string | Recipient address | +| `to` | string | Recipient address; omitted for an EVM contract creation | | `amount` | string | Transfer amount (human units) | | `symbol` | string | Native coin or token symbol | -| `status` | string | Lower-cased, so match on `"success"` and never `"SUCCESS"`. On EVM it is `success` or `revert`; on TRON it is the node's own `contractRet` lower-cased — `success`, `revert`, `out_of_energy`, and so on. It is never `failed` | +| `status` | string | Lower-cased, so match on `"success"` and never `"SUCCESS"`. On EVM it is `success` or `revert`; on TRON it is the node's own result code lower-cased — `success`, `revert`, `out_of_energy`, and so on — or `failed` when the node marks the transaction failed without a result code | | `blockNumber` | number | Block height | | `confirmations` | number | Blocks on top of the including block | @@ -101,12 +101,14 @@ EVM adds: | `gasUsed` | string | Gas actually consumed | | `feeWei` | string | Fee actually charged, in wei | | `effectiveGasPriceWei` | string | Price per gas actually paid | -| `transaction` | object | `eth_getTransactionByHash` result, as returned | +| `transaction` | object | `eth_getTransactionByHash` result, as returned. A result without `from`, `value` or `input` fails with `invalid_node_response` | | `receipt` | object | Decoded receipt (`success`, `gasUsed`, `feeWei`, `effectiveGasPriceWei`, `blockNumber`) with the node's own response under `raw` | +On TRON, a top-level `FAILED` is reported as a failed status; a receipt result of `DEFAULT` leaves the transaction's own result code authoritative. Malformed required EVM transaction fields fail with `invalid_node_response`; a contract-creation transaction omits `to`. + ## Exit status -`0` found · `1` execution failure — including *not found* on TRON (`rpc_error`) · `2` usage error, including *not found* on EVM (`not_found`). +`0` found · `1` execution failure — including *not found* (`rpc_error` on TRON, `not_found` on EVM) · `2` usage error. ## See also diff --git a/ts/docs/commands/tx/multisig.md b/ts/docs/commands/tx/multisig.md index bc204bc62..81ac6f0ae 100644 --- a/ts/docs/commands/tx/multisig.md +++ b/ts/docs/commands/tx/multisig.md @@ -26,13 +26,13 @@ Where the on-chain path passes a hex from person to person, the service path has There is no empty collection. The service derives the starting weight from the signature the transaction arrives with, so `--create` signs before it submits and the collection opens at `1 / N` — the originator does **not** sign again afterwards, and attempting it returns `already_signed`. -`--create` refuses a transaction that already carries a signature (`invalid_value`), one that has expired (`tx_expired`), and one whose permission group does not include the selected account (`not_authorized`). +`--create` refuses a transaction that already carries a signature (`invalid_value`), one that has expired (`tx_expired`), one built for another chain — its reference block is not on the selected network (`chain_id_mismatch`) — and one whose permission group does not include the selected account (`not_authorized`). `--create` and `--sign` check the reference block before reading the permission or signing; when it is newer than the node's head, they fail with `rpc_error` and can be retried once the node has caught up. In the list, a record for another chain is shown with `verified: false`. ### Reaching the threshold -Once the accumulated weight reaches the threshold the **service broadcasts the transaction itself**. The `--sign` receipt therefore points at confirmation first, and offers a manual broadcast only as a fallback. Broadcasting one that is already on chain fails with `transaction_rejected` (`Transaction already exists.`) — harmless, but confirm rather than guess. A transaction with more than one signature also incurs a 1 TRX multi-sig fee. +Once the accumulated weight reaches the threshold the **service broadcasts the transaction itself**. The `--sign` receipt therefore points at confirmation first, and offers a manual broadcast only as a fallback. Broadcasting one that the node already holds returns `stage: "submitted"` with `alreadyKnown: true`, and the command then waits for its confirmation as usual. A transaction with more than one signature also incurs a 1 TRX multi-sig fee. -`--watch` receives only a count, never transaction content, so watching leaks nothing about what is queued. It runs until interrupted (Ctrl-C, SIGINT/SIGTERM), then reports how many notifications arrived. +`--watch` receives only a count, never transaction content, so watching leaks nothing about what is queued. It runs until interrupted (Ctrl-C, SIGINT/SIGTERM), then reports how many notifications arrived. At the end of `--timeout` it fails with `timeout` (exit `1`) instead; pass `--timeout 0` to watch until interrupted. The credentials are per-environment (mainnet / testnet); set them with [`config`](../config.md). The service owns its data; this command keeps no local copy. @@ -184,7 +184,7 @@ A record the client cannot reconcile with the chain stays visible and is labelle ## Exit status -`0` success · `1` execution failure (`not_found` — txId not on the service, `not_authorized`, `already_signed`, `tx_expired`, `auth_failed`, `provider_error` — service error or rate limit; TronLink reports **every** non-404 status this way, 429 included) · `2` usage error (`tronlink_credentials_missing`, `unsupported_network`, `invalid_value` — including an already-signed transaction passed to `--create`, conflicting modes). +`0` success · `1` execution failure (`not_found` — txId not on the service, `chain_id_mismatch` — the transaction was built for another chain, `rpc_error`, `not_authorized`, `already_signed`, `tx_expired`, `auth_failed`, `provider_error` — service error, or a malformed service record such as a hash that is not 32-byte hex; TronLink reports every status other than 404 and 429 this way; `provider_rate_limited` — TronLink returned HTTP 429, with `details.retryAfterSeconds` when it sent a usable `Retry-After`) · `2` usage error (`tronlink_credentials_missing`, `unsupported_network`, `invalid_value` — including an already-signed transaction passed to `--create`; `invalid_option` — conflicting modes, `--create` without exactly one of `--hex` or `--file`, or `--hex` / `--file` without `--create`). ## See also diff --git a/ts/docs/commands/tx/send.md b/ts/docs/commands/tx/send.md index 0d0ebeb74..cbb2eaef6 100644 --- a/ts/docs/commands/tx/send.md +++ b/ts/docs/commands/tx/send.md @@ -7,7 +7,7 @@ Send the native coin, or a token, with a human `--amount`. ``` wallet-cli tx send --to (--amount | --raw-amount ) [--token | --contract
| --asset-id ] - [--dry-run | --sign-only | --build-only | --wait [--wait-timeout ]] + [--dry-run | --sign-only | --build-only | --wait | --no-wait] [--fee-limit ] [--permission-id ] [--expiration ] [--gas-limit ] [--max-fee ] [--priority-fee ] [--nonce ] [options] @@ -18,7 +18,7 @@ wallet-cli tx send --to (--amount | --raw-amount ) Builds, signs, and submits a transfer from the active account (or `--account`), on TRON or EVM networks alike. What is sent depends on which selector you pass: - **none** → the network's native coin; -- `--token ` → token resolved from the local address book; +- `--token ` → token resolved from the local address book; the symbol is matched case-insensitively and must match exactly one entry; - `--contract
` → contract token by address — TRC20 on TRON, ERC20 on EVM; - `--asset-id ` → TRC10 by numeric asset id (**TRON only**). @@ -32,7 +32,7 @@ Omitted EVM values are taken from the node: the gas limit from `eth_estimateGas` TRON multi-sig uses `--permission-id` to select the signing group and `--expiration` to extend how long co-signers have to add their signatures. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed, or poll [`tx status`](status.md). +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`), and [`tx status`](status.md) checks it later. Requires an account — the active one, unless `--account ` overrides it. The master password via `--password-stdin` is needed only when the selected mode signs; `--dry-run` and `--build-only` never unlock the wallet. Signing commands show no interactive prompt, so a signing mode without the password fails with `auth_required`. @@ -48,7 +48,8 @@ Requires an account — the active one, unless `--account ` ove | `--dry-run` | Build and estimate through the selected network; no signing or broadcast; excludes `--sign-only` / `--build-only` | | `--sign-only` | Build, estimate, sign, and output the signed hex without broadcasting; excludes `--dry-run` / `--build-only` | | `--build-only` | Build and estimate, output the **unsigned** hex without unlocking; excludes `--dry-run` / `--sign-only` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default 60000; on cap returns the submitted receipt) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin | TRON only: @@ -105,10 +106,10 @@ wallet-cli tx send --to TSx72ViULFepRGCS4PM5dP4FqD1d8qggCc --amount 1 --network Tx {"to":"0x7...000000"} ``` -Submit receipt (default mode, text and json): +Submit receipt with `--no-wait` (text and json): ```bash -printf '%s' "$PW" | wallet-cli tx send --to TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --amount 1 --network nile --password-stdin +printf '%s' "$PW" | wallet-cli tx send --to TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH --amount 1 --network nile --no-wait --password-stdin ``` ```console @@ -129,9 +130,9 @@ printf '%s' "$PW" | wallet-cli tx send --to TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH - | Mode | Fields | |---|---| -| default (submit) | `kind: "send"`, `stage: "submitted"`, `txId`, `rawAmount` (string), `to`, plus `toContact` when `--to` was a contact name | -| `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `netUsed` (bandwidth used) / `feeSun` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | -| `--wait` (reverted) | the same fields, but `stage: "failed"` and `failed: true` — the transaction was mined and then reverted | +| `--no-wait` (submitted) | `kind: "send"`, `stage: "submitted"`, `txId`, `rawAmount` (string), `to`, plus `toContact` when `--to` was a contact name | +| default or `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `failed`, and the realised cost — `netUsed` (bandwidth used) / `feeSun` on TRON, `gasUsed` / `feeWei` / `effectiveGasPriceWei` on EVM | +| default or `--wait` (reverted) | the same fields, but `stage: "failed"` and `failed: true` — the transaction was mined and then reverted | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee`, the unsigned `tx`, `rawAmount`, `to` (plus `nonce` on EVM) | | `--sign-only` | `kind`, `mode: "sign-only"`, `hex` (signed transaction hex), `signed`, `address` (signer), `txId`, `fee`, `rawAmount`, `to` | | `--build-only` | `kind`, `mode: "build-only"`, `hex` (**unsigned** transaction hex), unsigned `tx`, `fee`, `rawAmount`, `to` | @@ -144,9 +145,11 @@ A reverted transaction still leaves the envelope at `success: true` and exit `0` ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`nonce_too_low` — `--dry-run` with an already-mined `--nonce`, `rpc_error`, `timeout` — **on timeout the tx may still be in flight; check `tx status` before resending**) · `2` usage error (conflicting selectors/amounts/modes; `family_mismatch` when the recipient or the account belongs to another family; `invalid_option` when a fee flag is scoped to the other family). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`insufficient_token_balance` — a TRC20 or ERC20 transfer above the sender's token balance, refused before the fee estimate and before signing in every mode including `--dry-run`; a failed balance read keeps its own code; `nonce_too_low` — `--dry-run` with an already-mined `--nonce`, `rpc_error`, `timeout`, `invalid_node_response` — **on submission these can leave the outcome unknown**) · `2` usage error (`invalid_option` — conflicting selectors/amounts/modes; `family_mismatch` when the recipient or the account belongs to another family; `invalid_option` when a fee flag is scoped to the other family; `token_not_in_book` — no address-book entry has the `--token` symbol; `ambiguous_token_symbol` — more than one entry has it, and the message lists their ids). -`0` also covers `--wait` reporting `stage: "failed"`: the exit code reflects the command, not the on-chain result. See [script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed). +A `timeout`, `rpc_error` or `invalid_node_response` from submission includes the locally computed signed transaction id in `error.details.txId` and [`error.details.outcome: "unknown"`](../../machine-interface.md#unknown-outcome-errordetailsoutcome); text output shows the id too. Check it with [`tx status`](status.md) before sending again. Resending `tx send` builds and signs a new transaction, which on TRON can be a second transfer. Definite rejections and failures before submission gain neither field. + +`0` also covers a receipt with `stage: "failed"`: the exit code reflects the command, not the on-chain result. See [script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed). ## See also diff --git a/ts/docs/commands/tx/sign.md b/ts/docs/commands/tx/sign.md index 2b24154af..4ce739027 100644 --- a/ts/docs/commands/tx/sign.md +++ b/ts/docs/commands/tx/sign.md @@ -12,9 +12,9 @@ wallet-cli tx sign (--hex | --file | --transaction ) [--offli Signs a transaction that was built somewhere else — on TRON, adding the active account's signature to an unsigned or partially signed hex and reporting how far the accumulated signing weight is from the permission group's threshold; on EVM, producing the single signature the transaction needs. -Two input modes: with `--hex` / `--file` it signs the transaction hex (protobuf on TRON, RLP `0x02…` on EVM); with `--transaction` it takes unsigned TRON transaction JSON, preserving the direct single-signature flow. `--transaction` is a TRON compatibility path and never contacts a node. +Two input modes: with `--hex` / `--file` it signs the transaction hex (protobuf on TRON, RLP `0x02…` on EVM); with `--transaction` it takes unsigned TRON transaction JSON, preserving the direct single-signature flow. `--transaction` is a TRON compatibility path and never contacts a node, so it cannot verify the network either and emits `network_not_verified` (see `--offline` below). -**On EVM there is no co-signing.** An EVM transaction carries exactly one signature, so a hex that already has one is refused with `invalid_transaction`, and there is no threshold, weight or permission group to report. The chain id inside the transaction is checked against `--network` and a mismatch is `chain_id_mismatch`. +**On EVM there is no co-signing.** An EVM transaction carries exactly one signature, so a hex that already has one is refused with `invalid_transaction`, and there is no threshold, weight or permission group to report. The chain id inside the transaction is checked against `--network`; a mismatch or a legacy transaction without a chain id is `chain_id_mismatch`. A typed transaction with chain id zero is accepted when the selected network also has chain id zero. On TRON it is the on-chain co-signing path: an initiator produces a partially signed hex with `tx send --sign-only` (or any broadcast command in `--sign-only` mode), each co-signer runs `tx sign` in turn — passing the hex from person to person — and once the weight reaches the threshold, anyone broadcasts the final hex with [`tx broadcast --hex`](broadcast.md). All signatures must be collected before the transaction expires (default ~60s, up to 24h via `--expiration`). @@ -22,13 +22,13 @@ Signing endorses the transaction with your key: software accounts read the maste ### What is verified before signing -**TRON.** With `--hex` / `--file` the command contacts the node and refuses to sign unless this account is a key in the transaction's permission group (`not_authorized`) and has not already approved it (`already_signed`). Both are checked *before* a key is decrypted, so a mistaken signer never produces a signature at all. +**TRON.** With `--hex` / `--file` the command contacts the node and refuses to sign unless the transaction was built for the selected network, this account is a key in the transaction's permission group (`not_authorized`), and it has not already approved it (`already_signed`). A TRON signature carries no chain id, so the network is checked through the transaction's reference block: a reference block the node does not hold is `chain_id_mismatch`, and one newer than the node's head is `rpc_error` — retry once the node has caught up, or use a node that is in sync. All three are checked *before* a key is decrypted, so a mistaken signer never produces a signature at all. -`--offline` skips those two checks and never contacts a node — for signing machines with no network. Signature-eligibility errors then surface only when the transaction is broadcast, so confirm the signing account is in the group beforehand. +`--offline` skips those checks and never contacts a node — for signing machines with no network. The result then carries the warning `network_not_verified`, and network and signature-eligibility errors surface only when the transaction is broadcast, so confirm the network and that the signing account is in the group beforehand. **EVM.** The transaction's chain id must match the selected network (`chain_id_mismatch`), and it must not already carry a signature (`invalid_transaction`). Both are local checks; no node is contacted to sign, so `--offline` changes nothing there. -Payload integrity is checked in every mode, offline included. That three-way check is TRON's — an EVM transaction hashes its own bytes, so there is nothing to disagree. A TRON transaction states its content three times — `raw_data` (what you read), `raw_data_hex` (what the node executes), and `txID` (what the signature actually covers) — and nothing in the format forces them to agree, so a transaction whose `raw_data` reads "1 TRX" can carry the `txID` of a 1000 TRX transfer. `tx sign` therefore refuses (`tx_integrity`) unless `txID` is the sha256 of `raw_data_hex`, and `raw_data` re-encodes to exactly those bytes wherever the contract type can be decoded. +Payload integrity is checked in every mode, offline included. That three-way check is TRON's — an EVM transaction hashes its own bytes, so there is nothing to disagree. A TRON transaction states its content three times — `raw_data` (what you read), `raw_data_hex` (what the node executes), and `txID` (what the signature actually covers) — and nothing in the format forces them to agree, so a transaction whose `raw_data` reads "1 TRX" can carry the `txID` of a 1000 TRX transfer. `tx sign` therefore refuses (`invalid_transaction`) unless `txID` is the sha256 of `raw_data_hex`, and `raw_data` re-encodes to exactly those bytes wherever the contract type can be decoded. Three contract types cannot be re-encoded field by field by the bundled decoder — `ShieldedTransferContract`, `MarketSellAssetContract`, and `MarketCancelOrderContract`. They are not refused: the command still verifies `txID = sha256(raw_data_hex)` and binds the declared contract type to the protobuf envelope, but it cannot independently prove that the human-readable fields inside `raw_data` match the executed ones. Treat those fields as unverified and inspect the artifact with tooling that understands the contract type before signing. `UnfreezeAssetContract` is fully re-encoded by the bundled TRC10 codec. @@ -40,7 +40,7 @@ Three contract types cannot be re-encoded field by field by the bundled decoder | `--file ` | **Required** (one of). File containing the transaction hex (prefer this for long hex) | | `--transaction ` | **Required** (one of). **TRON only.** Unsigned TRON transaction JSON; compatibility path, never checked online | | `--offline` | Sign locally without contacting a node; skips the signer-permission and approval-weight checks. Only with `--hex` / `--file`, and only meaningful on TRON — EVM signing contacts no node either way | -| `--out ` | Atomically write the signed transaction to a mode-0644 file — the co-signed protobuf hex on TRON, the signed RLP on EVM. It *adds* a file; the same hex stays in the result body, so a consumer that wants only the summary drops that field itself. Not valid with `--transaction` | +| `--out ` | Write the signed transaction to a new mode-0600 file; an existing path is refused (`output_exists`) — the co-signed protobuf hex on TRON, the signed RLP on EVM. It *adds* a file; the same hex stays in the result body, so a consumer that wants only the summary drops that field itself. Not valid with `--transaction` | Plus the [global options](../index.md#global-options-every-command) and `--password-stdin` for software accounts. @@ -148,7 +148,7 @@ No `fee` is reported for `--transaction`: nothing was estimated, because the tra ## Exit status -`0` success · `1` execution failure (`tx_integrity` — the three TRON payload representations disagree; `invalid_transaction` — an unusable payload, or on EVM one that is already signed; `chain_id_mismatch` — the EVM transaction was built for another chain; `tx_expired`, `not_authorized` — this account isn't in the group's key list, `already_signed`, `watch_only_no_signer`, `auth_failed`, `signing_rejected`, `rpc_error`) · `2` usage error (`invalid_value`, `missing_option`; `invalid_option` — `--transaction` on an EVM network). +`0` success · `1` execution failure (`invalid_transaction` — inconsistent TRON payload representations, an unusable payload, or on EVM one that is already signed; `chain_id_mismatch` — the transaction was built for another chain (on TRON, its reference block is not on the selected network); `tx_expired`, `not_authorized` — this account isn't in the group's key list, `already_signed`, `watch_only_no_signer`, `auth_failed`, `signing_rejected`, `rpc_error`) · `2` usage error (`invalid_value` — malformed values, `missing_option`; `invalid_option` — missing or conflicting transaction sources, or `--transaction` on an EVM network; `output_exists` — the `--out` path already exists). ## See also diff --git a/ts/docs/commands/tx/status.md b/ts/docs/commands/tx/status.md index 6947a1ee6..2ec3c261f 100644 --- a/ts/docs/commands/tx/status.md +++ b/ts/docs/commands/tx/status.md @@ -19,6 +19,8 @@ Reports which step a transaction is at, using **four states**, on TRON and EVM n | `pending` | Seen by the node, with no execution result / receipt yet | no — keep polling | | `not_found` | Unknown to the queried endpoint (wrong network, not propagated, dropped, or pruned); the outcome is unknown | no — keep polling and reconcile; do not assume failure | +> On TRON, a contract call (`TriggerSmartContract`, `CreateSmartContract`) that is mined while its execution result is still unknown — receipt result `DEFAULT` or no result — is reported as `confirmed` here. Waiting for confirmation on a write treats the same transaction as not yet confirmed and returns `submitted` when the wait ends. + > `confirmed` is an inclusion-and-receipt state, not a finality guarantee. If a workflow needs finality, verify it separately with a TRON SolidityNode view or an EVM finalized-block check. > A polling deadline that ends in `pending` or `not_found` is still an unknown outcome. Do not record it as a failure, and do not use it as an automatic resend trigger; reconcile the txid against the intended network and endpoint first. @@ -80,6 +82,8 @@ On EVM, `not_found` also carries a `meta.warnings` entry, because a public endpo | `blockNumber` | number | Present when confirmed | | `confirmations` | number | Blocks on top of the including block; present when confirmed | +On TRON, a mined transaction with top-level `FAILED` or a failing receipt result has `state: "failed"`. A receipt result of `DEFAULT` alone is not failure. Contract submission commands keep polling for an execution result before reporting confirmation. + ## Exit status `0` query answered (including `not_found`) · `1` execution failure (node unreachable, timeout) · `2` usage error. diff --git a/ts/docs/commands/typed-data/sign.md b/ts/docs/commands/typed-data/sign.md index 8c519b1c1..274fa95d4 100644 --- a/ts/docs/commands/typed-data/sign.md +++ b/ts/docs/commands/typed-data/sign.md @@ -12,15 +12,15 @@ wallet-cli typed-data sign --typed-data [options] Signs structured (typed) data with the active account's key (or `--account`), and prints the signature, the digest that was signed, and the primary type. Signing only — nothing is broadcast, and no node is contacted. -Works on TRON and EVM: the selected network decides which of the account's keys signs and which address is reported. EIP-712 and TIP-712 are the same construction, so the same payload can be signed for either — the domain inside the payload, not `--network`, is what a verifying contract checks. `--network` is optional and falls back to `config.defaultNetwork`. +Works on TRON and EVM. The selected network decides which of the account's keys signs and which address is reported, and must be the chain the payload is for: a `domain.chainId` that differs from the network's chain id is refused (`chain_id_mismatch`). `domain.chainId` must be an integer when present; zero is accepted on a network whose chain id is zero. A domain without `chainId` is signed as is. `--network` is optional and falls back to `config.defaultNetwork`. The `--typed-data` value is EIP-712 / TIP-712 JSON with the shape `{"domain":…,"types":…,"primaryType"?:…,"message":…}`. Three conveniences apply when it is parsed: - `EIP712Domain` inside `types` is **ignored** (you may include it or leave it out). - `value` is accepted as an alias for `message`. -- TRON **base58** addresses work in `address` fields, alongside `0x` hex. +- On TRON networks, **base58** addresses work in `address` fields, alongside `0x` hex. -`primaryType` is optional — it is inferred when omitted. +`primaryType` is optional — it is inferred when omitted. `domain` and `message` must be objects of JSON values; invalid members fail with `invalid_payload`. Like [`message sign`](../message/sign.md), this command **never prompts** for the master password: software accounts must pass `--password-stdin`. Watch-only accounts cannot sign (`watch_only_no_signer`). @@ -63,10 +63,10 @@ Ledger account — confirm on device, no master password needed: wallet-cli typed-data sign --typed-data "$(cat permit.json)" --network nile ``` -The same payload signed on an EVM network, with the account's EVM key: +A payload for an EVM chain, signed with the account's EVM key: ```bash -echo "$PW" | wallet-cli typed-data sign --typed-data "$(cat permit.json)" --password-stdin --network sepolia +echo "$PW" | wallet-cli typed-data sign --typed-data "$(cat permit-sepolia.json)" --password-stdin --network sepolia ``` ## Output @@ -80,7 +80,7 @@ echo "$PW" | wallet-cli typed-data sign --typed-data "$(cat permit.json)" --pass ## Exit status -`0` signed · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `signing_rejected` — declined on the Ledger, `ledger_setting_required` — enable blind signing / EIP-712 support in the device app, `ledger_unsupported` — the device app cannot sign this payload) · `2` usage error (`--typed-data` missing → `missing_option`, or its value is not JSON → `invalid_value`). +`0` signed · `1` execution failure (`chain_id_mismatch` — `domain.chainId` is not the selected network's chain id, `watch_only_no_signer`, `auth_failed`, `signing_rejected` — declined on the Ledger, `ledger_setting_required` — enable blind signing / EIP-712 support in the device app, `ledger_unsupported` — the device app cannot sign this payload) · `2` usage error (`--typed-data` missing → `missing_option`, its value is not JSON → `invalid_value`, or its domain/message is not an object of JSON values → `invalid_payload`). ## See also diff --git a/ts/docs/commands/use.md b/ts/docs/commands/use.md index c2750bd38..03d08d02f 100644 --- a/ts/docs/commands/use.md +++ b/ts/docs/commands/use.md @@ -44,7 +44,7 @@ wallet-cli use main-1 -o json | Field | Type | Meaning | |---|---|---| -| `previous` | string | Account id that was active before | +| `previous` | string \| null | Account id that was active before; `null` when none was | | `accountId` | string | Now-active account id | | `label` | string | Account label | | `type` | string | `seed` / `privateKey` / `watch` / `ledger` | diff --git a/ts/docs/commands/vote/cast.md b/ts/docs/commands/vote/cast.md index 354088115..12f908952 100644 --- a/ts/docs/commands/vote/cast.md +++ b/ts/docs/commands/vote/cast.md @@ -5,7 +5,7 @@ Cast or replace your full vote allocation. ## Synopsis ``` -wallet-cli vote cast --for ... [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] +wallet-cli vote cast --for ... [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -18,9 +18,9 @@ Two hard rules from the chain: - **Total votes ≥ 1** — you cannot vote everything down to zero. To stop voting entirely, unstake the backing TRX ([`stake unfreeze`](../stake/unfreeze.md)); TP falls and the votes lapse. - **At most 30 entries per transaction** (java-tron `MAX_VOTE_NUMBER`). Since a cast is the full distribution, an account holds votes on at most 30 SRs — only 27 are ever elected, so normal voting never hits this. -Votes take effect at the next maintenance cycle (~6 h). Each vote uses 1 TP (it is allocated, not spent — re-casting or unstaking frees it); available TP = staked TRX minus votes already placed ([`vote status`](status.md)). +Votes take effect at the next maintenance cycle (the chain parameter `getMaintenanceTimeInterval`: 6 h on mainnet, 30 min on Nile). Each vote uses 1 TP (it is allocated, not spent — re-casting or unstaking frees it); available TP = staked TRX minus votes already placed ([`vote status`](status.md)). -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -32,7 +32,8 @@ Votes take effect at the next maintenance cycle (~6 h). Each vote uses 1 TP (it | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -41,32 +42,32 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. -Default — broadcasts and returns the **submitted** receipt without waiting: +With `--no-wait` — broadcasts and returns the **submitted** receipt without waiting: ```bash -echo "$PW" | wallet-cli vote cast --for TZ4...=600 --for TT5...=400 --network nile --password-stdin +echo "$PW" | wallet-cli vote cast --for TZ4...=600 --for TT5...=400 --network nile --no-wait --password-stdin ``` ```console ⏳ Voted 1,000 TP across 2 witnesses Votes TZ4...=600, TT5...=400 TxID e5f... - Status pending — tallied at next maintenance cycle (~6h) + Status pending — tallied at next maintenance cycle (~30m) ! Track it: wallet-cli tx info --network tron:3448148188 --txid e5f... ``` ```bash -echo "$PW" | wallet-cli vote cast --for TZ4...=600 --for TT5...=400 --network nile --password-stdin -o json +echo "$PW" | wallet-cli vote cast --for TZ4...=600 --for TT5...=400 --network nile --no-wait --password-stdin -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"vote.cast","data":{"kind":"vote-cast","stage":"submitted","txId":"e5f...","votes":[{"witness":"TZ4...","count":600},{"witness":"TT5...","count":400}],"totalVotes":1000},"meta":{"durationMs":18,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"vote.cast","data":{"kind":"vote-cast","stage":"submitted","txId":"e5f...","votes":[{"witness":"TZ4...","count":600},{"witness":"TT5...","count":400}],"totalVotes":1000,"maintenanceIntervalMs":1800000},"meta":{"durationMs":18,"warnings":[]},"chain":{"family":"tron","network":"tron:3448148188","chainId":"3448148188"}} ``` -Add `--wait` to block until the vote is confirmed on chain (adds real block / fee): +Without `--no-wait`, the command blocks until the vote is confirmed on chain (adds real block / fee): ```bash -echo "$PW" | wallet-cli vote cast --for TZ4...=600 --for TT5...=400 --network nile --wait --password-stdin +echo "$PW" | wallet-cli vote cast --for TZ4...=600 --for TT5...=400 --network nile --password-stdin ``` ```console @@ -75,7 +76,7 @@ echo "$PW" | wallet-cli vote cast --for TZ4...=600 --for TT5...=400 --network ni TxID f8a... Block #84,121,055 Fee 0 TRX - Status success — tallied at next maintenance cycle (~6h) + Status success — tallied at next maintenance cycle (~30m) ``` ## Output @@ -84,12 +85,12 @@ echo "$PW" | wallet-cli vote cast --for TZ4...=600 --for TT5...=400 --network ni | Stage | Fields | |---|---| -| default (submit) | `kind: "vote-cast"`, `stage: "submitted"`, `txId`, `votes[]` (`witness`, `count`), `totalVotes` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `failed` | +| `--no-wait` (submitted) | `kind: "vote-cast"`, `stage: "submitted"`, `txId`, `votes[]` (`witness`, `count`), `totalVotes`, `maintenanceIntervalMs` (the network's maintenance cycle length, `getMaintenanceTimeInterval`, in ms — the longest wait until the votes are tallied; omitted when the chain parameter cannot be read) | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `failed` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`) · `2` usage error (`insufficient_voting_power` — the total exceeds the account's **total** TP; votes already placed are re-allocated rather than added, so the check is against total, not available; `invalid_value` — bad SR address, non-positive count, > 30 entries). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`) · `2` usage error (`insufficient_voting_power` — the total exceeds the account's **total** TP; votes already placed are re-allocated rather than added, so the check is against total, not available; `invalid_value` — bad SR address, non-positive count, > 30 entries). ## See also diff --git a/ts/docs/commands/vote/index.md b/ts/docs/commands/vote/index.md index 79cef71df..569353b89 100644 --- a/ts/docs/commands/vote/index.md +++ b/ts/docs/commands/vote/index.md @@ -2,7 +2,7 @@ Vote for super representatives (SR). -Voting spends **Tron Power (TP)**: 1 TP = 1 staked TRX ([`stake freeze`](../stake/freeze.md)). Votes are tallied at the next maintenance cycle (~6 h) and then earn rewards continuously — query and claim them with [`reward`](../reward/index.md). +Voting spends **Tron Power (TP)**: 1 TP = 1 staked TRX ([`stake freeze`](../stake/freeze.md)). Votes are tallied at the next maintenance cycle (6 h on mainnet, 30 min on Nile) and then earn rewards continuously — query and claim them with [`reward`](../reward/index.md). > **TRON only.** Every command in this group implements a TRON protocol feature with no EVM counterpart; on an EVM network they fail with `family_mismatch` before any node call. diff --git a/ts/docs/commands/vote/status.md b/ts/docs/commands/vote/status.md index fdd3cfaf6..f627c513c 100644 --- a/ts/docs/commands/vote/status.md +++ b/ts/docs/commands/vote/status.md @@ -59,6 +59,8 @@ wallet-cli vote status --account main --network nile -o json Zero-reward-ratio warnings appear in `meta.warnings` as plain strings — see [reading `meta.warnings`](../../machine-interface.md#reading-metawarnings). +Malformed TRON account data fails with `provider_error`. + ## Exit status `0` success · `1` execution failure (`rpc_error`) · `2` usage error (`invalid_value`). diff --git a/ts/docs/commands/witness/create.md b/ts/docs/commands/witness/create.md index 0fd01c4d0..38ddbbe80 100644 --- a/ts/docs/commands/witness/create.md +++ b/ts/docs/commands/witness/create.md @@ -6,7 +6,7 @@ Register the account as a super representative candidate. ``` wallet-cli witness create --url - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -18,7 +18,7 @@ Registers the acting account as an SR candidate, making it votable and eligible The account must already be activated and hold at least the registration fee. `--url` is the candidate info page — the website explorers show next to the SR — and is the only business field the chain stores for a candidate; change it later with [`witness update`](update.md). -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. The Ledger TRON app cannot sign witness contract types. A Ledger account can dry-run or build; signing modes fail with `ledger_unsupported` before the device is touched. @@ -32,7 +32,8 @@ The Ledger TRON app cannot sign witness contract types. A Ledger account can dry | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -42,7 +43,7 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli witness create --url https://sr.acme.io --network nile --wait --password-stdin +echo "$PW" | wallet-cli witness create --url https://sr.acme.io --network nile --password-stdin ``` ```console @@ -56,7 +57,7 @@ echo "$PW" | wallet-cli witness create --url https://sr.acme.io --network nile - ``` ```bash -echo "$PW" | wallet-cli witness create --url https://sr.acme.io --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli witness create --url https://sr.acme.io --network nile --password-stdin -o json ``` ```json @@ -69,14 +70,14 @@ echo "$PW" | wallet-cli witness create --url https://sr.acme.io --network nile - | Stage | Fields | |---|---| -| default (submit) | `kind: "witness-create"`, `stage: "submitted"`, `txId`, `witnessAddress`, `url`, `feeSun`, and `registrationFeeSun` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed`, and `registrationFeeSun` | +| `--no-wait` (submitted) | `kind: "witness-create"`, `stage: "submitted"`, `txId`, `witnessAddress`, `url`, `feeSun`, and `registrationFeeSun` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed`, and `registrationFeeSun` | `registrationFeeSun` and `feeSun` are decimal strings carrying the same irreversible registration burn. The command deliberately overwrites the node receipt's bandwidth/energy fee with that economically relevant amount, so do not add the two together. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`already_witness`, `account_not_active`, `insufficient_balance` — below the registration fee, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`missing_option` — no `--url`). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`already_witness`, `account_not_active`, `insufficient_balance` — below the registration fee, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`missing_option` — no `--url`). ## See also diff --git a/ts/docs/commands/witness/set-brokerage.md b/ts/docs/commands/witness/set-brokerage.md index da0780aad..ca662332b 100644 --- a/ts/docs/commands/witness/set-brokerage.md +++ b/ts/docs/commands/witness/set-brokerage.md @@ -6,7 +6,7 @@ Set the share of block rewards the SR keeps. ``` wallet-cli witness set-brokerage - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -18,7 +18,7 @@ This is the same number [`vote list`](../vote/list.md) reports as `brokeragePct` Any registered witness can set it, elected or not. The acting account must be a candidate; otherwise the command fails with `not_a_witness`. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. The Ledger TRON app cannot sign witness contract types. A Ledger account can dry-run or build; signing modes fail with `ledger_unsupported` before the device is touched. @@ -32,7 +32,8 @@ The Ledger TRON app cannot sign witness contract types. A Ledger account can dry | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -44,7 +45,7 @@ In the examples, `$PW` is your master password (from an environment variable, pa Keep 20 %, pass 80 % to voters: ```bash -echo "$PW" | wallet-cli witness set-brokerage 20 --network nile --wait --password-stdin +echo "$PW" | wallet-cli witness set-brokerage 20 --network nile --password-stdin ``` ```console @@ -58,7 +59,7 @@ echo "$PW" | wallet-cli witness set-brokerage 20 --network nile --wait --passwor ``` ```bash -echo "$PW" | wallet-cli witness set-brokerage 20 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli witness set-brokerage 20 --network nile --password-stdin -o json ``` ```json @@ -71,14 +72,14 @@ echo "$PW" | wallet-cli witness set-brokerage 20 --network nile --wait --passwor | Stage | Fields | |---|---| -| default (submit) | `kind: "witness-set-brokerage"`, `stage: "submitted"`, `txId`, `witnessAddress`, `brokerage` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | +| `--no-wait` (submitted) | `kind: "witness-set-brokerage"`, `stage: "submitted"`, `txId`, `witnessAddress`, `brokerage` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | `brokerage` is the value now in effect, as a number. ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`missing_option` — no percent given; `invalid_value` — percent not an integer, or outside 0–100). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`missing_option` — no percent given; `invalid_value` — percent not an integer, or outside 0–100). ## See also diff --git a/ts/docs/commands/witness/update.md b/ts/docs/commands/witness/update.md index 8bb74245e..e23e431bc 100644 --- a/ts/docs/commands/witness/update.md +++ b/ts/docs/commands/witness/update.md @@ -6,7 +6,7 @@ Change the candidate info page URL. ``` wallet-cli witness update --url - [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait | --no-wait] [--permission-id ] [options] ``` @@ -16,7 +16,7 @@ Replaces the info page URL of an existing SR candidacy. The url is the only fiel The acting account must already be a candidate; otherwise the command fails with `not_a_witness`. -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. +**By default the command waits for confirmation** (`stage: "confirmed"` or `"failed"`); `--no-wait` returns at submission (`stage: "submitted"`). Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. The Ledger TRON app cannot sign witness contract types. A Ledger account can dry-run or build; signing modes fail with `ledger_unsupported` before the device is touched. @@ -30,7 +30,8 @@ The Ledger TRON app cannot sign witness contract types. A Ledger account can dry | `--build-only` | Build and estimate, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | | `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | -| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--wait` | Poll after broadcast until confirmed or failed, up to the command deadline (`--timeout`); at the deadline it returns the submitted receipt with a warning. This is the default | +| `--no-wait` | Return the submitted receipt without polling for confirmation | | `--password-stdin` | Master password from stdin (fd 0) | Plus the [global options](../index.md#global-options-every-command). @@ -40,7 +41,7 @@ Plus the [global options](../index.md#global-options-every-command). In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -echo "$PW" | wallet-cli witness update --url https://sr.acme.io/v2 --network nile --wait --password-stdin +echo "$PW" | wallet-cli witness update --url https://sr.acme.io/v2 --network nile --password-stdin ``` ```console @@ -54,7 +55,7 @@ echo "$PW" | wallet-cli witness update --url https://sr.acme.io/v2 --network nil ``` ```bash -echo "$PW" | wallet-cli witness update --url https://sr.acme.io/v2 --network nile --wait --password-stdin -o json +echo "$PW" | wallet-cli witness update --url https://sr.acme.io/v2 --network nile --password-stdin -o json ``` ```json @@ -67,12 +68,12 @@ echo "$PW" | wallet-cli witness update --url https://sr.acme.io/v2 --network nil | Stage | Fields | |---|---| -| default (submit) | `kind: "witness-update"`, `stage: "submitted"`, `txId`, `witnessAddress`, `url` | -| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | +| `--no-wait` (submitted) | `kind: "witness-update"`, `stage: "submitted"`, `txId`, `witnessAddress`, `url` | +| default or `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, flat settlement fields when returned (`feeSun`, `energyUsed`, `netUsed`, `energyFeeSun`, `netFeeSun`), their governance compatibility view `resource` (`netUsage`, `netFeeSun`, `energyUsage`, `energyFeeSun`), and `failed` | ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`missing_option` — no `--url`). +`0` submitted, with `stage` `confirmed`, `failed`, or `submitted` (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `ledger_unsupported`, `auth_failed`) · `2` usage error (`missing_option` — no `--url`). ## See also diff --git a/ts/docs/commands/x402/index.md b/ts/docs/commands/x402/index.md index 14b2ef243..1e29dc05c 100644 --- a/ts/docs/commands/x402/index.md +++ b/ts/docs/commands/x402/index.md @@ -26,7 +26,7 @@ wallet-cli x402 COMMAND - **Routes follow `--network`.** `pay` only considers routes on the selected network, and a catalog service has a separate URL for each network — [`x402 endpoint-list`](endpoint-list.md) shows them. The catalog's services currently take payment on mainnets (`tron`, `bsc`, `base`); for trying things out on a testnet, run your own endpoint with [`x402 serve`](serve.md). - **Limits come before signing.** `--max-amount` refuses an over-priced route, and a route that matches none of your filters is refused, both before anything is signed — the error says `paymentStatus: "not_sent"`. -- **The facilitator settles, so there is no `--wait`.** `pay` and `roundtrip` return once the facilitator has settled the payment; they do not take `--wait` / `--wait-timeout`. +- **The facilitator settles, so there is no `--wait`.** `pay` and `roundtrip` return once the facilitator has settled the payment; they refuse `--wait` and `--no-wait` with `invalid_option`. - **Two schemes.** `exact` pays from the account's token balance; `exact_gasfree` pays from its [GasFree](../gasfree/index.md) account, with no fallback between them. - **A failed payment may still have gone through.** Read `paymentStatus` and `retryPayment` in `error.details` before paying again — see [x402 and B.AI payment details](../../machine-interface.md#x402-and-bai-payment-details). - **Catalog commands read the local cache first**, written by `update-catalog`, and download only what it lacks. They never refresh the cache themselves. diff --git a/ts/docs/commands/x402/pay.md b/ts/docs/commands/x402/pay.md index 15436a877..4a87fcbf9 100644 --- a/ts/docs/commands/x402/pay.md +++ b/ts/docs/commands/x402/pay.md @@ -23,7 +23,7 @@ Sends the request. If the endpoint answers with a successful status (2xx), that - `exact` — pays from the account's own token balance. - `exact_gasfree` — pays from the account's GasFree account (TRON). The GasFree balance must cover the price **plus** the maximum service fee, or it fails with `gasfree_insufficient_balance` before anything is sent; there is no fallback to the ordinary balance. `--gasfree-relay` chooses where the GasFree account data comes from: `official` (default, no credentials), `gasfree` (the GasFree Open API, which needs `gasfreeApiKey` / `gasfreeApiSecret` from [`config`](../config.md)), or an HTTPS URL of your own. `--max-gasfree-fee` caps the fee you authorize. -**A first `exact` payment on TRON may send an approve first.** A `permit2` route pays through the Permit2 contract, which needs a token allowance. If the account has none and the endpoint does not sponsor the approval, `pay` signs and broadcasts `approve(Permit2, MaxUint256)` from the account before signing the payment: that transaction burns the account's own Energy or TRX, and it grants Permit2 an **unlimited** allowance on that token, which stays in place after the payment. If the endpoint sponsors the approval, the approve is signed into the payment package instead of being broadcast by the account. Either way the result reports it in `data.approval`; later payments in the same token reuse the allowance and skip this step. +**A first `exact` payment on TRON may send an approve first.** A `permit2` route pays through the Permit2 contract, which needs a token allowance. If the account has none and the endpoint does not sponsor the approval, `pay` signs and broadcasts `approve(Permit2, MaxUint256)` from the account before signing the payment: that transaction burns the account's own Energy or TRX (fee limit 100 TRX), and it grants Permit2 an **unlimited** allowance on that token, which stays in place after the payment. `pay` waits within the command's `--timeout` for the approve to be confirmed and checks the new allowance before it signs the payment; if the approve fails or is not confirmed in time, nothing is paid. If the endpoint sponsors the approval, the approve is signed into the payment package instead of being broadcast by the account. Either way the result reports it in `data.approval`; later payments in the same token reuse the allowance and skip this step. `--dry-run` stops after reading the challenge: it reports the route that would be paid, without signing. @@ -35,7 +35,7 @@ The response body is returned in `data.response` — parsed for `application/jso A response format error can occur **after payment has settled**. In that case `error.details` retains `paymentStatus: "settled"`, `settled: true`, the transaction hash, and `retryPayment: false`. Do not pay again to recover the response; reconcile with the provider using the transaction hash. Choose `--out` before requesting a binary resource. -**When a payment fails, read `error.details` before trying again.** `paymentStatus: "not_sent"` means nothing left the account; `"unknown"` means the CLI cannot tell, so treat it as possibly paid and reconcile first; `retryPayment: false` means do not pay again to recover. See [x402 and B.AI payment details](../../machine-interface.md#x402-and-bai-payment-details). +**When a payment fails, read `error.details` before trying again.** `paymentStatus: "not_sent"` means nothing left the account; `"unknown"` means the CLI cannot tell, so treat it as possibly paid and reconcile first (the error then also carries [`outcome: "unknown"`](../../machine-interface.md#unknown-outcome-errordetailsoutcome)); `retryPayment: false` means do not pay again to recover. See [x402 and B.AI payment details](../../machine-interface.md#x402-and-bai-payment-details). Requires an account, even for an endpoint that turns out to be free. The master password (via `--password-stdin`) is needed only when a payment is signed. @@ -49,8 +49,8 @@ Requires an account, even for an endpoint that turns out to be free. The master |---|---| | `--method ` | HTTP method (default `GET`) | | `--header <"Name: value">` | Request header in `Name: value` form; repeat for more | -| `--body ` | Request body; excludes `--body-file` | -| `--body-file ` | Read the request body from a file, or from stdin with `-`; excludes `--body` | +| `--body ` | Request body; excludes `--body-file`; not with `--method GET` | +| `--body-file ` | Read the request body from a file, or from stdin with `-`; excludes `--body`; not with `--method GET` | | `--max-amount ` | Refuse a route priced above this, in whole tokens; excludes `--max-raw-amount` | | `--max-raw-amount ` | The same limit in smallest units; requires `--token` or `--asset`, since a raw amount has no unit | | `--token ` | Accept only routes paying in this token | @@ -59,12 +59,12 @@ Requires an account, even for an endpoint that turns out to be free. The master | `--scheme ` | Accept only routes using this scheme | | `--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 | +| `--max-gasfree-fee-raw ` | The same cap in smallest units: a positive uint256, like `--max-raw-amount` (`invalid_amount` otherwise, before any request) | | `--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 | -Plus the [global options](../index.md#global-options-every-command). `--timeout` limits each HTTP request. +Plus the [global options](../index.md#global-options-every-command). `--timeout` limits the whole command, including the approve and every HTTP request. The paid request is not sent with less than 2000 ms left; the command then fails with `timeout`, `phase: "payment_request"` and `retryPayment: false`. ## Examples @@ -142,14 +142,14 @@ printf '%s' "$PW" | wallet-cli x402 pay https://x402-gateway.bankofai.io/provide | `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 | +| `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; when the approve itself fails after it was sent, `error.details.txId` carries its transaction ID instead, with `outcome: "unknown"` unless it is known to have failed on chain | | `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`) | ## 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`, `--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). +`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`, `chain_id_mismatch` — the payment names a chain other than the selected network, `signed_payload_mismatch` — the endpoint asked for a transaction other than the Permit2 approve of the payment token, the approve's own failures — `transaction_rejected` (the node refused it), `execution_reverted` (it failed on chain, or left too little allowance), `timeout` (not confirmed within `--timeout`; it may still confirm, `outcome: "unknown"`), and `rpc_error` / `invalid_node_response` (its outcome is unknown) — all with `paymentStatus: "not_sent"`, and all but `transaction_rejected` with the approve's `error.details.txId`; `invalid_settlement`, `invalid_x402_response`, `response_too_large`, `provider_rate_limited` — HTTP 429; `tx_expired` — the payment authorization expired or the clock is out of sync, `paymentStatus: "unknown"`; `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, a request body with `--method GET`, `--decimals` without `--asset`, `--max-raw-amount` without `--token` or `--asset`, or `--wait` / `--no-wait`, which this command does not take because the facilitator settles the payment; `invalid_amount` — a limit that is not a positive amount, or `--max-amount` with more decimals than the token; `invalid_value` — e.g. a malformed `--header`, an unsupported `--method`, or a `--gasfree-relay` URL that is not HTTPS). ## See also diff --git a/ts/docs/commands/x402/roundtrip.md b/ts/docs/commands/x402/roundtrip.md index 4dd655c00..4ad6719e4 100644 --- a/ts/docs/commands/x402/roundtrip.md +++ b/ts/docs/commands/x402/roundtrip.md @@ -17,6 +17,8 @@ Runs [`x402 serve`](serve.md) and [`x402 pay`](pay.md) in one step: it starts th **This is a real payment** of the price to `--pay-to`, and `pay` only accepts the route the server was started with, so nothing else can be paid. +One `--timeout` covers the whole run: start-up, the payment, and the local server's verification and settlement. The server is then closed without waiting for open connections. + The server always binds to `127.0.0.1`. The same validation as `x402 serve` applies to the price, token, scheme, port and facilitator options. Requires an account and the master password (via `--password-stdin`). ## Options @@ -77,7 +79,7 @@ printf '%s' "$PW" | wallet-cli x402 roundtrip --pay-to TMSgJxtPw29AFEHMXsjGo4kWV ## Exit status -`0` paid · `1` execution failure (the payment errors of [`x402 pay`](pay.md#exit-status); `auth_required` — no `--password-stdin`; `missing_wallet_address`; `port_in_use`) · `2` usage error (the usage errors of [`x402 serve`](serve.md#exit-status); `invalid_option` — two exclusive options (`--amount` / `--raw-amount`, `--token` / `--asset`, both GasFree fee caps), `--decimals` without `--asset`, or `--wait` / `--wait-timeout`, which this command does not take; `gasfree_credentials_missing`). +`0` paid · `1` execution failure (the payment errors of [`x402 pay`](pay.md#exit-status); `auth_required` — no `--password-stdin`; `missing_wallet_address`; `port_in_use`) · `2` usage error (the usage errors of [`x402 serve`](serve.md#exit-status); `invalid_option` — two exclusive options (`--amount` / `--raw-amount`, `--token` / `--asset`, both GasFree fee caps), `--decimals` without `--asset`, or `--wait` / `--no-wait`, which this command does not take; `gasfree_credentials_missing`). ## See also diff --git a/ts/docs/commands/x402/serve.md b/ts/docs/commands/x402/serve.md index 5dc1d0a04..09539381d 100644 --- a/ts/docs/commands/x402/serve.md +++ b/ts/docs/commands/x402/serve.md @@ -30,6 +30,8 @@ What the server answers: **On TRON the facilitator is asked first.** Before listening, the server reads `--facilitator-url`'s `/supported` list and advertises the network the way the facilitator spells it for x402 v2 and the chosen scheme — the decimal id (`tron:3448148188`) when supported, otherwise the facilitator's hex form (such as `tron:0xcd8690dc`). If the list cannot be read or has no matching entry, the server does not start. [`x402 roundtrip`](roundtrip.md) and [`bai recharge`](../bai/recharge.md) run the same check. +**Time limits.** `--timeout` limits start-up: the facilitator check and listening. After that, each `/pay` request gets its own limit of the same length, shared by verification and settlement. Settlement does not start with less than 2000 ms of the request's time left, or after the client has disconnected; the response is then `502` with `code` `timeout` or `aborted`, `phase: "settle"`, `paymentStatus: "not_sent"` and `retryPayment: false`. A settlement that has started is not stopped by a disconnect: the server waits for the facilitator's answer until the request's limit ends, then stops waiting and answers `502` with `code` `timeout`, `phase: "settle"` and `paymentStatus: "unknown"`; stopping the wait does not undo a settlement the facilitator has made. With `--timeout 0` requests have no limit, so a started settlement is awaited until the facilitator answers. Stopping the server closes open connections. With `--daemon`, the command waits up to `--timeout` for the background server to be listening (with `0`, until it is listening or has exited); a server that is not ready in time is stopped and the command fails with `timeout` and its log file in `error.details.logFile`. + **Access log.** Each request is logged with its status and duration — never its URL query, headers, body or payment signature. In the foreground the lines go to stderr, as `Payment required: HTTP 402 (1 ms)` in text mode or `{"event":"x402.request","method":"GET","route":"/pay","status":402,"durationMs":1}` in JSON mode; with `--daemon` they go to the log file. ## Options @@ -124,7 +126,7 @@ Printed once at start-up: ## Exit status -`0` stopped with Ctrl-C, or with `--daemon` the server started · `1` execution failure (`port_in_use` — the port is taken; `provider_error` — the facilitator's `/supported` list could not be read, or with `--daemon` the background server failed to start, including when the port is taken, with its log file in `error.details.logFile`; `provider_rate_limited` — the facilitator answered 429; `invalid_x402_response` — its `/supported` list is malformed) · `2` usage error (`unsupported_network_capability` — on TRON, the facilitator does not support this network and scheme; `missing_option` — no `--pay-to`; `invalid_address` — `--pay-to` or `--asset` is not a valid address; `family_mismatch` — `--pay-to` belongs to the other chain family; `invalid_option` — `--amount` with `--raw-amount`, `--token` with `--asset`, `--decimals` without `--asset`, or `--decimals` that differs from a known token's; `invalid_amount` — the price is not positive or has more decimals than the token; `invalid_value` — the token is not registered on the network and no `--asset` with `--decimals` was given, `exact_gasfree` on an EVM network, `--facilitator-url` is not HTTPS, `--resource-url` is not HTTP(S), `--host` is not a loopback address, `--port` is outside 1–65535, or `--valid-for-seconds` is outside 1–86400). +`0` stopped with Ctrl-C, or with `--daemon` the server started · `1` execution failure (`port_in_use` — the port is taken; `provider_error` — the facilitator's `/supported` list could not be read, or with `--daemon` the background server failed to start, including when the port is taken, with its log file in `error.details.logFile`; `timeout` — start-up did not finish within `--timeout`; with `--daemon`, with its log file in `error.details.logFile`; `provider_rate_limited` — the facilitator answered 429; `invalid_x402_response` — its `/supported` list is malformed) · `2` usage error (`unsupported_network_capability` — on TRON, the facilitator does not support this network and scheme; `missing_option` — no `--pay-to`; `invalid_address` — `--pay-to` or `--asset` is not a valid address; `family_mismatch` — `--pay-to` belongs to the other chain family; `invalid_option` — `--amount` with `--raw-amount`, `--token` with `--asset`, `--decimals` without `--asset`, or `--decimals` that differs from a known token's; `invalid_amount` — the price is not positive or has more decimals than the token; `invalid_value` — the token is not registered on the network and no `--asset` with `--decimals` was given, `exact_gasfree` on an EVM network, `--facilitator-url` is not HTTPS, `--resource-url` is not HTTP(S), `--host` is not a loopback address, `--port` is outside 1–65535, or `--valid-for-seconds` is outside 1–86400). ## See also diff --git a/ts/docs/concepts/accounts-and-hd.md b/ts/docs/concepts/accounts-and-hd.md index f02ef0ac8..bd0cef829 100644 --- a/ts/docs/concepts/accounts-and-hd.md +++ b/ts/docs/concepts/accounts-and-hd.md @@ -35,9 +35,9 @@ Which one a command acts as follows the **selected network**, not a setting on t Ledger accounts use a different template: `import ledger --index ` follows Ledger Live's `m/44'/'/'/0/0`. The two agree only at index 0. See [`import ledger`](../commands/import/ledger.md). -### Accounts from before 4.13.1 +### Seed accounts with an alternate TRON path -Earlier versions derived TRON accounts at `m/44'/195'/'/0/0`. For account #0 that is the same path, so nothing changes. Accounts #1 and later keep their old TRON address, but this version no longer signs with it: +A stored seed account can use `m/44'/195'/'/0/0`. At index 0 this matches the default path. For indexes greater than zero, the stored TRON address is retained, with these restrictions: - Signing as that TRON address fails with `legacy_derivation`. The account's EVM address, `--build-only`, and queries still work. - `derive` refuses to add new accounts to that seed with `legacy_derivation`. @@ -68,7 +68,7 @@ Labels are unique, 1–64 chars, renameable (`rename`) — the stable handle is ## Lifecycle - `backup ` exports secret + metadata to a file created with mode **0600** and never overwritten (in the current working directory by default). Treat the file as the secret it contains — and mind where you run it, since the CLI does not check whether that directory is shared or version-controlled. The native format carries the seed or key itself, so it covers every family at once; `backup --keystore` carries a **single private key**, so `--network` selects which family's key it holds. -- `delete` removes accounts; **deleting an HD wallet cascades from the seed root** — all derived accounts of that seed go with it. The on-chain assets are untouched. Run `backup` first and follow any warning it prints — see [Accounts from before 4.13.1](#accounts-from-before-4131). +- `delete` removes accounts; **deleting an HD wallet cascades from the seed root** — all derived accounts of that seed go with it. The on-chain assets are untouched. Run `backup` first and follow any warning it prints — see [Seed accounts with an alternate TRON path](#seed-accounts-with-an-alternate-tron-path). - Losing the master password is unrecoverable locally; the escape hatch is always the mnemonic → `import mnemonic`. ## See also diff --git a/ts/docs/concepts/networks.md b/ts/docs/concepts/networks.md index 855d66129..958d3fbf9 100644 --- a/ts/docs/concepts/networks.md +++ b/ts/docs/concepts/networks.md @@ -34,16 +34,18 @@ wallet-cli networks An **alias** is a short name you may type instead of the id. It resolves once, at selection, and nothing downstream ever sees it — `chain.network` in the JSON envelope always reports the canonical id. Aliases live in config and can be re-pointed, so scripts should pass canonical ids. The TRON ids used before CAIP-2 — `tron:mainnet`, `tron:nile`, `tron:shasta` — remain permanent aliases. -For every built-in network the **chain id** is the canonical id's second segment. It is a separate field rather than a derived one: a network added in `config.yaml` supplies its own `chainId`, and nothing checks that the two agree. On EVM it is the EIP-155 number every signature commits to (`56`); on TRON it is the decimal genesis-hash prefix (`3448148188`), which nothing but the display layer reads. The readable name for a TRON network lives in its alias (`nile`), not in this field. +For every built-in network the **chain id** is the canonical id's second segment. It is a separate field rather than a derived one: a network added in `config.yaml` supplies its own `chainId`, and nothing checks that the two agree. On EVM it is the EIP-155 number every signature commits to (`56`); on TRON it is the decimal genesis-hash prefix (`3448148188`), also used to check a typed-data domain's chain id. The readable name for a TRON network lives in its alias (`nile`), not in this field. Point a network at your own node, or at a commercial endpoint, with [`config`](../commands/config.md): ```bash wallet-cli config networks.tron:3448148188.httpEndpoint http://127.0.0.1:8090 wallet-cli config networks.tron:728126428.apiKeyHeader TRON-PRO-API-KEY -wallet-cli config networks.tron:728126428.apiKey +printf '%s\n' "$TRONGRID_KEY" | wallet-cli config networks.tron:728126428.apiKey --api-key-stdin ``` +The API key is a credential: it is read only from `--api-key-stdin`, never from the command line, so it stays out of shell history and process listings. + Listings (`networks`, `config`) print an endpoint's **host only**, because a commercial URL can carry a key in its path; a named read (`config networks..httpEndpoint`) gives the full value. ## How a command picks its network @@ -78,6 +80,8 @@ Units: **1 coin = 10^18 wei**, and `--max-fee` / `--priority-fee` are given in * The coin itself is a **network** fact, not a family one: `eip155:1` pays in ETH and `eip155:56` in BNB. The base unit and its 18 decimals are shared; the symbol is not. +Address checks compare TRON base58 and hex representations of the same account by their decoded bytes; base58 addresses remain case-sensitive. EVM address comparison ignores case. + ## See also - [`networks`](../commands/networks.md) — the list above, and its JSON form diff --git a/ts/docs/concepts/security.md b/ts/docs/concepts/security.md index ec7ffefe9..fdfebe10c 100644 --- a/ts/docs/concepts/security.md +++ b/ts/docs/concepts/security.md @@ -8,7 +8,7 @@ All secrets (seeds, private keys) are stored **encrypted under your master passw The master password is local protection only: it is never sent anywhere and **cannot be recovered**. It must be at least 8 characters with an uppercase letter, a lowercase letter, a digit, and a special character. -The recovery object is the BIP39 mnemonic, but `create` never prints it — the seed is stored encrypted. Run [`backup`](../commands/backup.md) to export the plaintext mnemonic to a `0600` file and keep that file offline. Lose both password and backup and the funds are gone; lose only the password and `import mnemonic` (using the phrase from your backup) restores the wallet. The exception is TRON accounts #1 and later in a wallet created before 4.13.1 — back those up as keystores; see [Recover addresses after `legacy_derivation`](../troubleshooting/legacy-derivation-recovery.md). +The recovery object is the BIP39 mnemonic, but `create` never prints it — the seed is stored encrypted. Run [`backup`](../commands/backup.md) to export the plaintext mnemonic to a `0600` file and keep that file offline. Lose both password and backup and the funds are gone; lose only the password and `import mnemonic` (using the phrase from your backup) restores the wallet. The exception is seed accounts at `m/44'/195'/'/0/0` with index greater than zero — back those up as keystores; see [Recover addresses after `legacy_derivation`](../troubleshooting/legacy-derivation-recovery.md). One seed covers **every chain family** — the same phrase re-derives your TRON and your EVM addresses — so there is one thing to back up, not one per chain. @@ -23,7 +23,7 @@ Corollary for scripts: source the piped secret from a secret store, not from a t ## Chain data cannot repaint your terminal -Several fields you read before approving something are written by whoever put them on chain, or by a third-party service — permission names, token names and symbols, co-signer labels. In text mode every output frame is neutralised before it reaches the terminal: ANSI/OSC control bytes are stripped, and invisible formatting characters are replaced by a visible escape such as ``. +Several fields you read before approving something are written by whoever put them on chain, or by a third-party service — permission names, token names and symbols, co-signer labels. In text mode every output frame is neutralised before it reaches the terminal: ANSI/OSC control bytes are stripped from results and errors and shown as a visible `\xNN` in warnings, and invisible formatting characters are replaced by a visible escape such as ``. That escape matters. `U+202E` reverses the display order of everything after it, which would let a permission name change how the address or weight printed beside it appears; zero-width characters can make two different names render identically. `` or `` appearing in a name means the underlying string really contains it — a red flag, not a rendering glitch. Ordinary right-to-left text (Arabic, Hebrew) contains no such characters and displays normally. diff --git a/ts/docs/dev/architecture.archify.json b/ts/docs/dev/architecture.archify.json new file mode 100644 index 000000000..ccdbcc5f5 --- /dev/null +++ b/ts/docs/dev/architecture.archify.json @@ -0,0 +1,485 @@ +{ + "schema_version": 1, + "diagram_type": "architecture", + "meta": { + "title": "wallet-cli ts/ architecture at 657e04b6", + "locale": "en", + "quality_profile": "showcase", + "repository": { + "url": "https://github.com/tronprotocol/wallet-cli", + "revision": "657e04b66935cf49ed3519289ce94f8603e118b0", + "link_mode": "local-only" + } + }, + "components": [ + { + "id": "boot", + "type": "backend", + "label": "Composition root", + "sublabel": "bootstrap/ runner \u00b7 composition", + "pos": [ + 40, + 60 + ], + "size": [ + 200, + 64 + ], + "sources": [ + { + "path": "ts/src/bootstrap/composition.ts" + }, + { + "path": "ts/src/bootstrap/runner.ts" + }, + { + "path": "ts/src/bootstrap/family-registry.ts" + } + ] + }, + { + "id": "families", + "type": "backend", + "label": "Family plugins", + "sublabel": "bootstrap/families/ tron \u00b7 evm", + "pos": [ + 340, + 60 + ], + "size": [ + 200, + 64 + ], + "tag": "FAMILY_REGISTRY", + "sources": [ + { + "path": "ts/src/bootstrap/families/tron.ts" + }, + { + "path": "ts/src/bootstrap/families/evm.ts" + }, + { + "path": "ts/src/bootstrap/families/types.ts" + } + ] + }, + { + "id": "cli", + "type": "frontend", + "label": "CLI adapter", + "sublabel": "argv \u00b7 prompts \u00b7 render \u00b7 envelope", + "pos": [ + 40, + 330 + ], + "size": [ + 200, + 64 + ], + "tag": "adapters/inbound/cli", + "sources": [ + { + "path": "ts/src/adapters/inbound/cli/shell/index.ts" + }, + { + "path": "ts/src/adapters/inbound/cli/registry/index.ts" + }, + { + "path": "ts/src/adapters/inbound/cli/output/envelope.ts" + } + ] + }, + { + "id": "app", + "type": "backend", + "label": "Application", + "sublabel": "use cases \u00b7 services \u00b7 ports", + "pos": [ + 340, + 330 + ], + "size": [ + 200, + 64 + ], + "tag": "application/", + "sources": [ + { + "path": "ts/src/application/services/signer/index.ts" + }, + { + "path": "ts/src/application/services/pipeline/index.ts" + }, + { + "path": "ts/src/application/ports/chain/gateway-provider.ts" + } + ] + }, + { + "id": "domain", + "type": "backend", + "label": "Domain", + "sublabel": "address \u00b7 amounts \u00b7 errors", + "pos": [ + 340, + 560 + ], + "size": [ + 200, + 64 + ], + "tag": "domain/", + "sources": [ + { + "path": "ts/src/domain/address/index.ts" + }, + { + "path": "ts/src/domain/errors/index.ts" + } + ] + }, + { + "id": "family", + "type": "backend", + "label": "domain/family", + "sublabel": "FAMILIES \u00b7 address codecs", + "pos": [ + 40, + 560 + ], + "size": [ + 200, + 64 + ], + "sources": [ + { + "path": "ts/src/domain/family/index.ts" + } + ] + }, + { + "id": "chainInt", + "type": "backend", + "label": "Chain + Ledger", + "sublabel": "chain/tron \u00b7 chain/evm \u00b7 ledger", + "pos": [ + 740, + 150 + ], + "size": [ + 210, + 64 + ], + "sources": [ + { + "path": "ts/src/adapters/outbound/chain/tron/tron.ts" + }, + { + "path": "ts/src/adapters/outbound/chain/evm/evm.ts" + }, + { + "path": "ts/src/adapters/outbound/ledger/index.ts" + } + ] + }, + { + "id": "svcInt", + "type": "backend", + "label": "Service integrations", + "sublabel": "sunswap \u00b7 sunpump \u00b7 gasfree \u00b7 x402 \u2026", + "pos": [ + 740, + 330 + ], + "size": [ + 210, + 64 + ], + "sources": [ + { + "path": "ts/src/adapters/outbound/sunswap/router-api.ts" + }, + { + "path": "ts/src/adapters/outbound/x402/payment-client.ts" + }, + { + "path": "ts/src/adapters/outbound/tronlink/client.ts" + } + ] + }, + { + "id": "store", + "type": "backend", + "label": "Local stores", + "sublabel": "keystore \u00b7 config \u00b7 token/contact books", + "pos": [ + 740, + 610 + ], + "size": [ + 210, + 64 + ], + "sources": [ + { + "path": "ts/src/adapters/outbound/keystore/index.ts" + }, + { + "path": "ts/src/adapters/outbound/config/index.ts" + }, + { + "path": "ts/src/adapters/outbound/tokenbook/index.ts" + } + ] + }, + { + "id": "plumbing", + "type": "backend", + "label": "Shared plumbing", + "sublabel": "http \u00b7 persistence", + "pos": [ + 740, + 470 + ], + "size": [ + 210, + 64 + ], + "sources": [ + { + "path": "ts/src/adapters/outbound/http/index.ts" + }, + { + "path": "ts/src/adapters/outbound/persistence/fs/index.ts" + } + ] + }, + { + "id": "nodes", + "type": "external", + "label": "TRON nodes \u00b7 EVM RPC", + "sublabel": "TronWeb \u00b7 ethers", + "pos": [ + 1060, + 90 + ], + "size": [ + 200, + 64 + ] + }, + { + "id": "device", + "type": "external", + "label": "Ledger device", + "sublabel": "HID \u00b7 Speculos", + "pos": [ + 1060, + 210 + ], + "size": [ + 200, + 64 + ] + }, + { + "id": "http", + "type": "external", + "label": "HTTP services", + "sublabel": "SunSwap \u00b7 GasFree \u00b7 TronLink \u00b7 x402", + "pos": [ + 1060, + 330 + ], + "size": [ + 200, + 64 + ] + }, + { + "id": "files", + "type": "database", + "label": "~/.wallet-cli files", + "sublabel": "wallets \u00b7 config.yaml \u00b7 books", + "pos": [ + 1060, + 610 + ], + "size": [ + 200, + 64 + ] + } + ], + "boundaries": [ + { + "kind": "region", + "label": "bootstrap", + "wraps": [ + "boot", + "families" + ] + }, + { + "kind": "region", + "label": "adapters/outbound", + "wraps": [ + "chainInt", + "svcInt", + "store", + "plumbing" + ] + }, + { + "kind": "region", + "label": "domain", + "wraps": [ + "domain", + "family" + ] + } + ], + "connections": [ + { + "id": "boot-families", + "from": "boot", + "to": "families", + "label": "familyMap" + }, + { + "id": "boot-cli", + "from": "boot", + "to": "cli", + "label": "builds CLI", + "labelDy": 24 + }, + { + "id": "families-cli", + "from": "families", + "to": "cli", + "label": "Spec + Binding", + "variant": "emphasis", + "fromSide": "bottom", + "toSide": "top" + }, + { + "id": "families-app", + "from": "families", + "to": "app", + "label": "tron/ evm/ use cases", + "variant": "emphasis", + "labelDy": 24, + "labelDx": 56 + }, + { + "id": "families-chain", + "from": "families", + "to": "chainInt", + "label": "createGateway", + "variant": "emphasis" + }, + { + "id": "cli-app", + "from": "cli", + "to": "app", + "label": "calls use cases" + }, + { + "id": "cli-family", + "from": "cli", + "to": "family", + "label": "family facts", + "labelDy": 24 + }, + { + "id": "app-domain", + "from": "app", + "to": "domain" + }, + { + "id": "chain-app", + "from": "chainInt", + "to": "app" + }, + { + "id": "svc-app", + "from": "svcInt", + "to": "app", + "label": "implement ports", + "labelAt": [ + 692, + 362 + ] + }, + { + "id": "store-app", + "from": "store", + "to": "app" + }, + { + "id": "svc-plumbing", + "from": "svcInt", + "to": "plumbing", + "variant": "dashed" + }, + { + "id": "store-plumbing", + "from": "store", + "to": "plumbing", + "variant": "dashed" + }, + { + "id": "chain-nodes", + "from": "chainInt", + "to": "nodes", + "label": "JSON-RPC" + }, + { + "id": "chain-device", + "from": "chainInt", + "to": "device", + "label": "APDU" + }, + { + "id": "svc-http", + "from": "svcInt", + "to": "http", + "label": "HTTPS \u00b7 WS" + }, + { + "id": "store-files", + "from": "store", + "to": "files", + "label": "atomic write" + } + ], + "cards": [ + { + "dot": "emerald", + "title": "Dependency direction", + "items": [ + "bootstrap wires everything", + "inbound and outbound both point at application", + "application points at domain only" + ] + }, + { + "dot": "violet", + "title": "Family plug-in", + "items": [ + "families/tron.ts and evm.ts register Spec + family Binding pairs", + "they supply createGateway and signStrategy", + "neutral code reads FAMILIES from domain/family" + ] + }, + { + "dot": "amber", + "title": "Code vs rules at HEAD", + "items": [ + "chain/* and x402 import application/services", + "ledger imports chain/tron", + "x402 HTTP server lives in adapters/outbound" + ] + } + ] +} diff --git a/ts/docs/dev/architecture.html b/ts/docs/dev/architecture.html new file mode 100644 index 000000000..a2ae8348c --- /dev/null +++ b/ts/docs/dev/architecture.html @@ -0,0 +1,15067 @@ + + + + + + + wallet-cli ts/ architecture at 657e04b6 Diagram + + + + + + + + +
+ +
+
+
+

wallet-cli ts/ architecture at 657e04b6

+
+
+ + + + + + + +
+ + wallet-cli ts/ architecture at 657e04b6 + An architecture diagram generated by Archify. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Composition root · bootstrap/ runner · composition · bootstrap + + + + Composition root + bootstrap/ runner · composition + + + + Family plugins · bootstrap/families/ tron · evm · bootstrap · FAMILY_REGISTRY + + + + Family plugins + bootstrap/families/ tron · evm + FAMILY_REGISTRY + + + + CLI adapter · argv · prompts · render · envelope · Architecture component · adapters/inbound/cli + + + + CLI adapter + argv · prompts · render · envelope + adapters/inbound/cli + + + + Application · use cases · services · ports · Architecture component · application/ + + + + Application + use cases · services · ports + application/ + + + + Domain · address · amounts · errors · domain · domain/ + + + + Domain + address · amounts · errors + domain/ + + + + domain/family · FAMILIES · address codecs · domain + + + + domain/family + FAMILIES · address codecs + + + + Chain + Ledger · chain/tron · chain/evm · ledger · adapters/outbound + + + + Chain + Ledger + chain/tron · chain/evm · ledger + + + + Service integrations · sunswap · sunpump · gasfree · x402 … · adapters/outbound + + + + Service integrations + sunswap · sunpump · gasfree · x402 … + + + + Local stores · keystore · config · token/contact books · adapters/outbound + + + + Local stores + keystore · config · token/contact books + + + + Shared plumbing · http · persistence · adapters/outbound + + + + Shared plumbing + http · persistence + + + + TRON nodes · EVM RPC · TronWeb · ethers · Architecture component + + + + TRON nodes · EVM RPC + TronWeb · ethers + + + + Ledger device · HID · Speculos · Architecture component + + + + Ledger device + HID · Speculos + + + + HTTP services · SunSwap · GasFree · TronLink · x402 · Architecture component + + + + HTTP services + SunSwap · GasFree · TronLink · x402 + + + + ~/.wallet-cli files · wallets · config.yaml · books · Architecture component + + + + ~/.wallet-cli files + wallets · config.yaml · books + + + + + + familyMap + + + + builds CLI + + + + Spec + Binding + + + + tron/ evm/ use cases + + + + createGateway + + + + calls use cases + + + + family facts + + + + + + implement ports + + + + + + + JSON-RPC + + + + APDU + + + + HTTPS · WS + + + + atomic write + + + + + + bootstrap + + + + + adapters/outbound + + + + + domain + + + + + Legend + + + Frontend + + + + Backend + + + + Database + + + + External + + + +

+ + + + + + + + + +
+ + +
+
+
+
+

Dependency direction

+
+
    +
  • • bootstrap wires everything
  • +
  • • inbound and outbound both point at application
  • +
  • • application points at domain only
  • +
+
+ +
+
+
+

Family plug-in

+
+
    +
  • • families/tron.ts and evm.ts register Spec + family Binding pairs
  • +
  • • they supply createGateway and signStrategy
  • +
  • • neutral code reads FAMILIES from domain/family
  • +
+
+ +
+
+
+

Code vs rules at HEAD

+
+
    +
  • • chain/* and x402 import application/services
  • +
  • • ledger imports chain/tron
  • +
  • • x402 HTTP server lives in adapters/outbound
  • +
+
+
+ +
+ + + + diff --git a/ts/docs/dev/rules/architecture.md b/ts/docs/dev/rules/architecture.md new file mode 100644 index 000000000..26cf5f93f --- /dev/null +++ b/ts/docs/dev/rules/architecture.md @@ -0,0 +1,275 @@ +# Architecture rules (`ts/`) + +These rules define the target architecture of `ts/`. Code that breaks them is refactored to follow them. A rule +changes only by a design decision recorded in this directory: a better design, or a constraint the code encodes +that the rule missed. `CLAUDE.md` and `AGENTS.md` point here; do not restate the rules elsewhere. Paths are +relative to `ts/src/`. + +The published contract in `ts/docs/machine-interface.md` (envelope, error codes, retry classes, exit codes, +stdout and stderr) is an input to these rules. Changing it is a separate product decision. + +Each rule names how it is enforced: + +- **depcruise**: a rule in `ts/.dependency-cruiser.cjs`. +- **lint**: an ESLint rule. +- **type**: the compiler rejects the violation. +- **test**: a test fails on the violation. +- **review**: no automation; checked in every review. + +An automated check is on as soon as it is written. Known violations go into its baseline: a new violation fails +at once, and the refactor removes the known ones. + +## 1. Layers and dependency direction + +Section 1 constrains production modules only. A `*.test.ts` file may import whatever it needs to test its +subject, including test frameworks, fixtures, and SDKs as reference implementations. A test lives next to its +subject. Production modules never import test files or test helpers. Sections 2 to 8 apply to tests as written. + +| Layer | Owns | May depend on | +|---|---|---| +| `domain` | Business values and invariants: validation, calculation, identity, normalization, units, business constants, the error code registry, pure cryptographic computation | Nothing outside `domain` (see 1.5) | +| `application` | Workflow and policy: use cases, services, ports, contracts, time limits, the signing entry | `domain` | +| `adapters/inbound` | What drives the app: parse argv, prompt, render output, the envelope | `application`, `domain` | +| `adapters/outbound` | What the app calls: implement ports, translate external data into port types, I/O, locks, encryption at rest | `application/ports`, `application/contracts`, `domain` | +| `bootstrap` | Composition root and process lifecycle; assembly only, no decisions | everything | + +1.1 Dependencies point inward, as the table shows. **depcruise**. + +1.2 Outbound adapters do not import `application/services` or `application/use-cases`. **depcruise**. + +1.3 Ports and contracts are the innermost part of `application`: they import only `domain` and other ports or +contracts. **depcruise**. + +1.4 A use case is a `*-service.ts` module under `application/use-cases/`. Nothing in `application` imports a +use case. Logic that use cases share goes into a helper module in the same directory, a service, or `domain`. +**depcruise**. + +1.5 Outside packages (Node and npm) follow an allowlist; 1.1 covers the project's own modules. +- `domain` and `application` import only pure computation libraries (`@noble/*`, `@scure/*`) and `node:crypto`: + no I/O module and no SDK. +- Chain, device, and protocol SDKs (such as `tronweb` or `@ledgerhq/*`) and HTTP clients are imported only in + `adapters/outbound`. The full package lists are in `ts/.dependency-cruiser.cjs`. + +**depcruise**. + +1.6 No circular dependencies. **depcruise**. + +1.7 Every depcruise rule has a firing test in `ts/test/depcruise/rules.test.ts` that builds a violating tree and +asserts the rule reports it. **test**. + +1.8 An outbound integration is a directory under `adapters/outbound/`, such as `chain/tron` or `ledger`. A chain +node integration lives in `chain//`; a protocol or service integration has its own directory, whatever +families it serves. +- SDKs are imported only inside integrations. +- Integrations do not import each other. One that needs another's capability receives it through a port wired + in `bootstrap`. +- Shared plumbing (`adapters/outbound/http/`, `adapters/outbound/persistence/`) may be imported by any + integration and imports no integration. + +**depcruise**. + +1.9 Everything outside the process is reached through an adapter: +- `adapters/outbound` for what the app calls: chain nodes, Ledger, HTTP services, and the files the app owns + (keystore, config, books, written artifacts). +- `adapters/inbound` for what drives the app: argv, stdin, TTY, stdout and stderr, input files the user names, + and servers that accept requests. Inbound reads a named input file into a value before it calls the use case, + so ports carry no input paths (3.4). + +Libraries that only compute or format (such as `zod`) are not integrations; 1.5 still limits which of them +`domain` and `application` import. **lint** for servers, **review**. + +## 2. Rule ownership + +2.1 Every business rule has exactly one owner. The owner defines the rule; other layers apply it by calling the +owner and never write its content themselves. The owner is: +- `domain` for business values and invariants that do not depend on a workflow: address and amount validity, + units, normalization, password policy, permission limits, and business constants such as the default fee + limit, default slippage, and transaction expiration. +- `application` for workflow policy and defaults that depend on context: when to sign, broadcast, confirm, or + refuse; which networks offer a capability; the default token of a network; which account a command acts on; + time limits. +- `adapters/outbound` for how external data maps to domain values, and which error code a transport failure + gets. +- `adapters/inbound` for the CLI shape only: flag names, exclusive groups, turning argument text into values + (splitting a list, reading a decimal string), the prompt flow, and the layout. + +A command may call a `domain` or `application` function to fail early. Its Zod schema may declare `.default(X)` +when `X` is the owner's constant, so help and `--json-schema` still show the default. A command does not contain +the content of a business rule, a default value, a unit conversion policy, or an account selection policy. +**review**. + +2.2 Every path that brings a business value in applies the owner's validity rules before the value is used or +stored. This includes configuration loads, persisted records, and non-CLI callers of a use case, such as +plugins. An early check in inbound is a convenience, not the enforcement point. Loading a value and writing the +same value never disagree on whether it is valid. **test**, **review**. + +Exception: records that earlier releases stored under a looser rule load as they are, so users keep access to +their files; a write checks only the records it adds or changes, and nothing rewrites, truncates, or drops the +old ones. The tolerated cases are: +- two contacts with the same address (a contact name stays unique on load); +- a wallet label longer than 64 characters (every other label rule holds on load). + +A new tolerated case is added here by a design decision. **test**: each case loads, and a write that adds the +same case is refused. + +2.3 Each command has one Zod schema. It drives validation, positional arity, help text, and `--json-schema`; no +second definition of a command's arguments exists. **review**. + +## 3. Ports + +3.1 Port types are closed. No `unknown`, no `any`, and no alias that resolves to one of them (such as +`Record`) crosses a port toward the inside. This covers `application/ports`, +`application/contracts`, and `domain/types`. +- A JSON-shaped type with concrete values (strings, numbers, booleans, null, and arrays or objects of them) may + carry data passed through for display. Inner layers do not read its fields to derive a fact (3.2). +- Values that inner layers send outward, such as contract call arguments or a document to write, are outside + 3.1. A payload sent to be signed is closed by the sign request of 6.4. + +**lint** for `unknown`, `any`, and aliases to them; **review** for reads of pass-through data. + +3.2 Outbound adapters normalize external facts into port types, once. Inner layers do not read raw node, SDK, or +wire fields to derive a fact. This covers at least: transaction execution result, decoded token and account +text, staking totals, resources, head block, EVM quantities, and contract revert reasons. Data passed through +verbatim for display (for example `block get`) has a named raw type, and inner layers do not branch on it. +**type** for raw fields absent from port types, **test** for each adapter's normalization, **review** for +re-decoding a normalized value. + +3.3 Amounts that cross a port or a use-case boundary carry their unit in the type (for example a branded +base-unit string, or `{ raw: bigint, decimals }`). `number` is used only for values the adapter has proven to be +safe integers. A display-only float (such as a USD value) is named as such. **type**. + +3.4 Ports use operation-specific types, not argv structures or flag names. Raw text, such as HTTP header lines, +is parsed before the port call. A storage port may accept the destination path the user chose; the adapter owns +path resolution, file layout, permissions, and the no-overwrite rule. **review**. + +3.5 Repositories: +- A logical change is validated and published under one lock, through one call (`update(change)`), not a + sequence of calls. +- Policy over stored data is a pure `domain` function that the adapter applies inside the lock. +- Create data files before publishing a reference to them; remove the reference before deleting the files. A + committed index never references missing data. +- Each multi-file operation defines its commit point. A failure before it, including the process being killed, + leaves the previous state; cleanup after it is reported truthfully. Write ordering alone is not rollback: full + rollback needs a recovery design with fault tests. +- Inbound reaches stored state and devices (such as Ledger) only through application use cases, never through a + repository or device port. +- A lock is taken over only when the process that holds it is dead, never because of its age. A lock that cannot + be acquired reports its path, so the user can remove it when no command is running. + +**test**, **review**. + +3.6 An adapter does not drive application flow: it does not build signers or decide when to sign. It receives +what it needs through the port call. **depcruise** for the import direction (1.2), **review** and **test** for +the flow. + +3.7 Time limits that `application` sets reach a port call as an `AbortSignal`. An adapter's own transport +timeout is internal to the adapter. It may reach through an SDK by any mechanism, as long as a request whose +time has run out is never sent. With no command deadline (`--timeout 0`), a request may wait as long as its +transport allows. **type**, **test**. + +The command deadline reaches outbound as a `DispatchCheck` (`application/contracts/deadline.ts`). The check is +bound to the port at `gateways.get`, or passed in the call. The adapter calls `assertDispatchable()` immediately +before it sends each request, after any queue, pacing, or retry. A submit gets `submitDispatchCheck`, so the +admission rule of `application` runs again at that point; outbound does not restate it. **test**. + +## 4. Output + +4.1 Use cases return typed results. JSON and text output are projections of the same result; they differ only +where `ts/docs/machine-interface.md` says so. **type** for result types, **test** for the projections. + +4.2 Renderers format; they do not compute facts or make judgments. A fact the user needs (unit, warning, risk) +is a field of the result or an entry in `meta.warnings`. **review**. + +4.3 The `--json-schema` output and the envelope are snapshot-tested; changing them is a recorded contract +decision. **test**. + +## 5. Families and capabilities + +5.1 Chain facts are defined once, in `domain`: address format, address encoding conversion, transaction hash +format, units, and the zero address. No module writes them by hand. +- Other code gets them through `domain/family`. Code in a `tron/` or `evm/` directory, or in a protocol's + directory, may use its own family's codec (for example `domain/address`) directly. +- Addresses are compared and stored in the canonical form that `domain/family` defines. +- A `tron/` or `evm/` directory holds only that chain's own protocol. Code for a protocol built on a chain is + not placed there. + +**review**. + +5.2 When a feature behaves differently per family (for example x402 schemes, or which use case a command runs), +the feature module holds one table from family to behavior. It does not branch on the family in several places. +**review**. + +5.3 `application` owns one capability predicate per capability. The predicate decides over the network, the +configuration, and the inputs the capability needs. Discovery (help, `--json-schema`) separates "supported on +this network" from "needs these inputs here". A missing default asset does not mean the whole capability is +unavailable. **test**: for each builtin network, the predicate and the execution gate agree on supported, +unsupported, and conditional-input cases. + +## 6. Safety invariants + +6.1 Every path that submits a transaction or moves funds checks the dry-run guard before the side effect. A +test lists every such port method and checks that it refuses during dry-run. **test**. + +6.2 `application` owns the TRON transaction lifecycle and selects its steps by execution mode and transaction +origin. Call sites do not skip a step that applies to their mode. +- Dry-run and build-only never sign or submit. Sign-only never submits. +- Broadcasting an imported signed transaction never resolves a local signer or rewrites the signed body. +- Confirmation follows the documented `--wait` policy; a contract call requires its execution result. +- A write signed by a service (for example `sunpump launch`) follows its documented lifecycle with the same + side-effect guarantees. + +**type** for the mode-specific API, **test** for expiry, signer gates, early exits, and confirmation. + +6.3 Errors keep the codes and retry classes of the published contract. +- A failed read, or a request known not to have acted, gets the matching retryable code. +- A write that may have been committed follows its documented outcome-unknown policy, and its error carries + `error.details.outcome: "unknown"`. A chain submit of a signed transaction whose outcome is unknown reports + the id of that transaction in `error.details.txId`. +- A call that the deadline stops before its request is sent fails with `DeadlineExpiredError` (`timeout`, + nothing sent, no txId). A call stopped after its request was sent fails with a `TransportError` or + `ChainError` (`timeout`, outcome unknown); a submit adds the txId. +- "Not supported" comes only from a capability decision or a recognized unsupported response, never from an + unclassified exception. + +**test** for the transaction id on submit failures, **review**. + +6.4 Signing has one entry: an `application` service that takes a closed sign request naming its network, its +origin, and its payload. +- It refuses a payload whose embedded chain identity (EVM `chainId`, EIP-712 `domain.chainId`) disagrees with + the named network. It refuses a TRON transaction when the named network's node does not hold its reference + block. +- Submitting happens only through the guarded paths of 6.1. +- An integration whose SDK can sign or send wraps the SDK so those functions are unreachable elsewhere, and + builds no signer of its own. + +**depcruise** for SDK entry points that sign or send, **test** for chain identity mismatches, **review**. + +6.5 Secrets (private keys, mnemonics, BIP39 passphrases, passwords) are encrypted at rest and never read from +argv or environment variables, only from the dedicated stdin channel or a hidden TTY prompt. **lint**: +`process.env` is read only in `bootstrap` and an allowlist in production modules; **review**: no command schema has +a secret option. + +Exception: native `backup` and `address generate` may write their secret in plaintext to a file +created with mode 0600 that never overwrites or follows an existing path. A secret is printed to +stdout only when the user passes the command's explicit option (`address generate --print-secret`). +**test**. + +6.6 Credentials that `config` stores for external services are not 6.5 secrets. They are read only from stdin, +never from argv or environment variables, are stored in a file with mode 0600, and are masked in every output. +**test**. + +## 7. Language boundary + +7.1 `application`, `domain`, and `adapters/outbound` do not name CLI flags or commands. Errors and warnings name +inputs and next steps through structured references and fields; inbound decides how to describe them to the user. +**review**. + +7.2 `domain` owns the error code registry: each code's meaning, retry class, and exit class, as published in the +contract. `adapters/inbound` owns the envelope shape and the fallback for unknown and `either` codes. **test** for +the registry against the contract. + +## 8. Documentation + +8.1 Comments follow [comments.md](comments.md). + +8.2 Design decisions that must survive are recorded in this directory, not in code comments. diff --git a/ts/docs/dev/rules/comments.md b/ts/docs/dev/rules/comments.md new file mode 100644 index 000000000..98c4014e0 --- /dev/null +++ b/ts/docs/dev/rules/comments.md @@ -0,0 +1,44 @@ +# Comment rules (`ts/`) + +Code is the source of truth. Write a comment only when the code cannot say it, and say it in the fewest words +that help a human review the code. These rules apply to every code file under `ts/`, including tests. + +## When to write a comment + +- A non-obvious constraint whose removal would invite a "simplification" that breaks behavior: money, + security, signing, atomicity or lock scope, ordering, protocol or node quirks. +- A unit or encoding the type does not show (SUN vs TRX, raw vs decimal, hex vs base58). +- An exported API whose name and types are not enough. +- In a test, only when the intent is not clear from the test name and assertions. + +Otherwise, make the code clearer instead: a better name, a smaller function, a named constant. + +## What to write + +- What the code does now, and for a constraint, what breaks if it is removed. +- One line where possible. + +## What not to write + +- History or decisions: "previously", "used to", "we chose", "fixed", incident stories, commit hashes, PR or + issue numbers, dates, measurements, audit references. Record a decision that must survive in + `ts/docs/dev/rules/` (architecture rule 8.2). +- What the code, the name, or the types already say. +- Commented-out code. +- Long rationale, or details of another file's internals that can drift. +- Intended behavior the code does not have (see [Keep comments true](#keep-comments-true)). + +## Keep comments true + +When you change code, update or delete the comments it makes wrong in the same change. When a comment and the +code disagree, fix the comment; if the code is the one that is wrong, fix it in a separate, tested change. + +## Directives + +Tool directives (`eslint-disable*`, `@ts-expect-error`, `@ts-ignore`, `prettier-ignore`, coverage ignores), +triple-slash directives, shebangs, `/*#__PURE__*/`, `@deprecated`, and license headers are not prose. Add, +change, or remove one only together with the code change that needs it. Keep license headers. + +## Style + +English. `//` by default; `/** */` for exported API docs. diff --git a/ts/docs/guide/getting-started.md b/ts/docs/guide/getting-started.md index 7a5adf530..45b4dab4b 100644 --- a/ts/docs/guide/getting-started.md +++ b/ts/docs/guide/getting-started.md @@ -68,7 +68,7 @@ printf '%s' "$MY_PASSWORD" | wallet-cli tx send --to TSx72ViULFepRGCS4PM5dP4FqD1 op read "op://Private/wallet-cli/password" | wallet-cli tx send --to TSx72ViULFepRGCS4PM5dP4FqD1d8qggCc --amount 1 --network nile --password-stdin ``` -The transaction is signed and **submitted**. Submission is not confirmation — check where it landed: +The transaction is signed, submitted, and followed until it is confirmed or failed. If the command ends with the transaction still **submitted** (its time limit ran out, or you passed `--no-wait`), check where it landed: ```bash wallet-cli tx status --txid --network nile @@ -81,7 +81,7 @@ Block #70,604,611 Confirmations 19 ``` -`pending` means wait and re-run; `failed` means the chain rejected it (see [troubleshooting](../troubleshooting.md)). To make `tx send` block until confirmed, add `--wait`. +`pending` means wait and re-run; `failed` means the chain rejected it (see [troubleshooting](../troubleshooting.md)). Not sure about a transaction? Rehearse it first — `--dry-run` builds and estimates without signing or broadcasting: diff --git a/ts/docs/guide/ledger.md b/ts/docs/guide/ledger.md index 82ab13002..a81594392 100644 --- a/ts/docs/guide/ledger.md +++ b/ts/docs/guide/ledger.md @@ -39,13 +39,13 @@ wallet-cli tx send --to T... --amount 1 --network nile --account cold Instead of a password prompt, the Ledger asks you to approve. When the TRON app can decode the transaction, its details appear **on the Ledger screen** — verify the recipient and amount there (that is the whole point of the device) and approve. Some contract operations are signed by hash instead; see [Hash signing](#hash-signing). The transaction then broadcasts normally; confirm with [`tx status`](../commands/tx/status.md). -**Time to review.** A TRON transaction normally expires about 60 seconds after it is built, which a careful on-device review can outlast. For a new TRON transaction sent to a Ledger, the CLI therefore sets the expiration to at least ten minutes from signing — or the device timeout (`--timeout`) plus one minute, if longer. An expiration you set yourself is kept, and a transaction file that was already built or signed elsewhere is never changed. The expiration is checked again after the device signs: a transaction that expired while you were reviewing it fails with `tx_expired` and is not broadcast — rebuild and sign again. +**Time to review.** A TRON transaction normally expires about 60 seconds after it is built, which a careful on-device review can outlast. For a new TRON transaction sent to a Ledger, the CLI therefore sets the expiration to at least ten minutes from signing. An expiration you set yourself is kept, and a transaction file that was already built or signed elsewhere is never changed. The expiration is checked again after the device signs: a transaction that expired while you were reviewing it fails with `tx_expired` and is not broadcast — rebuild and sign again. This is your best defense against address-swapping malware: what the device screen shows is what gets signed, regardless of what the host displays. ## 3. When the device doesn't respond -Device calls are bounded by the same `--timeout` as RPC (default 60000 ms) and fail with `error.code: "timeout"`. In order: +Device calls count against the command's `--timeout` (default 90000 ms), and a command that runs out of time fails with `error.code: "timeout"`. In order: 1. Is the Ledger unlocked and the right app open — the one the account was registered with, not the dashboard? 2. Replug the cable; avoid USB hubs. @@ -61,8 +61,9 @@ The error code says what went wrong with the device itself: | `ledger_unsupported` | The open app cannot sign this, or is not the right app | Open the right app and check its version | | `ledger_setting_required` | A TRON app setting is off | Enable the setting the message names — see [TRON app settings](#tron-app-settings) | | `signing_rejected` | You declined on the device | — | +| `signed_payload_mismatch` | The device's signature does not match the account or the transaction; nothing was sent | — | -On a timeout or cancellation the CLI closes the device without waiting for it; if the next command cannot open it, reconnect it. +On a timeout or cancellation the CLI closes the device without waiting for it; if the next command cannot open it, reconnect it. Approving the device after the command expires does not send a transaction. For a newly built TRON transaction without explicit `--expiration`, device preparation extends validity to at least 10 minutes from preparation, preserving a longer existing expiry. This window is independent of `--timeout`; explicit expiration and protocol bounds still apply, and expiry is checked before and after signing. More remedies: [Troubleshooting](../troubleshooting.md#timeout-exit-1). diff --git a/ts/docs/guide/scripting.md b/ts/docs/guide/scripting.md index 0c2bb7510..3481f5f17 100644 --- a/ts/docs/guide/scripting.md +++ b/ts/docs/guide/scripting.md @@ -49,14 +49,14 @@ printf '%s' "$PW" | wallet-cli tx send --to T... --amount 1 \ ## Waiting for confirmation -`tx send` returns at submission. For scripts, the simplest safe form is `--wait`: +`tx send` waits for confirmation by default and reports the outcome in `data.stage` (`confirmed`, `failed`, or `submitted` when the time limit, or a poll error that cannot be retried unchanged, ends the wait): ```bash wallet-cli tx send --to T... --amount 1 --network tron:3448148188 \ - --password-stdin --wait --wait-timeout 90000 -o json + --password-stdin --timeout 120000 -o json ``` -Or decouple: capture `data.txId`, then poll [`tx status`](../commands/tx/status.md) until `data.state` is `confirmed` (abort on `failed`). The full four-state polling pattern, including the batch-operation rules, is in [machine-interface.md → Script safety](../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed). +Or decouple: pass `--no-wait`, capture `data.txId`, then poll [`tx status`](../commands/tx/status.md) until `data.state` is `confirmed` (abort on `failed`). The full four-state polling pattern, including the batch-operation rules, is in [machine-interface.md → Script safety](../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed). ## Sign here, broadcast there @@ -95,7 +95,7 @@ wallet-cli tx broadcast --tx-stdin --network tron:3448148188 -o json < signed.js ## Timeouts and retries -Every RPC/device call is bounded by `--timeout` (ms). On `error.code = "timeout"`, retrying with a larger value is safe for **read** commands; for `tx send`, first check `tx status` on the txid you may already have submitted — blind resend is how double-spends happen. +`--timeout` (ms, default 90000) is one deadline for the whole command, including the wait for confirmation; `0` removes it. On `error.code = "timeout"`, retrying with a larger value is safe for **read** commands. For a write, look for `error.details.outcome` first: when it is `"unknown"` the write may have taken effect, so reconcile before resending: check `tx status` with `error.details.txId` when it is present, otherwise the command's own evidence (the GasFree `nonce` or `traceId`, `candidateTxHash`, the B.AI order, `sunpump token-search`) — blind resend is how double-spends happen. See [time limit](../machine-interface.md#time-limit---timeout). ## See also diff --git a/ts/docs/guide/send-tokens.md b/ts/docs/guide/send-tokens.md index 41227b16d..c27fda7e4 100644 --- a/ts/docs/guide/send-tokens.md +++ b/ts/docs/guide/send-tokens.md @@ -96,7 +96,7 @@ wallet-cli tx send --to T... --asset-id 1002000 --raw-amount 1000000 --network n wallet-cli tx send --to T... --token USDT --amount 5 --network nile --dry-run -o json ``` -Check the `fee` block in the output, then re-run without `--dry-run`. Submission alone is not success — either confirm afterwards with [`tx status`](../commands/tx/status.md), or add `--wait` to the `tx send` command so it blocks until the transaction is confirmed or failed. +Check the `fee` block in the output, then re-run without `--dry-run`. `tx send` then waits until the transaction is confirmed or failed and reports it in `data.stage`; a `submitted` stage means the outcome is not known yet — confirm it with [`tx status`](../commands/tx/status.md). > **Mainnet**: the same commands with `--network tron`, `--network ethereum` or `--network bsc` move real assets, irreversibly. Triple-check `--to` (a confirmed transfer to a wrong address is gone), and dry-run first. diff --git a/ts/docs/guide/stake-and-resources.md b/ts/docs/guide/stake-and-resources.md index 1ec9ba41e..5ba2548ae 100644 --- a/ts/docs/guide/stake-and-resources.md +++ b/ts/docs/guide/stake-and-resources.md @@ -35,7 +35,7 @@ Plain TRX transfers consume **bandwidth**; smart-contract calls (TRC20 transfers wallet-cli stake freeze --amount-sun 100000000 --resource energy --network nile ``` -`--resource` chooses which resource the stake produces. It defaults to `bandwidth`; stake for `energy` when you plan to send TRC20 tokens or call contracts, since those spend energy (as in step 1). The TRX stays yours — it is locked, not spent — and staking also grants TRON Power (governance votes). Like the other stake write commands, `stake freeze` supports `--dry-run`, `--sign-only`, `--build-only`, `--wait`, and returns at submission by default. +`--resource` chooses which resource the stake produces. It defaults to `bandwidth`; stake for `energy` when you plan to send TRC20 tokens or call contracts, since those spend energy (as in step 1). The TRX stays yours — it is locked, not spent — and staking also grants TRON Power (governance votes). Like the other stake write commands, `stake freeze` supports `--dry-run`, `--sign-only`, `--build-only`, `--no-wait`, and waits for confirmation by default. Verify the effect by running `account info` again — the `Energy` limit now reflects the TRX you staked: diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index 64a49d5bf..70ebf3792 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -10,10 +10,23 @@ wallet-cli -o json [--network ] [--timeout ] [--account - Always pass `-o json`. Text output is for humans and carries no stability promise. - In JSON mode, stdout carries **exactly one terminal frame** — the result envelope. Nothing else is ever written to stdout. Diagnostics go to stderr. -- Long-running `x402` and `bai` commands also report progress on stderr: `⏳ …` lines in text mode, and one `{"type":"activity","message":"…"}` line per step in JSON mode. They are informational — ignore them or show them to a user, but never parse them for the outcome. -- Every RPC / device call is bounded by `--timeout` (milliseconds, default `config.timeoutMs`, built-in 60000). +- Long-running `x402` and `bai` commands also report progress on stderr: `⏳ …` lines in text mode, and one `{"type":"activity","message":"…"}` line per step in JSON mode. Ledger device prompts go there the same way (`{"type":"awaiting_device",…}` in JSON mode). They are informational — ignore them or show them to a user, but never parse them for the outcome. +- `--timeout ` is one deadline for the whole command; see [Time limit](#time-limit---timeout). +- A command that submits a transaction waits for its confirmation; `--no-wait` returns after submission. See [Script safety](#script-safety-never-mistake-submitted-for-confirmed). - `--network` takes a canonical **CAIP-2** id (`tron:3448148188`, `eip155:11155111`) or a short alias (`nile`, `sepolia`, `bsc`). The namespace is not the chain family: `eip155` addresses the `evm` family. Aliases resolve once at selection; nothing downstream ever sees one, and the envelope's `chain.network` always reports the canonical id. Prefer canonical ids in scripts — an alias is a local config entry and can be re-pointed. -- The TRON ids used before CAIP-2 (`tron:mainnet`, `tron:nile`, `tron:shasta`) remain permanent aliases, so existing invocations keep working. **Output is a different matter**: `chain.network`, `chain.chainId`, the `networks` listing's `id`, and the `config` keys all report the CAIP-2 id now, so a consumer that string-matches or keys a map by `tron:nile` must be updated. +- `tron:mainnet`, `tron:nile` and `tron:shasta` are permanent aliases. `chain.network`, the `networks` listing's `id`, and network `config` keys use canonical CAIP-2 ids; `chain.chainId` contains the decimal chain id. + +### Time limit (`--timeout`) + +`--timeout ` limits the whole command, from start to the result envelope. It takes decimal digits representing an integer from 0 to 2147483647; the default is `config.timeoutMs`, built-in 90000. `0` disables the command deadline and confirmation cap. Transports can enforce their own timeouts; ERC-8004 metadata fetching keeps a 10 s safety cap, and transaction validity remains finite. + +- **What counts.** Every node, service and device call, polling for confirmation, approvals, on-device confirmation on a Ledger, and reading a `*-stdin` flag. A `*-stdin` read starts before the command runs, also when stdin is a terminal (end it with Ctrl-D). Time spent waiting for input at an interactive TTY prompt (`create`, `import`, `backup`, `change-password`, `delete`) does not count. +- **Out of time.** Before a result is known, the command fails with `timeout` (exit `1`). A read returns no partial result: optional data that a command otherwise leaves out when it is unavailable (prices, token metadata, ERC-8004 metadata, witness lists, brokerage, block numbers and times, a dry run's nonce and balance checks) also ends the command with `timeout`. +- **Before a write is sent.** A transaction, payment or service submission is not started with less than 2000 ms left; it fails with `timeout` and no `error.details.txId` or `error.details.outcome`, and nothing was sent. A finite `--timeout` below 2000 can read and build, but never submits. +- **After a write is sent.** A signed transaction whose reply is lost fails with `timeout`, `rpc_error` or `invalid_node_response`, with the transaction's id in `error.details.txId` and `error.details.outcome: "unknown"` ([unknown outcome](#unknown-outcome-errordetailsoutcome)); check it with `tx status` before sending again. A transaction that was accepted while the command was still polling for its confirmation returns exit `0` with `stage: "submitted"`, its `txId`, and a warning; so does one whose polling stopped on an error that cannot be retried unchanged. A read after an accepted write that runs out of time adds a warning and keeps the result (for example `proposal create` when the new proposal's id was not read). +- **Commands with their own outcome.** GasFree, TronLink, x402, B.AI and SunPump report an interrupted submission as described in [Out of time after a submission](#out-of-time-after-a-submission). `tx multisig --watch` fails with `timeout` at the deadline; pass `--timeout 0` to watch until stopped. `x402 serve` applies the limit to start-up and then to each request; see [`x402 serve`](commands/x402/serve.md). +- **Local work.** Waiting for a file lock takes at most 5 s and never past the deadline, and no write starts after it. A write that has started finishes and reports its real result. Key derivation for the master password cannot be interrupted, so a command can end later than its deadline by the time that takes. +- **Delivery.** The process ends after the result has been written to stdout. A reader that never reads the pipe can delay that; the deadline limits the command's work, not delivery to an unread pipe. ### Discovery @@ -31,6 +44,8 @@ wallet-cli tx send --json-schema One call returns the whole surface: `tool`, `version`, `globalFlags`, `errorCodes`, and `commands[]`. Command entries carry `id`, `kind`, `path`, `usage`, `summary`, `requires` (network / auth / wallet), `capability`, `examples`, and `inputSchema`, a JSON Schema for their input; chain commands also declare `families`. This is the intended way to learn the CLI; do not scrape `--help`. A family argument scopes the catalog to one chain family. +A command with a `capability` also carries `support`, mapping each configured network of its families to `supported`, `unsupported`, or `needs-inputs`. `unsupported` fails with **`unsupported_network_capability`** before any request. `needs-inputs` means the network supports the command only with inputs it otherwise defaults: `x402 serve` on a network with no registered default token needs `--token`, or `--asset` with `--decimals`. A command with flags that exclude each other carries `exclusive`: groups of flag names, each with a `label` and a `select` of `exactly-one` (the default) or `at-most-one`. Breaking a group fails with **`invalid_option`**. + ### Chain families A network belongs to one **chain family**, `tron` or `evm`, and that is what decides which commands and which flags apply: @@ -100,7 +115,7 @@ Schema id: `wallet-cli.result.v1`. | ----------------- | ------------------------ | ------------------- | -------------------------------------------------------------------------------- | | `schema` | `"wallet-cli.result.v1"` | always | Version gate; dispatch on this | | `success` | boolean | always | Mirrors the exit code (`true` ⇔ 0) | -| `command` | string | always | Canonical command id, e.g. `tx.send`, `list`. It names the **operation**, not the words typed: `backup --records` reports `backup.records`, `import keystore` reports `import.keystore` | +| `command` | string | always | Canonical command id, e.g. `tx.send`, `list`. It names the **operation**, not the words typed: `backup --records` reports `backup.records`, `import keystore` reports `import.keystore`. Empty (`""`) when the call failed before a command was resolved (`unknown_command`, an unreadable `config.yaml`) | | `data` | object/array | success only | Command-specific payload; see each command's reference page | | `error.code` | string | error only | Machine-readable; see [error codes](#error-codes) | | `error.message` | string | error only | Human-readable; **not** stable — never parse it. When a chain rejection was classified into a code, this reads `: `, so the terminal shows the node's own numbers or revert reason; the same node text is also in `details.nodeMessage` (TRON, redacted) or `details.providerMessage` (TronLink), alongside `details.nodeCode` / `details.providerCode` | @@ -111,7 +126,7 @@ Schema id: `wallet-cli.result.v1`. | `meta.pagination` | object | windowed commands only | `offset` / `limit` / `total`; present when the command returns a pagination window — see [pagination](#pagination) | | `chain` | object | when a network was selected | `family` / `network` / `chainId`. Present on every chain command and on the local commands whose policy resolves a network — currently `backup`, `current` and `list`, which use the selected or default network as a family/display selector without contacting a node. Commands with `network: "none"` (`config`, `networks`, `contact`, `encoding`, `address`, `create`, `import`, …) omit it — its presence does **not** mean a node was contacted | -Encoding rules: `bigint` values are serialized as decimal **strings** (e.g. `"balance": "1976489000"`), binary as hex. Amounts backed by `bigint` or protocol int64 values are strings, but bounded counters and fees such as `feeSun`, `multiSignFeeSun`, `energyUsed` and `netUsed` may come back as JSON numbers. Follow each command's field table instead of coercing every amount to one type. +Encoding rules: `bigint` values are serialized as decimal **strings** (e.g. `"balance": "1976489000"`), binary as hex. Amounts backed by `bigint` or protocol int64 values are strings, but bounded counters and fees such as `feeSun`, `multiSignFeeSun`, `energyUsed` and `netUsed` may come back as JSON numbers. The `feeSun` of a TRON `fee` estimate object (`feeModel: "tron-resource"`) is always a decimal string when present. Follow each command's field table instead of coercing every amount to one type. **Token amounts carry their scale.** In the `sunswap` and `sunpump` payloads, every token amount is base units as a decimal string, and the object that carries it also carries the `decimals` needed to read it — `{address, symbol, decimals, amount}`. Where an amount sits at the top of a payload rather than inside a side, its scale is beside it under a matching name (`lpAmount` / `lpDecimals`, `tokensIn` / `tokenDecimals`). The exception is a SunSwap V3 / V4 position's `liquidity`, `liquidityAfter` and `liquidityExpected`: numbers the contract keeps, denominated in neither token, with no decimals. Token `decimals` from 0 to 77 are supported; SunSwap / SunPump contract or market metadata outside that range — or a market figure in scientific notation with an exponent beyond ±1000 — fails with `invalid_node_response` rather than being scaled. @@ -127,7 +142,7 @@ A listing that accepts an ordering echoes it back as `{orderBy, sort}`, next to ### Reading `meta.warnings` -An entry is either a plain string or a `{code, message}` object. The object form is used where the condition is worth branching on — currently the [`permission update`](commands/permission/update.md) safety warnings and the post-confirmation checks; everything else is a bare string. Normalise before display, and never assume a uniform element type: +An entry is either a plain string or a `{code, message}` object. The object form is used where the condition is worth branching on — currently the [`permission update`](commands/permission/update.md) safety warnings, the post-confirmation checks, the error code that ended a wait for confirmation, `network_not_verified` from a TRON [`tx sign`](commands/tx/sign.md) that contacted no node, `secret_file_not_removed` from a [`delete`](commands/delete.md) that could not remove the encrypted secret file, and `backup_file_not_removed` from a wallet-data upgrade that could not remove its temporary rollback copy (the message names the file), and the `sunswap_*` notices of the SunSwap commands (such as `sunswap_scan_truncated`); everything else is a bare string. Normalise before display, and never assume a uniform element type: ```bash # text for humans — works for both forms @@ -190,9 +205,16 @@ Each entry is an object, not a bare string: `retry` answers "now what": `same` — retry the identical command right away (a node or service hiccup); `later` — the identical command will work, but not yet; back off first (a lock-up period, a withdrawal interval, a rate limit); `changed` — retry only after changing the request (raise the fee, rebuild with a new nonce); `never` — retrying as-is cannot succeed, something outside the command has to change. Every exit-`2` code is `never` by construction. -`retry` describes the **error**, not the command. `timeout` and `rpc_error` are `same` because for most calls that is correct — the node never acted, so resending is free. But a command that may already have broadcast a transaction (`tx send`, and anything else on the submit path) can hit `timeout` or `rpc_error` **after the node accepted the transaction and before the response got back**. There the outcome is unknown, not failed, and resending does not retry the original request: it builds and signs a **new** transaction, which on TRON is a second, distinct transfer. `retry: "same"` is right for a `timeout`/`rpc_error` while resolving a network id or reading a balance; it is not a licence to resend a broadcast blind. Reconcile with [`tx status`](#script-safety-never-mistake-submitted-for-confirmed) before deciding. `sunpump launch` is the one service-side write that does not leave this to the caller: a create call that fails in any way short of the service refusing it (a timeout, a 5xx, a dropped connection, a reply that does not parse or names no token) fails as `launch_outcome_unknown` (`retry: "never"`), because the service deploys the token before it replies. +`retry` describes the **error**, not the command. + +- `timeout` and `rpc_error` are `same` because for most calls that is correct — the node never acted, so resending is free. `retry: "same"` is right for a `timeout`/`rpc_error` while resolving a network id or reading a balance. +- A command that may already have broadcast a transaction (`tx send`, and anything else on the submit path) can hit `timeout`, `rpc_error` or `invalid_node_response` **after the node accepted the transaction and before the response got back**. There the outcome is unknown, not failed, and resending does not retry the original request: it builds and signs a **new** transaction, which on TRON is a second, distinct transfer. `retry: "same"` is not a licence to resend a broadcast blind. +- Such an error carries [`error.details.outcome: "unknown"`](#unknown-outcome-errordetailsoutcome); reconcile with [`tx status`](#script-safety-never-mistake-submitted-for-confirmed) before deciding. +- `sunpump launch` is the one service-side write that does not leave this to the caller: a create call that fails in any way short of the service refusing it (a timeout, a 5xx, a dropped connection, a reply that does not parse or names no token) fails as `launch_outcome_unknown` (`retry: "never"`), because the service deploys the token before it replies. + +On the submit path, a `timeout`, `rpc_error` or `invalid_node_response` carries `error.details.outcome: "unknown"` and `error.details.txId`: the id of the signed transaction, computed locally. Look it up with `tx status` before sending again. Text output shows it too. This applies when the local id is available; no id is invented. Definite node rejections and failures before submission (including expiry, authorization, validation and dry-run checks) gain neither field. -That index is the machine-readable catalog this build exposes. Treat it as a discovery aid, not a closed enum: a few code paths choose among error-code strings dynamically, so a runtime envelope can still carry a code absent from `errorCodes`. The tables below are the frequently-hit subset, kept for reading. New codes may still be added within v1, and two strings (`invalid_value`, `aborted`) appear under either exit code depending on where they are raised — so always tolerate an unknown code by falling back to its exit-code class. +That index is the machine-readable catalog this build exposes. Treat it as a discovery aid, not a closed enum: a few code paths choose among error-code strings dynamically, so a runtime envelope can still carry a code absent from `errorCodes`. The tables below are the frequently-hit subset, kept for reading. New codes may still be added within v1, and two strings (`invalid_value`, `aborted`) appear under either exit code depending on where they are raised (`aborted` is exit `1` for Ctrl-C at an interactive prompt and exit `2` for a declined confirmation) — so always tolerate an unknown code by falling back to its exit-code class. Common codes at exit **2** (usage — fix the call): @@ -211,7 +233,7 @@ Common codes at exit **2** (usage — fix the call): | `unsupported_network_capability` | The selected network does not offer what this command needs | | `limit_exceeded` | A bounded input (file size, list length, page size) was over its limit | | `unknown_command` | No such command | -| `output_exists` | Target file already exists and is never overwritten (`backup --out`, `address generate --out`). Deterministic — retrying the same path always fails | +| `output_exists` | Target file already exists and is never overwritten (`backup --out`, `address generate --out`, `tx sign --out`). Deterministic — retrying the same path always fails | | `file_not_found` | An input file named by a flag does not exist (`contract deploy --artifact` / `--code-file`, `contract create2 --code-file`) | | `keystore_not_found` | `import keystore`: no file at the given path | | `invalid_keystore` | `import keystore`: not a valid Web3 V3 keystore — bad JSON, `version` ≠ 3, an unsupported cipher/KDF, or a payload that is not a 32-byte private key | @@ -222,7 +244,7 @@ Common codes at exit **2** (usage — fix the call): | `token_not_in_book` / `token_is_official` / `token_already_listed` | Token address-book conditions | | `unsupported_token` | The selected provider or command does not support that token | | `insufficient_voting_power` | The requested votes exceed the account's voting power | -| `gasfree_credentials_missing` / `tronlink_credentials_missing` | Required service credentials are not configured (set them with `config`) | +| `gasfree_credentials_missing` / `tronlink_credentials_missing` | Required service credentials are not configured (set them with `config --api-key-stdin`) | | `unknown_parameter` | No chain parameter by that name or id (`proposal create --set`) | | `invalid_asset_name` | A TRC10 name or abbreviation outside 1–32 visible ASCII characters | | `migration_required` | Persisted wallet data needs an upgrade this invocation cannot perform, because the master password was unavailable — re-run in a terminal, or pipe it with `--password-stdin`. See [startup wallet-data upgrades](#startup-wallet-data-upgrades) | @@ -236,24 +258,24 @@ Common codes at exit **1** (execution — runtime failure): |---|---| | `rpc_error` | The node rejected or failed the request — a TRON API call, or a JSON-RPC method such as `eth_estimateGas` | | `invalid_node_response` | The node answered, but its answer contradicts the request or the protocol: a TRC10 or exchange record carrying an id other than the one asked for, a `precision` outside 0–6 or a rate that is not a positive int32, a JSON-RPC reply with neither a `result` nor an `error` field, or no latest block. These values decide signed amounts, so the command stops instead of acting on them; list reads drop the offending record and keep the page | -| `timeout` | Aborted waiting for network or device (`--timeout` exceeded) | +| `timeout` | The command's time limit (`--timeout`) ran out, or a node, service or device did not answer in time. A transaction that may have been sent carries its id in `error.details.txId` and `error.details.outcome: "unknown"` | | `auth_required` | A required credential was unavailable — a software master password, or Ledger app/device readiness | | `auth_failed` | Wrong master password (decryption failed) | | `signing_rejected` / `transaction_rejected` | Signing or broadcast rejected (device or chain) | | `watch_only_no_signer` | The account is watch-only and cannot sign | | `invalid_mnemonic` / `invalid_private_key` | Storage validation rejected a malformed mnemonic or private key; an interactive import normally catches it at the prompt and asks again | -| `token_metadata_unavailable` | Required token metadata could not be read from the selected network. This one crosses exit codes: most sites raise it at exit `1`, but `tx send` on TRON raises it at exit **2** when a contract answers no `decimals()` and the address book has no entry either — there the call itself has to change | +| `token_metadata_unavailable` | Required token metadata could not be read from the selected network | | `wrong_device_seed` | Connected Ledger does not match the registered account | | `device_not_found` / `device_unavailable` / `device_disconnected` | No Ledger was detected; a Ledger was detected but could not be opened (another app such as Ledger Live may hold it, or USB access is denied); the Ledger disconnected during the operation. See [Ledger](guide/ledger.md#3-when-the-device-doesnt-respond) | | `ledger_setting_required` | A setting in the Ledger TRON app must be enabled — **Custom contracts** or **Sign by Hash**; the message names which. See [TRON app settings](guide/ledger.md#tron-app-settings) | | `tx_integrity` / `invalid_transaction` | A presigned transaction failed integrity / validity checks | | `insufficient_balance` / `insufficient_token_balance` | Not enough TRX / token to cover the amount plus fees | -| `provider_error` | A node or external service produced something the CLI will not act on — a malformed, self-contradictory or out-of-range response (TRON permission data, chain parameters, a protobuf codec the local TronWeb build does not expose, GasFree / TronLink payloads), a failed request, or an error status from GasFree / TronLink. TronLink reports **every** non-404 status this way, 429 included | -| `provider_rate_limited` | An external service returned HTTP 429. GasFree: `error.details.retryAfter` carries its `Retry-After` header when it sent one. x402 facilitator or endpoint: `error.details.retryAfterSeconds` when sent, plus the payment details below — a 429 during settlement is `paymentStatus: "unknown"`. B.AI, the SunSwap market and route services, and the SunPump API: `error.details.httpStatus: 429`, plus `error.details.retryAfterSeconds` from SunSwap / SunPump when a `Retry-After` header was sent. TronLink's 429 is `provider_error` instead | +| `provider_error` | A node or external service produced something the CLI will not act on — a malformed, self-contradictory or out-of-range response (TRON permission data, chain parameters, a protobuf codec the local TronWeb build does not expose, GasFree / TronLink payloads), a failed request, a 5xx status from GasFree, or an error status from TronLink. GasFree's other statuses have their own codes: 401/403 `gasfree_auth_failed`, 404 `not_found`, 429 `provider_rate_limited`, any other 4xx `gasfree_rejected`. TronLink reports every status other than 404 and 429 this way | +| `provider_rate_limited` | An external service returned HTTP 429: GasFree, TronLink, B.AI, the SunSwap market and route services, the SunPump API, an x402 facilitator, GasFree relay or endpoint, the x402 provider catalog, or the TronGrid history endpoint (`account history`). `error.details` carries `httpStatus: 429` and, when the service sent a `Retry-After` header as seconds or as an HTTP-date, `retryAfterSeconds` (whole seconds from now; `0` for a date in the past). The TronGrid history endpoint never reports `retryAfterSeconds`. A B.AI error also carries `procedure` and `retryPayment: false`. An x402 error adds the payment details below — a 429 during settlement is `paymentStatus: "unknown"` | | `tx_expired` | The transaction expired before or during signing — including while waiting for a Ledger confirmation, checked again after the device signs — or the TRON node rejected it as expired. Rebuild and sign again | -| `chain_id_mismatch` | An EVM transaction was built for a different chain than the selected network | +| `chain_id_mismatch` | A transaction or typed data names a different chain than the selected network | | `nonce_too_low` | The EVM transaction's nonce is already used by a mined transaction | -| `history_not_supported` | The selected network exposes no transaction history endpoint (`account history`, TRON) | +| `history_not_supported` | The selected network exposes no transaction history endpoint (`account history`, TRON): none is configured, or it answers HTTP 404 | | `not_found` | The addressed thing does not exist — for example an unactivated account, a transaction, a block, or a GasFree / TronLink resource. Lookups with a group of their own use the specific code below | | `proposal_not_found` / `contract_not_found` / `asset_not_found` / `exchange_not_found` | Nothing on chain under that proposal id, contract address, TRC10 reference, or exchange pair id | | `ambiguous_asset_name` | A TRC10 name matches more than one token; `error.details` carries the candidates — see [`error.details.matches`](#errordetailsmatches) | @@ -273,20 +295,20 @@ Common codes at exit **1** (execution — runtime failure): | `permit_mismatch` / `router_call_mismatch` | `sunswap swap`: the Permit2 grant did not match the planned swap, so it was not signed; or the encoded router call did not match the quoted swap, so it was not sent | | `launchpad_token_not_found` | That address is not a SunPump token on this network (`sunpump buy` / `sell` / `token-info`) | | `launchpad_trading_closed` | The token's bonding curve is closed — it is awaiting launch, or it has launched and moved to SunSwap. The message distinguishes the two: one is a wait, the other a redirect to `sunswap swap` | -| `launch_outcome_unknown` | `sunpump launch`: the create call failed without the service refusing it — a timeout, a 5xx, a dropped connection, or a reply that does not parse or names no token. The token may exist — look it up with `sunpump token-search ` (`error.details.symbol`) before launching again. `error.details.failure` / `httpStatus` say what went wrong, where known | +| `launch_outcome_unknown` | `sunpump launch`: the create call failed without the service refusing it — a timeout, a 5xx, a dropped connection, or a reply that does not parse or names no token. `error.details.outcome` is `"unknown"`. The token may exist — look it up with `sunpump token-search ` (`error.details.symbol`) before launching again. `error.details.failure` / `httpStatus` say what went wrong, where known | | `insufficient_reserve` | `exchange withdraw`: more than that side of the pair holds | -| `precision_loss` / `slippage_exceeded` / `exchange_trading_disabled` | Node rejections named from a narrow allowlist — an amount the reserve ratio cannot convert cleanly, a return below the floor, or a network that is not accepting Bancor trades at all | +| `precision_loss` / `slippage_exceeded` / `exchange_trading_disabled` | Node rejections named from a narrow allowlist — an amount the reserve ratio cannot convert cleanly, a return below the floor (`sunswap swap` also refuses a `--min-out` above the quote with it, before signing), or a network that is not accepting Bancor trades at all | | `not_exportable` | The account holds no exportable secret (watch-only or Ledger) — `backup` | | `wrong_keystore_password` | `import keystore`: the file's own password is wrong (distinct from `auth_failed`, which is the master password). A file whose `mac` is missing or not hex is `invalid_keystore`, not a wrong password — hex case is not significant | -| `legacy_derivation` | A TRON account #1 or later from a wallet created before 4.13.1 uses a derivation path this version no longer signs with. Raised when signing as that TRON address, or when `derive` adds an account to its wallet. See [Recover addresses after `legacy_derivation`](troubleshooting/legacy-derivation-recovery.md) | +| `legacy_derivation` | A seed account uses `m/44'/195'/'/0/0` with index greater than zero, which the CLI refuses to sign as a seed account. Raised when signing as that TRON address, or when `derive` adds an account to its wallet. See [Recover addresses after `legacy_derivation`](troubleshooting/legacy-derivation-recovery.md) | | `derivation_mismatch` | An account's stored address matches no derivation path of its seed — `wallets.json` and the encrypted vault disagree | -| `execution_reverted` | The contract reverted the call; `error.details.revertData` carries the raw revert data | +| `execution_reverted` | The contract reverted the call; `error.details.revertData` carries the raw revert data when the node returned any | | `not_authorized` | The account is not permitted to perform this operation. `8004 update` / `transfer` / `approve`: the account is neither the Agent's owner nor an operator allowed to do this, decoded from the registry's revert before signing | | `agent_not_found` | No Agent with that id in the selected network's registry — `8004 show`, and the `8004` writes (decoded from the registry's revert before signing) | | `amount_exceeds_limit` / `no_matching_requirement` | `x402 pay`, with or without `--dry-run`: the price is above `--max-amount` / `--max-raw-amount`, or no payment route offered by the endpoint matches the selected network, `--token`, `--asset`, or `--scheme`. Raised before signing (`details.paymentStatus: "not_sent"`) | | `gasfree_insufficient_balance` / `gasfree_not_activated` / `gasfree_asset_unsupported` | An `exact_gasfree` x402 payment: the GasFree account cannot cover payment plus maximum fee, is not activated, or does not hold the selected token contract (check the network, token and `--gasfree-relay`). Raised before anything is sent (`details.paymentStatus: "not_sent"`) | | `permit2_allowance_required` / `approval_reset_required` | An x402 payment needs a token allowance first, or the token needs its allowance reset to zero before a new one | -| `fee_cap_exceeded` / `payer_mismatch` / `signed_payload_mismatch` | An x402 authorization is refused before or after signing: the GasFree fee is above the cap, the payload names another payer, or the signature covers a different struct than requested | +| `fee_cap_exceeded` / `payer_mismatch` / `signed_payload_mismatch` | An x402 authorization is refused before or after signing: the GasFree fee is above the cap, the payload names another payer, or the signature covers a different struct than requested. `signed_payload_mismatch` is also raised for every signature from a Ledger (transaction, message or typed data) that is malformed, does not recover to the signing account, or covers other bytes than the ones the device was sent; the signature is not returned, written or submitted | | `invalid_settlement` / `invalid_x402_response` | A paid response carried an invalid settlement receipt, or an x402 response could not be decoded | | `provider_not_found` / `catalog_schema_unsupported` | `x402 provider-show` / `endpoint-list`: no provider by that name; or the downloaded catalog uses a version this build cannot read | | `port_in_use` | `x402 serve` / `roundtrip`: the requested local port is taken. `x402 serve --daemon` reports a background server that failed to start, including on a taken port, as `provider_error` with `error.details.logFile` | @@ -296,6 +318,18 @@ Common codes at exit **1** (execution — runtime failure): Unexpected exceptions are **redacted** to `internal_error` with a generic message, so a library error that happens to echo secret material can never reach the envelope. The two tables above are a reading aid; `--json-schema`'s `errorCodes` is the maintained discovery index, not a parser exhaustiveness guarantee. +TRON broadcast replies `OTHER_ERROR` and `NOT_ENOUGH_EFFECTIVE_CONNECTION` are uncertain outcomes: `rpc_error` with `error.details.txId`, `outcome: "unknown"` and `nodeCode`. A `DUP_TRANSACTION_ERROR` reply that names the transaction the command sent means the node already holds it: the broadcast reports `stage: "submitted"` with `alreadyKnown: true`, as EVM does for an "already known" reply, and the command then waits for confirmation as for any other submit. A `DUP_TRANSACTION_ERROR` that names no transaction or another one is an uncertain outcome like `OTHER_ERROR`. Unclassified EVM JSON-RPC broadcast errors also use `rpc_error` with `txId` and `outcome: "unknown"`; recognized refusals use `transaction_rejected` or their specific rejection code. An unrecognizable broadcast reply is `invalid_node_response`, with the signed transaction id and `outcome: "unknown"`. + +### Unknown outcome (`error.details.outcome`) + +An error whose request may have acted carries `error.details.outcome: "unknown"`: the transaction, payment or service write may have taken effect even though the command failed. + +- **Where it appears.** On every path that submits or pays: a chain submit (`tx send`, `tx broadcast`, and every other ✍️ command), `tx multisig --create` / `--sign`, `gasfree transfer` (including a `gasfree_integrity` failure after the submission), an x402 or B.AI payment whose `paymentStatus` is `unknown`, the order B.AI creates before a `bai recharge` payment, and `launch_outcome_unknown`. +- **What it means for retrying.** It overrides the code's `retry` class. An agent that sees it does not rebuild or re-sign the operation, because that creates a second, distinct one. +- **How to reconcile.** Look up `error.details.txId` with [`tx status`](commands/tx/status.md), or, when there is no `txId`, use the command's own evidence (the GasFree `nonce` or `traceId`, `candidateTxHash`, the B.AI order, `sunpump token-search`). For a chain transaction, an agent may instead rebroadcast the same signed transaction with [`tx broadcast`](commands/tx/broadcast.md): a node that already holds it answers `submitted` with `alreadyKnown: true`. +- **Where it does not appear.** A failure known not to have acted — a definite rejection, a refusal before anything was sent, or a `timeout` that sent nothing — does not carry the field. +- The other fields (`txId`, `paymentStatus`, `retryPayment`, …) keep the meanings given for each command. + ### x402 and B.AI payment details A failed payment carries extra fields in `error.details` so a script can tell whether money may have moved: @@ -304,13 +338,26 @@ A failed payment carries extra fields in `error.details` so a script can tell wh |---|---| | `phase` | Where it failed: `request`, `challenge`, `create_payment`, `sign`, `payment_request`, `verify`, or `settle` | | `httpStatus` | HTTP status of the failed request, when there was one | -| `paymentStatus` | `not_sent` — nothing was sent; `unknown` — the outcome cannot be established, so reconcile before paying again; `settled` — a settlement receipt arrived but the paid resource could not be delivered | +| `paymentStatus` | `not_sent` — nothing was sent; `unknown` — the outcome cannot be established, so reconcile before paying again (the error also carries [`outcome: "unknown"`](#unknown-outcome-errordetailsoutcome)); `settled` — a settlement receipt arrived but the paid resource could not be delivered | | `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 | +| `approval` | TRON: the one-time Permit2 approve that was signed before the failure — `{txId, token, spender, allowance, feeLimitSun, status}` with `status` `confirmed` (mined before the payment was signed) or `exported` (signed into the payment package for the endpoint to sponsor). When the approve fails after it was sent and before it confirmed, `error.details.txId` carries its transaction ID instead, with `outcome: "unknown"` unless the approve is known to have failed on chain (`execution_reverted`): an unconfirmed approve may still land. It is not the payment; do not report it as one. Only `status: "confirmed"` means the allowance exists on chain; `exported` is a signature only. A `paymentStatus: "not_sent"` beside it means the payment package, with any exported approval in it, did not leave in a paid request | +| `orderId` | `bai recharge`: B.AI's id of the order created before the payment, when B.AI returned one | + +**Treat `paymentStatus: "unknown"` as possibly paid.** A payment whose request was never sent, even after it was signed, reports `not_sent`; once the paid request is sent, an error without a settlement receipt reports `unknown`. -**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. +### Out of time after a submission + +These commands do not submit a chain transaction of their own, so a deadline that ends after their submission is reported this way: + +| Command | Result | +|---|---| +| `gasfree transfer` | The provider accepted the transfer but the time limit ended the wait for its outcome: exit `0`, the submitted receipt with its `traceId`, and a warning. The reply to the submission was lost: `timeout` or `provider_error` with `paymentStatus: "unknown"`, `outcome: "unknown"`, `retryPayment: false`, and `network`, `serviceProvider`, `owner`, `from` (the GasFree address), `to`, `token`, `tokenAddress`, `amount`, `authorizedMaxFee`, `nonce`, `deadline` (the authorization's expiry, unix seconds), and `permitDigest` in `error.details`. No `traceId` is known and the signature is not included. The CLI does not look the transfer up afterwards: compare the `nonce` that [`gasfree info`](commands/gasfree/info.md) reports with `details.nonce` before you transfer again. A receipt or polled record that does not match the signed authorization fails with `gasfree_integrity`, `traceId` and `outcome: "unknown"`; check it with [`gasfree trace`](commands/gasfree/trace.md) before you transfer again | +| `tx multisig --create` / `--sign` | The reply to the TronLink submission was lost: the error adds `txId` (when the transaction has one), `address`, `network` and `outcome: "unknown"` to `error.details`, with a warning to inspect the address's existing collection before submitting again | +| `x402 pay`, `x402 roundtrip` | No paid request is sent with less than 2000 ms left (`timeout`, `phase: "payment_request"`, `paymentStatus: "not_sent"`, `retryPayment: false`). A limit that ends while the payment is being created — node reads, GasFree relay reads, signing — fails with `timeout` and `phase` `create_payment` or `sign`, and no request of the payment starts after it. An interrupted payment carries the [payment details](#x402-and-bai-payment-details) above | +| `bai recharge` | The time ran out after the payment and before it was reported to B.AI: exit `0` with `creditStatus: "unconfirmed"`, the `txHash`, `retryPayment: false`, `code: "timeout"`, and a warning to report it with `bai report-recharge`. Do not pay again. A payment the time limit refused before its request was sent fails with `timeout` and `paymentStatus: "not_sent"`. When the reply to the order creation is lost (a `timeout`, a 5xx, a dropped connection or a reply that does not parse), the error carries `outcome: "unknown"` and no payment details: no payment was made, but B.AI may hold an unpaid order | +| `sunpump launch` | A launch request that may have reached SunPump fails with `launch_outcome_unknown` and `outcome: "unknown"`; see [`sunpump launch`](commands/sunpump/launch.md) | ### `error.details.matches` @@ -334,7 +381,7 @@ Alongside it, an error may carry a scalar list of just the identifiers to retry wallet-cli never reads passwords, mnemonics or private keys from argv or from dedicated secret environment variables: arguments and exported values leak into shell history, process listings and CI logs. Two channels only: -1. **stdin flags** — `--password-stdin` for the master password, `--tx-stdin` / `--message-stdin` for large payloads. **Only one `*-stdin` flag can consume stdin per run.** (Mnemonics and private keys have no stdin path — `import mnemonic` / `import private-key` / `change-password` are interactive-only, hidden TTY input.) +1. **stdin flags** — `--password-stdin` for the master password, `--tx-stdin` / `--message-stdin` for large payloads, `--api-key-stdin` for the service credentials `config` stores. **Only one `*-stdin` flag can consume stdin per run.** (Mnemonics and private keys have no stdin path — `import mnemonic` / `import private-key` / `change-password` are interactive-only, hidden TTY input.) 2. **Interactive TTY prompt** — only on the commands that declare themselves interactive: `create`, the `import` variants, `backup`, `change-password`, and `delete`'s confirmation. Everywhere else a terminal changes nothing: `tx send`, `contract *`, `stake *`, `message sign` and friends never prompt, and a missing master password is `auth_required` (exit `1`) whether or not a TTY is attached. A shell variable in an example is only a shell-side source for a pipe; wallet-cli does not read it. Keep such variables process-local and short-lived, and do not export them long term. @@ -350,19 +397,25 @@ printf '%s' "$MASTER_PASSWORD_FROM_YOUR_VAULT" | wallet-cli tx send \ This is a wallet; a wrong success check loses money. The rules: -1. Broadcast (✍️) commands **by default return after submission**, not confirmation. The payload is a flat object with a `kind` naming the operation (`send`, `stake-freeze`, `permission-update`, `account-activate`, `proposal-create`, `asset-issue`, `exchange-trade`, …), a `stage`, and the `txId`; the `submitted` stage carries no block / fee / result (those appear only after `--wait` confirms): +1. Broadcast (✍️) commands **wait for confirmation by default**: they poll until the transaction is confirmed or failed, within the command's [time limit](#time-limit---timeout). When the time runs out first, the command returns the submitted receipt with a warning. This covers transactions the CLI signs and submits, `tx broadcast` of an imported signed transaction (TRON and EVM), and `gasfree transfer`. + - **Chain transactions.** A failed poll is retried only when its error code has `retry` `same` or `later`; any other error ends the wait at once, and the command returns the submitted receipt with a `{code, message}` warning whose `code` is that error code. A transaction that later steps depend on (an approval) fails instead; see item 5. + - **`--timeout 0`.** The command polls until there is an outcome or such an error, which for an EVM transaction that is never mined means indefinitely; use a finite `--timeout` or `--no-wait` when the command must end. + - **`gasfree transfer`** polls the GasFree trace instead, with its own rules: it retries a poll that could not reach the service, got a 5xx status, or timed out; any other failed poll ends the wait, and the command returns the submitted receipt with a plain-text warning that names the error code and the `gasfree trace` command to follow it with. A trace that does not match the signed authorization, changes its id, or moves back to an earlier state fails with `gasfree_integrity`, `traceId`, and `outcome: "unknown"`. + - **Payload.** A flat object with a `kind` naming the operation (`send`, `stake-freeze`, `permission-update`, `account-activate`, `proposal-create`, `asset-issue`, `exchange-trade`, …), a `stage`, and the `txId`; block, fee, and result fields appear only with `confirmed` or `failed`. - ```json - { "kind": "send", "stage": "submitted", "txId": "7d9b6a08…", "rawAmount": "1000000", "to": "TSx72…" } - ``` + **A receipt reports the transaction outcome in `data.stage`, never in `success`.** A transaction that was accepted, mined, and then reverted is a *successful command* carrying a *failed transaction*: the envelope stays `success: true` and the exit code stays `0`, while `data.stage` is `"failed"`. Exit codes say whether the CLI could carry out the request, not whether the chain accepted the result — so branch on `data.stage` (`confirmed` / `failed` / `submitted`) before recording the operation as done. `submitted` means the outcome is not known yet. - **Ids the chain assigns arrive only with confirmation.** A new proposal's `proposalId`, a TRC10's `assetId`, an exchange pair's `exchangeId` do not exist at submission — they are absent from the submitted receipt and appear once `--wait` (or a later query) sees the transaction on chain. Scripts that create one of these must wait for it. + **Ids the chain assigns arrive only with confirmation.** A new proposal's `proposalId`, a TRC10's `assetId`, an exchange pair's `exchangeId` do not exist at submission — they are absent from a submitted receipt and appear once the command (or a later query) sees the transaction on chain. The same applies to an Agent id: `8004 register` puts the requested `uri` in `data.identity` at once, and adds `identity.agentId` (a decimal string) only when a confirmed registration event can be read. `8004 update` reports `identity.agentId`, `oldURI` and `requestedURI`, plus `newURI` after confirmation; `8004 transfer` reports `agentId`, `oldOwner` and `requestedOwner`, plus `newOwner` after confirmation. `8004 approve` reports `agentId` and `operator` (the zero address for `--revoke`). `8004 add-operator` and `remove-operator` report `operator` and `requestedApproval`, plus `approved` — read back from the registry — after confirmation. -2. To block until the outcome is known, pass `--wait` (polls until confirmed/failed, capped by `--wait-timeout`, default 60000 ms; on cap it returns the submitted receipt). +2. To return right after submission, pass `--no-wait`. The receipt has `stage: "submitted"` and the `txId`, with no block, fee, or result: + + ```json + { "kind": "send", "stage": "submitted", "txId": "7d9b6a08…", "rawAmount": "1000000", "to": "TSx72…" } + ``` - **A `--wait` receipt reports the transaction outcome in `data.stage`, never in `success`.** A transaction that was accepted, mined, and then reverted is a *successful command* carrying a *failed transaction*: the envelope stays `success: true` and the exit code stays `0`, while `data.stage` is `"failed"`. Exit codes say whether the CLI could carry out the request, not whether the chain accepted the result — so after any `--wait`, branch on `data.stage` (`confirmed` / `failed` / `submitted`) before recording the operation as done. + `--wait` names the default explicitly. Pass each flag bare and at most one of them: `--wait=`, `--no-wait=`, a following `true` or `false`, or both flags together fail with `invalid_option` (exit `2`). Both flags apply only where a transaction is submitted: with `--dry-run`, `--build-only`, `--sign-only`, or `--quote`, on reads and local commands, and on `x402 pay`, `x402 roundtrip`, `bai recharge`, `sunpump launch`, and `tx multisig`, either flag fails with `invalid_option` and nothing is sent. Without the flags these commands run as usual. A command's `--help` lists `--wait` and `--no-wait` only where they apply. 3. Or poll yourself with `tx status`, which has a **four-state model**: @@ -379,7 +432,7 @@ This is a wallet; a wrong success check loses money. The rules: > A deadline that ends in `pending` or `not_found` is an unknown outcome. Do not record it as failed, and do not resend automatically without external reconciliation. - **GasFree transfers are the exception.** `gasfree transfer` submits to a provider, not directly to a node: the submitted receipt carries a `traceId` (not a `txId`), and progress follows the provider's states — `WAITING` → `INPROGRESS` → `CONFIRMING` → `SUCCEED` / `FAILED`. Follow it with `--wait` or [`gasfree trace `](commands/gasfree/trace.md) rather than `tx status`; a `txId` appears only once the provider puts it on-chain. + **GasFree transfers are the exception.** `gasfree transfer` submits to a provider, not directly to a node: the submitted receipt carries a `traceId` (not a `txId`), and progress follows the provider's states — `WAITING` → `INPROGRESS` → `CONFIRMING` → `SUCCEED` / `FAILED`. The command follows these states by default; after `--no-wait`, follow them with [`gasfree trace `](commands/gasfree/trace.md) rather than `tx status`; a `txId` appears only once the provider puts it on-chain. ```bash #!/usr/bin/env bash @@ -388,7 +441,7 @@ set -euo pipefail deadline=$((SECONDS + 90)) txid=$( printf '%s' "$PW" | - wallet-cli tx send --to T... --amount 1 --network tron:3448148188 --password-stdin -o json | + wallet-cli tx send --to T... --amount 1 --network tron:3448148188 --no-wait --password-stdin -o json | jq -er '.data.txId' ) @@ -418,9 +471,13 @@ exit 1 4. **Batch operations**: each command is one transaction with one exit code — except the ones in rule 5. Stop-on-first-failure is the default safe posture; if you continue, track per-item txids and reconcile with `tx status` before reporting success. -5. **A command may send several transactions.** The `sunswap` liquidity commands, a router `sunswap swap` that spends a token, and a first `sunpump sell` approve before they act, and the approval must be on chain before the call that spends it. Each approval's txid is in `data.approvalTxIds`, in the order sent, beside the main `txId`. Approvals always wait for confirmation, even without `--wait` (which governs only the main transaction); each waits up to `--wait-timeout` (default 60000 ms), so several approvals lengthen the run, and one that does not confirm in time stops the command with `timeout` (exit 1) before the next step. If a later step fails — a confirmation timeout, another approval, the Permit2 signature, or the main call — the original error code is kept and `error.details.approvalTxIds` lists the approvals already submitted (text errors list them too). They are separate transactions and are not rolled back; a listed id records submission, not success, so check its receipt before retrying. Re-running is safe: an allowance that already suffices is not approved again. +5. **A command may send several transactions.** The `sunswap` liquidity commands, a router `sunswap swap` that spends a token, and a first `sunpump sell` approve before they act, and the approval must be on chain before the call that spends it. Each approval's txid is in `data.approvalTxIds`, in the order sent, beside the main `txId`. + - **Waiting.** Approvals always wait for confirmation, also with `--no-wait` (which applies only to the main transaction). The approvals and the main transaction share the command's [time limit](#time-limit---timeout); an approval that does not confirm in time stops the command with `timeout` (exit 1) before the next step. + - **An approval's wait stops on an error.** When the error cannot be retried unchanged, the command stops with that error's code, the approval's id in `error.details.txId` and in `error.details.approvalTxIds`, and `outcome: "unknown"`. + - **A later step fails** — a confirmation timeout, another approval, the Permit2 signature, or the main call. The original error code is kept and `error.details.approvalTxIds` lists the approvals already submitted (text errors list them too). They are separate transactions and are not rolled back; a listed id records submission, not success, so check its receipt before retrying. + - **Re-running is safe:** an allowance that already suffices is not approved again. -6. **One command writes without a transaction of ours.** `sunpump launch` asks SunPump to create a token; the service signs it, pays for it and chooses its owner. So there is no `stage`, no `confirmed`, no fee and no `--wait` — and `data.token.owner` is the creator the service picked, **not** the local account. Its `createTxHash` is the service's own: use `tx info` to find out whether it is on chain yet. A refusal from that service is `provider_error` (exit `1`), including a name or symbol it will not accept. +6. **One command writes without a transaction of ours.** `sunpump launch` asks SunPump to create a token; the service signs it, pays for it and chooses its owner. So there is no `stage`, no `confirmed`, no fee, and no `--wait` or `--no-wait` — and `data.token.owner` is the creator the service picked, **not** the local account. Its `createTxHash` is the service's own: use `tx info` to find out whether it is on chain yet. A refusal from that service is `provider_error` (exit `1`), including a name or symbol it will not accept. 7. **Trade receipts separate measured from quoted.** A confirmed `sunswap swap` reports `amountOut`, `sunpump buy` `tokensOut`, `sunpump sell` `trxOut`, and the liquidity commands their token amounts — read from the transaction's own transfers and events, excluding network fees, with `amountsEstimated: false`. If the receipt cannot be read, the pre-trade estimate stays, `amountsEstimated` is `true` and a warning says why; a failed receipt read never turns a confirmed trade into a failure. A router swap's `priceImpactPercent` and trading fee remain quoted figures (`priceImpactEstimated: true`). diff --git a/ts/docs/troubleshooting.md b/ts/docs/troubleshooting.md index b49eb766d..d31166049 100644 --- a/ts/docs/troubleshooting.md +++ b/ts/docs/troubleshooting.md @@ -4,7 +4,7 @@ Remedies for humans, keyed by the [error codes](machine-interface.md#error-codes ## `migration_required` (exit 2) -Your local wallet data was written by an older version and has to be upgraded before a command that uses it can run. The upgrade gate runs ahead of the command you typed, and that command is deliberately not executed. `--help`, `--version`, `--json-schema`, and a bare `wallet-cli` never read wallet data, so they skip the gate. +Local wallet data whose schema requires an upgrade must be upgraded before a command that uses it can run. The upgrade gate runs ahead of the command you typed, and that command is deliberately not executed. `--help`, `--version`, `--json-schema`, and a bare `wallet-cli` never read wallet data, so they skip the gate. - **At a terminal**: re-run it. The gate asks for consent first, then for the master password when the upgrade has to re-derive account data. - **Non-interactive (CI, pipes)**: pass `--password-stdin`. Ledger-only and watch-only wallets hold no secret to re-encrypt and upgrade without a password; a wallet holding a seed or a private key needs one, and this code is what you get when it is missing. @@ -15,15 +15,18 @@ The full behaviour, including the `migration` result envelope, is in [startup wa ## `encoding_error`: an account this build cannot read (exit 1) -The opposite direction of `migration_required` — a wallet registry written by a **newer** wallet-cli, holding an account whose storage kind this build has no reader for. +A wallet registry can hold an account whose storage kind this build has no reader for. - [`list`](commands/list.md) degrades instead of failing: it shows every account it can read and warns, naming the wallet ids it skipped. The address scan behaves the same way, so an address you can still see keeps resolving. - Naming an unreadable account — `--account`, or by label or address — is refused with `encoding_error` **before the command acts**, so nothing is written and no receipt is half-built. -- The remedy is to upgrade wallet-cli to a version that understands the format. Nothing local is corrupt. +- Use a wallet-cli build that understands that storage kind. +- Malformed `wallets.json`, `tokens.json` or `contacts.json` also fails with `encoding_error`. Restore the affected file from a verified backup; do not replace it with an empty file. Token-book validation also applies when adding or removing an official token. + +Stored contact addresses may be duplicated, but contact names must be unique across families. Stored wallet labels may exceed 64 characters; added or changed labels must satisfy the 1–64 character rule. Writes preserve unchanged records. ## `legacy_derivation` (exit 1) -The account is a TRON account #1 or later in a wallet created before 4.13.1, and uses a derivation path this version no longer signs with. The same error stops `derive` from adding accounts to that wallet. Nothing is lost: follow [Recover addresses after `legacy_derivation`](troubleshooting/legacy-derivation-recovery.md) **before deleting anything**. +The seed account uses `m/44'/195'/'/0/0` with index greater than zero, which the CLI refuses to sign as a seed account. The same error stops `derive` from adding accounts to that wallet. Nothing is lost: follow [Recover addresses after `legacy_derivation`](troubleshooting/legacy-derivation-recovery.md) **before deleting anything**. ## `derivation_mismatch` (exit 1) @@ -35,13 +38,13 @@ The command was malformed — a flag is unknown, missing, conflicting, or has a - Re-run with `--help` on the exact subcommand: `wallet-cli tx send --help`. - Common conflicts: `--amount` vs `--raw-amount`; `--token` vs `--contract` vs `--asset-id`; `--dry-run` vs `--sign-only`; `--constructor-args` vs `--constructor-params`; `--artifact` vs `--code` vs `--code-file` on `contract deploy`; two `*-stdin` flags in one run. -- `invalid_value` on `config`: check the allowed keys (`defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`, `networks`, `aliases`, `networks..{httpEndpoint|apiKeyHeader|apiKey}`, and the credential keys `gasfreeApiKey`, `gasfreeApiSecret`, `tronlinkSecretId`, `tronlinkSecretKey`, `tronlinkChannel`, `baiApiKey`) and values (`defaultOutput` is `text` or `json`). +- `invalid_value` on `config`: check the allowed keys (`defaultNetwork`, `defaultOutput`, `timeoutMs`, `networks`, `aliases`, `networks..{httpEndpoint|apiKeyHeader|apiKey}`, and the credential keys `gasfreeApiKey`, `gasfreeApiSecret`, `tronlinkSecretId`, `tronlinkSecretKey`, `tronlinkChannel`, `baiApiKey`) and values (`defaultOutput` is `text` or `json`; `timeoutMs` is an integer from 0 to 2147483647). ## `family_mismatch` (exit 2) The command, the account, or the transaction does not belong to the selected network's chain family — for example `stake freeze --network sepolia`, or a TRON-only watch-only account used on an EVM network. -- Check which family the command serves: `wallet-cli --help` names it, and [the command reference](commands/index.md#which-commands-run-on-which-networks) lists every TRON-only command. +- Check which family the command serves: its `families` in `wallet-cli --json-schema` names it, and [the command reference](commands/index.md#which-commands-run-on-which-networks) lists every TRON-only command. - Check which network you actually selected — with `--network` omitted it is `config.defaultNetwork`. `wallet-cli config defaultNetwork` shows it. - If the account is the mismatch: a seed or private-key account works on both families, but a **watch-only or Ledger account has one address and one family**. `wallet-cli list -o json` shows each account's `addresses` and its `family`. @@ -76,12 +79,13 @@ A credential, secret, or signing-device approval was needed but none was availab ## `timeout` (exit 1) -The node or the Ledger device didn't answer within `--timeout` (default 60000 ms). +The command did not finish within `--timeout` (default 90000 ms), which covers the whole command, or a node, service or Ledger device did not answer in time. - Check basic connectivity to the network; if you are behind a proxy, verify the CLI's traffic actually goes through it. -- Raise the bound: `--timeout 120000`. +- Raise the limit: `--timeout 180000`. A `--timeout` below 2000 never submits a transaction or payment. +- A transaction that was accepted but not yet confirmed when the time runs out returns `stage: "submitted"` and a warning instead of `timeout`; check it with `tx status`. - Ledger: confirm the device is unlocked and the app the account was registered with (`--app tron` or `--app ethereum`) is open, then retry. -- **If this happened on `tx send`**: the transaction may still have been submitted. Recover the txid if you have it and check `tx status` before resending. +- **If `error.details.outcome` is `"unknown"`**, the transaction, payment or service write may have taken effect. When `error.details.txId` is present, check `tx status` with that id before resending; otherwise reconcile with the command's own evidence (the GasFree `nonce` or `traceId`, `candidateTxHash`, the B.AI order, `sunpump token-search`). See [unknown outcome](machine-interface.md#unknown-outcome-errordetailsoutcome). ## `rpc_error` (exit 1) @@ -89,9 +93,9 @@ The node accepted the connection but rejected the request — a TRON API call, o - *Transaction not found*: wrong `--txid`, wrong `--network` (a Nile txid queried on mainnet), or the tx hasn't propagated yet — retry after a few seconds. - *Insufficient balance / bandwidth / energy*: fund the account, or stake for resources (`stake freeze`) — see [Networks](concepts/networks.md) for how resources work; on Nile use the faucet. -- *TRC20 send reverting* (`estimateEnergy failed: REVERT opcode executed`): work through it in this order — (1) **the token balance is too low** for the amount you asked for, by far the most common cause: check it with `token balance --contract
`, and remember a TRX faucet does not give you tokens; (2) the recipient or the contract address is wrong; (3) only once both are ruled out, consider raising `--fee-limit` (default 100000000 SUN) — raising it does not fix an insufficient balance, it only lets a genuinely expensive call through. +- *TRC20 send reverting* (`execution_reverted`, "TRON constant call reverted"): work through it in this order — (1) the recipient or the contract address is wrong; (2) only once that is ruled out, consider raising `--fee-limit` (default 100000000 SUN) — it only lets a genuinely expensive call through. A token balance too low for the amount fails before this, with `insufficient_token_balance`: check it with `token balance --contract
`, and remember a TRX faucet does not give you tokens. -- *`Request failed with status code 429`*: the public TronGrid endpoint is rate-limiting you. Space the calls out and retry, or configure an API key with `config networks..apiKey`. (A 429 from the SunSwap or SunPump services is `provider_rate_limited` instead.) +- *`Request failed with status code 429`*: the public TronGrid endpoint is rate-limiting you. Space the calls out and retry, or configure an API key with `config networks..apiKey --api-key-stdin`. (A 429 from GasFree, TronLink, B.AI, x402, the SunSwap or SunPump services, or `account history` is `provider_rate_limited` instead.) ## `unsupported_network_capability` (exit 2) diff --git a/ts/docs/troubleshooting/legacy-derivation-recovery.md b/ts/docs/troubleshooting/legacy-derivation-recovery.md index f475216ad..52afdeda0 100644 --- a/ts/docs/troubleshooting/legacy-derivation-recovery.md +++ b/ts/docs/troubleshooting/legacy-derivation-recovery.md @@ -1,6 +1,6 @@ # Recover addresses after `legacy_derivation` -Before 4.13.1, wallet-cli derived TRON accounts at `m/44'/195'/'/0/0`. It now uses `m/44'/195'/0'/0/`, the same layout as EVM. Account #0 is the same under both, and EVM paths never changed. **TRON accounts #1 and later in an older wallet keep their old address**, and this version handles them like this: +The default TRON seed path is `m/44'/195'/0'/0/`. A stored seed account can instead use `m/44'/195'/'/0/0`. The paths agree at index 0. **At indexes greater than zero, the CLI retains the stored TRON address** and applies these rules: | What you do | Result | |---|---| @@ -14,10 +14,7 @@ The old key is not lost — the recovery phrase still produces it at the old pat ## The error -```console -error [legacy_derivation]: account "main-1" was derived at m/44'/195'/1'/0/0, a TRON path this version no longer produces, so it cannot be signed here. Follow the complete recovery procedure before deleting anything: - https://github.com/tronprotocol/wallet-cli/blob/wallet-cli-4.13.1/ts/docs/troubleshooting/legacy-derivation-recovery.md -``` +`legacy_derivation` identifies the affected account and its TRON path, and points to this recovery procedure. ## Before you start @@ -43,11 +40,10 @@ wallet-cli list -o json > ./accounts-before.json wallet-cli backup main --out ./main-mnemonic.json ``` -Choose **Native wallet backup** at the prompt. The backup lists every account that needs step 1, with the command for it: +Choose **Native wallet backup** at the prompt. The warning lists each affected account and supplies its keystore export command: -```console -warning: this version's default mnemonic recovery will NOT recreate wlt_n5g8s27j.1 (m/44'/195'/1'/0/0) — that account was derived at a TRON path this version no longer produces, but the recovery phrase can still derive that key at the listed path. Export it separately before deleting anything: - wallet-cli backup wlt_n5g8s27j.1 --keystore --network tron:728126428 --password-stdin +```bash +wallet-cli backup wlt_n5g8s27j.1 --keystore --network tron:728126428 --password-stdin ``` Keep both files private and out of version control. @@ -77,7 +73,7 @@ wallet-cli import keystore ./main-1-tron.keystore.json --label main-1-legacy-tro The TRON address matches the old one and signs normally. The EVM address comes from the same private key and is **not** the wallet's EVM address for `main-1` — ignore it unless you mean to use it. -Check that every old TRON address now also appears under `private key`: +Check that every affected TRON address also appears under `private key`: ```bash wallet-cli list --network tron:728126428 diff --git a/ts/eslint-rules/closed-port-types.mjs b/ts/eslint-rules/closed-port-types.mjs new file mode 100644 index 000000000..aaf7a03e3 --- /dev/null +++ b/ts/eslint-rules/closed-port-types.mjs @@ -0,0 +1,167 @@ +import { isAbsolute, posix, relative, resolve, sep } from "node:path"; +import ts from "typescript"; + +const scopes = ["src/application/ports/", "src/application/contracts/", "src/domain/types/"]; +const declarationKinds = new Set([ + ts.SyntaxKind.InterfaceDeclaration, + ts.SyntaxKind.TypeAliasDeclaration, + ts.SyntaxKind.ClassDeclaration, +]); +const signatureKinds = new Set([ + ts.SyntaxKind.MethodSignature, + ts.SyntaxKind.MethodDeclaration, + ts.SyntaxKind.CallSignature, + ts.SyntaxKind.ConstructSignature, + ts.SyntaxKind.FunctionType, + ts.SyntaxKind.ConstructorType, +]); +const analyses = new WeakMap(); + +function projectPath(cwd, fileName) { + return relative(cwd, fileName).split(sep).join(posix.sep); +} + +function isExported(statement) { + return statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); +} + +function describe(node) { + return node.kind === ts.SyntaxKind.AnyKeyword ? "any" : "unknown"; +} + +function location(node) { + const file = node.getSourceFile(); + const at = (position) => { + const { line, character } = file.getLineAndCharacterOfPosition(position); + return { line: line + 1, column: character }; + }; + return { start: at(node.getStart()), end: at(node.getEnd()) }; +} + +// Walks port-facing types with a direction: true while a value flows from an adapter to the inside. +function analyze(program, cwd) { + const checker = program.getTypeChecker(); + const reports = new Map(); + const memo = new Map(); + const inScope = (file) => { + const path = projectPath(cwd, file.fileName); + return scopes.some((scope) => path.startsWith(scope)) && !path.endsWith(".test.ts"); + }; + const isExternal = (file) => + file.isDeclarationFile || + program.isSourceFileFromExternalLibrary(file) || + program.isSourceFileDefaultLibrary(file); + + function report(node, what) { + const file = resolve(node.getSourceFile().fileName); + if (!reports.has(file)) reports.set(file, new Map()); + reports.get(file).set(node.getStart(), { loc: location(node), what }); + } + + // Reports open leaves in scope; returns a description of one reached outside scope. + function visit(node, inward) { + if (!node) return undefined; + if (node.kind === ts.SyntaxKind.UnknownKeyword || node.kind === ts.SyntaxKind.AnyKeyword) { + return leaf(node, inward); + } + if (signatureKinds.has(node.kind)) { + let hidden; + for (const parameter of node.parameters) hidden ??= visit(parameter.type, !inward); + return visit(node.type, inward) ?? hidden; + } + if (node.kind === ts.SyntaxKind.TypeReference) return reference(node, node.typeName, inward); + if (node.kind === ts.SyntaxKind.ExpressionWithTypeArguments) { + return reference(node, node.expression, inward); + } + if (declarationKinds.has(node.kind)) return declaration(node, inward); + if ( + node.kind === ts.SyntaxKind.TypeQuery || + node.kind === ts.SyntaxKind.TypeParameter || + node.kind === ts.SyntaxKind.Identifier || + (node.kind === ts.SyntaxKind.TypeOperator && node.operator === ts.SyntaxKind.KeyOfKeyword) + ) { + return undefined; + } + if (node.kind === ts.SyntaxKind.MappedType) return visit(node.type, inward); + let hidden; + ts.forEachChild(node, (child) => { + hidden ??= visit(child, inward); + }); + return hidden; + } + + function leaf(node, inward) { + if (!inward) return undefined; + if (!inScope(node.getSourceFile())) return describe(node); + report(node, describe(node)); + return undefined; + } + + function reference(node, name, inward) { + let hidden; + for (const argument of node.typeArguments ?? []) hidden ??= visit(argument, inward); + let symbol = checker.getSymbolAtLocation(name); + if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol); + for (const target of symbol?.declarations ?? []) { + const file = target.getSourceFile(); + if (!declarationKinds.has(target.kind) || isExternal(file)) continue; + const found = declaration(target, inward); + if (found && inScope(node.getSourceFile())) { + report(node, `${symbol.name} (${found})`); + } else hidden ??= found; + } + return hidden; + } + + function declaration(node, inward) { + const key = `${inward}:${node.getSourceFile().fileName}:${node.pos}`; + if (memo.has(key)) return memo.get(key); + // A recursive reference ends here as closed; the first visit reports its leaves. + memo.set(key, undefined); + let hidden; + if (node.kind === ts.SyntaxKind.TypeAliasDeclaration) hidden = visit(node.type, inward); + else { + for (const clause of node.heritageClauses ?? []) { + for (const type of clause.types) hidden ??= visit(type, inward); + } + for (const member of node.members) hidden ??= visit(member, inward); + } + memo.set(key, hidden); + return hidden; + } + + for (const file of program.getSourceFiles()) { + if (!inScope(file) || !projectPath(cwd, file.fileName).startsWith(scopes[0])) continue; + for (const statement of file.statements) { + if ( + statement.kind === ts.SyntaxKind.InterfaceDeclaration && + isExported(statement) && + statement.members.some((member) => signatureKinds.has(member.kind)) + ) { + declaration(statement, true); + } + } + } + return reports; +} + +export default { + meta: { + type: "problem", + schema: [], + messages: { open: "Open type crosses a port inward: {{what}}." }, + }, + create(context) { + const program = context.sourceCode.parserServices?.program; + if (!program) throw new Error("closed-port-types requires type information."); + if (!analyses.has(program)) analyses.set(program, analyze(program, context.cwd)); + const file = isAbsolute(context.filename) ? context.filename : resolve(context.filename); + return { + Program() { + for (const { loc, what } of analyses.get(program).get(resolve(file))?.values() ?? []) { + context.report({ loc, messageId: "open", data: { what } }); + } + }, + }; + }, +}; diff --git a/ts/eslint-rules/environment-reads.d.mts b/ts/eslint-rules/environment-reads.d.mts new file mode 100644 index 000000000..63ac23131 --- /dev/null +++ b/ts/eslint-rules/environment-reads.d.mts @@ -0,0 +1,4 @@ +import type { Rule } from "eslint"; + +declare const rule: Rule.RuleModule; +export default rule; diff --git a/ts/eslint-rules/environment-reads.mjs b/ts/eslint-rules/environment-reads.mjs new file mode 100644 index 000000000..464fa5f50 --- /dev/null +++ b/ts/eslint-rules/environment-reads.mjs @@ -0,0 +1,148 @@ +function propertyName(node) { + const property = node.property ?? node.key; + if (!node.computed && property.type === "Identifier") return property.name; + if (property.type === "Literal") return property.value; + if (property.type === "TemplateLiteral" && property.expressions.length === 0) { + return property.quasis[0].value.cooked; + } +} + +const expressionWrappers = [ + "ChainExpression", + "TSAsExpression", + "TSNonNullExpression", + "TSTypeAssertion", + "TSSatisfiesExpression", +]; + +function unwrap(node) { + while (expressionWrappers.includes(node.type)) { + node = node.expression; + } + return node; +} + +function binding(sourceCode, node) { + for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) { + const variable = scope.set.get(node.name); + if (variable && variable.isValueVariable !== false) return variable; + } +} + +function isGlobal(sourceCode, node, name) { + return ( + node.type === "Identifier" && node.name === name && !binding(sourceCode, node)?.defs.length + ); +} + +function imported(sourceCode, node, name) { + if (node.type !== "Identifier") return false; + return ( + binding(sourceCode, node)?.defs.some((definition) => { + if ( + definition.type !== "ImportBinding" || + !["process", "node:process"].includes(definition.parent.source.value) + ) + return false; + const specifier = definition.node; + if (name === "process") { + return ( + ["ImportDefaultSpecifier", "ImportNamespaceSpecifier"].includes(specifier.type) || + (specifier.imported?.name ?? specifier.imported?.value) === "default" + ); + } + return ( + specifier.type === "ImportSpecifier" && + (specifier.imported.name ?? specifier.imported.value) === name + ); + }) ?? false + ); +} + +function isProcess(sourceCode, expression) { + const node = unwrap(expression); + if (isGlobal(sourceCode, node, "process") || imported(sourceCode, node, "process")) return true; + if (node.type === "MemberExpression" && propertyName(node) === "process") { + return ( + isGlobal(sourceCode, node.object, "globalThis") || isGlobal(sourceCode, node.object, "global") + ); + } + return ( + node.type === "CallExpression" && + isGlobal(sourceCode, node.callee, "require") && + node.arguments.length === 1 && + ["process", "node:process"].includes(node.arguments[0].value) + ); +} + +function isWriteOnly(node) { + let target = node; + while (expressionWrappers.includes(target.parent.type)) target = target.parent; + if (target.parent.type === "MemberExpression" && target.parent.object === target) + target = target.parent; + for (;;) { + const parent = target.parent; + if (expressionWrappers.includes(parent.type)) { + target = parent; + continue; + } + if (parent.type === "AssignmentExpression") + return parent.left === target && parent.operator === "="; + if (parent.type === "UnaryExpression") + return parent.argument === target && parent.operator === "delete"; + if (["ForInStatement", "ForOfStatement"].includes(parent.type)) return parent.left === target; + if ( + (parent.type === "Property" && + parent.value === target && + parent.parent.type === "ObjectPattern") || + ["ObjectPattern", "ArrayPattern", "RestElement"].includes(parent.type) || + (parent.type === "AssignmentPattern" && parent.left === target) + ) { + target = parent; + } else return false; + } +} + +export default { + meta: { + type: "problem", + schema: [], + messages: { read: "Read environment values in bootstrap or an approved environment module." }, + }, + create(context) { + const { sourceCode } = context; + function reportRead(node) { + if (!isWriteOnly(node)) context.report({ node, messageId: "read" }); + } + function destructuring(pattern, source) { + if (pattern.type !== "ObjectPattern" || !source || !isProcess(sourceCode, source)) return; + for (const property of pattern.properties) { + if (property.type === "Property" && propertyName(property) === "env") { + context.report({ node: property, messageId: "read" }); + } + } + } + return { + MemberExpression(node) { + if (propertyName(node) === "env" && isProcess(sourceCode, node.object)) reportRead(node); + }, + Identifier(node) { + // Type queries resolve value bindings but are erased at runtime. + if ( + imported(sourceCode, node, "env") && + binding(sourceCode, node).references.some( + (reference) => reference.identifier === node && reference.isValueReference !== false, + ) && + !sourceCode.getAncestors(node).some((ancestor) => ancestor.type === "TSTypeQuery") + ) + reportRead(node); + }, + VariableDeclarator(node) { + destructuring(node.id, node.init); + }, + AssignmentExpression(node) { + destructuring(node.left, node.right); + }, + }; + }, +}; diff --git a/ts/eslint-suppressions.json b/ts/eslint-suppressions.json new file mode 100644 index 000000000..7a73a41bf --- /dev/null +++ b/ts/eslint-suppressions.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/ts/eslint.config.js b/ts/eslint.config.js index c81696933..e76c13103 100644 --- a/ts/eslint.config.js +++ b/ts/eslint.config.js @@ -1,32 +1,96 @@ import js from "@eslint/js"; import tseslint from "typescript-eslint"; import prettier from "eslint-config-prettier"; +import closedPortTypes from "./eslint-rules/closed-port-types.mjs"; +import environmentReads from "./eslint-rules/environment-reads.mjs"; export default tseslint.config( { - // standalone build/verify scripts run under Node or Bun, not the CLI's stream ports + // Build scripts use Node/Bun streams directly. ignores: ["dist/**", "node_modules/**", ".wallet-cli/**", ".private/**", "scripts/**"], }, js.configs.recommended, ...tseslint.configs.recommended, { + files: ["src/**/*.ts"], + ignores: ["src/adapters/inbound/**", "**/*.test.ts"], rules: { - // the CLI renders through the streams port, never straight to the console + "no-restricted-imports": [ + "error", + { + paths: [ + ...["http", "https", "http2", "net", "tls"].flatMap((module) => + [module, `node:${module}`].map((name) => ({ + name, + importNames: + module === "http2" + ? ["createServer", "createSecureServer", "Server"] + : ["createServer", "Server"], + allowTypeImports: true, + message: "Import server capabilities only in adapters/inbound.", + })), + ), + { + name: "ws", + importNames: ["WebSocketServer", "Server"], + allowTypeImports: true, + message: "Import server capabilities only in adapters/inbound.", + }, + ], + }, + ], + }, + }, + { + plugins: { + "wallet-cli": { + rules: { "closed-port-types": closedPortTypes, "environment-reads": environmentReads }, + }, + }, + }, + { + files: ["src/**/*.ts"], + ignores: ["**/*.test.ts"], + rules: { "wallet-cli/environment-reads": "error" }, + }, + { + files: [ + "src/application/ports/**/*.ts", + "src/application/contracts/**/*.ts", + "src/domain/types/**/*.ts", + ], + ignores: ["**/*.test.ts"], + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, + rules: { "wallet-cli/closed-port-types": "error" }, + }, + { + files: [ + "src/bootstrap/**", + "src/adapters/inbound/cli/input/prompt/index.ts", + "src/adapters/inbound/cli/help/index.ts", + "src/adapters/outbound/config/index.ts", + "src/adapters/outbound/ledger/index.ts", + ], + rules: { "wallet-cli/environment-reads": "off" }, + }, + { + rules: { + // CLI output must use the streams port. "no-console": "error", "@typescript-eslint/no-unused-vars": [ "error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, ], - // several renderers and sanitizers match control bytes on purpose, to strip terminal - // escape-sequence injection out of chain-controlled text (see cli/render/scalars.ts) + // Sanitizers match control bytes to prevent terminal injection. "no-control-regex": "off", - // `any` sits at the adapter boundary, where TronWeb / Ledger / yargs ship no usable types. - // tsc is the type authority here; flagging every such cast is noise, not signal. + // SDK boundaries require casts where upstream types are incomplete. "@typescript-eslint/no-explicit-any": "off", }, }, { - // golden-output assertions match the CLI's literal column spacing + // Output assertions match literal column spacing. files: ["**/*.test.ts", "test/**/*.ts"], rules: { "no-regex-spaces": "off", @@ -39,6 +103,6 @@ export default tseslint.config( globals: { module: "writable", require: "readonly", __dirname: "readonly" }, }, }, - // formatting is Prettier's job — must stay last so it can switch stylistic rules off + // Keep last to disable conflicting formatting rules. prettier, ); diff --git a/ts/package-lock.json b/ts/package-lock.json index da3ba39a6..65ede4a51 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -38,7 +38,7 @@ "ws": "8.21.1", "yaml": "^2.9.0", "yargs": "^18.0.0", - "zod": "^4.4.3" + "zod": "^4.6.6" }, "bin": { "wallet-cli": "dist/index.js" @@ -1953,9 +1953,9 @@ ] }, "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "dev": true, "license": "MIT", "funding": { @@ -1987,9 +1987,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", "cpu": [ "arm" ], @@ -2004,9 +2004,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", "cpu": [ "arm64" ], @@ -2021,9 +2021,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", "cpu": [ "arm64" ], @@ -2038,9 +2038,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", "cpu": [ "x64" ], @@ -2055,9 +2055,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", "cpu": [ "x64" ], @@ -2072,9 +2072,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", "cpu": [ "arm" ], @@ -2089,9 +2089,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", "cpu": [ "arm64" ], @@ -2109,9 +2109,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", "cpu": [ "arm64" ], @@ -2129,9 +2129,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", "cpu": [ "ppc64" ], @@ -2149,9 +2149,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", "cpu": [ "s390x" ], @@ -2169,9 +2169,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", "cpu": [ "x64" ], @@ -2189,9 +2189,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", "cpu": [ "x64" ], @@ -2209,9 +2209,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", "cpu": [ "arm64" ], @@ -2226,9 +2226,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", "cpu": [ "arm64" ], @@ -2243,9 +2243,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", "cpu": [ "x64" ], @@ -5138,9 +5138,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", "funding": [ { "type": "individual", @@ -6631,9 +6631,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, "funding": [ { @@ -6651,9 +6651,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -7145,13 +7145,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", + "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7161,21 +7161,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" } }, "node_modules/rollup": { @@ -7410,9 +7410,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -8157,16 +8157,16 @@ } }, "node_modules/vite": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", - "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz", + "integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.9", + "postcss": "^8.5.32", + "rolldown": "~1.2.16", "tinyglobby": "^0.2.17" }, "bin": { @@ -8183,7 +8183,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8549,9 +8549,9 @@ } }, "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/ts/package.json b/ts/package.json index 006b5d0f6..80c39cf4f 100644 --- a/ts/package.json +++ b/ts/package.json @@ -48,10 +48,12 @@ "build:standalone": "bun run scripts/build-standalone.mjs", "dev": "tsx src/index.ts", "typecheck": "tsc --noEmit", - "depcruise": "depcruise src", + "depcruise": "depcruise src --ignore-known && node scripts/check-depcruise-baseline.mjs", + "depcruise:baseline": "depcruise-baseline src && prettier --write .dependency-cruiser-known-violations.json", "format": "prettier --write .", "format:check": "prettier --check .", - "lint": "eslint .", + "lint": "node scripts/lint.mjs .", + "lint:baseline": "node -e \"require('node:fs').rmSync('eslint-suppressions.json', { force: true })\" && eslint . --suppress-all", "lint:fix": "eslint . --fix", "test": "vitest run", "test:unit": "vitest run --project unit", @@ -91,7 +93,7 @@ "ws": "8.21.1", "yaml": "^2.9.0", "yargs": "^18.0.0", - "zod": "^4.4.3" + "zod": "^4.6.6" }, "overrides": { "axios": "^1.18.1", diff --git a/ts/scripts/build-standalone.mjs b/ts/scripts/build-standalone.mjs index 838a93e6f..91c336bd4 100644 --- a/ts/scripts/build-standalone.mjs +++ b/ts/scripts/build-standalone.mjs @@ -87,9 +87,7 @@ try { for (const log of result.logs) console.error(log); process.exitCode = 1; } else { - // Bun emits an ad-hoc-signed Mach-O, but embedding the native HID payload can leave that - // signature invalid. Sign the staged bytes before publishing so the previous executable remains - // usable if signing fails. + // Embedding HID can invalidate Bun's Mach-O signature; sign before replacing the executable. if (process.platform === "darwin") { const signed = spawnSync("/usr/bin/codesign", ["--force", "--sign", "-", stagedOutfile], { stdio: "inherit", @@ -103,11 +101,10 @@ try { console.log(`built ${outfile} (${target})`); } } finally { - // Failed compile/sign/publish attempts must not accumulate large embedded-runtime executables. try { if (existsSync(stagedOutfile)) unlinkSync(stagedOutfile); } catch { - // Preserve the primary build error; a stale, uniquely named staging file is safe to remove later. + // Preserve the primary build error. } } @@ -145,9 +142,7 @@ async function publishExecutable(staged, destination) { lastError = error; } - // Windows cannot rename over an existing executable on every filesystem/runtime combination. - // Remove only after the new executable has built successfully; a running image remains locked - // and leaves the old destination intact. + // Windows may require unlinking before rename; a running executable remains locked. if (process.platform === "win32" && existsSync(destination)) { try { unlinkSync(destination); diff --git a/ts/scripts/check-depcruise-baseline.mjs b/ts/scripts/check-depcruise-baseline.mjs new file mode 100644 index 000000000..937438604 --- /dev/null +++ b/ts/scripts/check-depcruise-baseline.mjs @@ -0,0 +1,48 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const baselinePath = ".dependency-cruiser-known-violations.json"; +const known = JSON.parse(readFileSync(baselinePath, "utf8")); +const current = JSON.parse( + execFileSync( + process.execPath, + [ + fileURLToPath( + new URL("../node_modules/dependency-cruiser/bin/dependency-cruise.mjs", import.meta.url), + ), + "src", + "--output-type", + "baseline", + "--no-ignore-known", + ], + { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + ), +); + +function violationKey({ rule, from, to, cycle, via }) { + return JSON.stringify( + cycle + ? [rule.name, cycle.map((module) => module.name).sort()] + : [rule.name, from, to, via?.map((module) => module.name).sort()], + ); +} + +const currentKeys = new Set(current.map(violationKey)); +const seen = new Set(); +const stale = known.filter((entry) => { + const key = violationKey(entry); + const invalid = seen.has(key) || !currentKeys.has(key); + seen.add(key); + return invalid; +}); + +if (stale.length > 0) { + process.stderr.write( + `Stale or duplicate entries in ${baselinePath}:\n${JSON.stringify(stale, null, 2)}\n`, + ); + process.stderr.write( + "Run npm run depcruise:baseline and review the diff to remove these entries.\n", + ); + process.exitCode = 1; +} diff --git a/ts/scripts/lint.mjs b/ts/scripts/lint.mjs new file mode 100644 index 000000000..ed9a840be --- /dev/null +++ b/ts/scripts/lint.mjs @@ -0,0 +1,70 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, posix, relative, resolve, sep } from "node:path"; +import { ESLint } from "eslint"; + +const args = process.argv.slice(2); +const suppressionsPath = "eslint-suppressions.json"; +const suppressions = JSON.parse(readFileSync(suppressionsPath, "utf8")); +const files = Object.keys(suppressions); + +// The check must ignore exactly what the lint run ignores, or an excluded file keeps a stale entry. +const ignorePatterns = []; +let ignore = true; +for (let i = 0; i < args.length; i++) { + if (args[i] === "--") break; + if (args[i] === "--ignore-pattern") ignorePatterns.push(args[++i]); + else if (args[i].startsWith("--ignore-pattern=")) ignorePatterns.push(args[i].slice(17)); + else if (["--ignore", "--ignore=true", "--no-ignore=false"].includes(args[i])) ignore = true; + else if (["--no-ignore", "--ignore=false", "--no-ignore=true"].includes(args[i])) ignore = false; + else if (args[i].startsWith("--ignore=") || args[i].startsWith("--no-ignore=")) { + process.stderr.write( + `Unsupported ignore option: ${args[i]}. Use --ignore or --no-ignore, optionally with =true or =false (no whitespace).\n`, + ); + process.exit(2); + } +} + +// ESLint reports unused suppressions only for files in the current lint results. +const gitIgnored = new Set(); +if (files.length > 0) { + const result = spawnSync("git", ["check-ignore", "--stdin", "-z"], { + input: files.join("\0") + "\0", + encoding: "utf8", + }); + if (result.status !== 0 && result.status !== 1) { + throw new Error(`git check-ignore failed: ${result.stderr || result.error}`); + } + for (const file of result.stdout.split("\0").filter(Boolean)) gitIgnored.add(file); +} + +const eslint = new ESLint({ ignorePatterns, ignore }); +const invalid = []; +for (const file of files) { + // ESLint matches entries by this exact form; an alias such as ./src/a.ts is never reported unused. + const canonical = relative(process.cwd(), resolve(file)).split(sep).join(posix.sep); + if (file !== canonical || canonical.startsWith("../")) { + invalid.push(`${file}: path must be relative to ts/ in ESLint's form (${canonical})`); + } else if (!existsSync(file)) invalid.push(`${file}: file does not exist`); + else if (gitIgnored.has(file)) invalid.push(`${file}: file is ignored by git`); + else if (await eslint.isPathIgnored(file)) invalid.push(`${file}: file is ignored by ESLint`); + for (const [rule, entry] of Object.entries(suppressions[file])) { + if (!Number.isInteger(entry?.count) || entry.count < 1) { + invalid.push(`${file}: ${rule}: count must be a positive integer`); + } + } +} + +if (invalid.length > 0) { + process.stderr.write(`Stale or invalid entries in ${suppressionsPath}:\n${invalid.join("\n")}\n`); + process.stderr.write("Run npm run lint:baseline and review the diff to remove these entries.\n"); + process.exit(1); +} + +const eslintBin = join( + dirname(createRequire(import.meta.url).resolve("eslint/package.json")), + "bin/eslint.js", +); +const lint = spawnSync(process.execPath, [eslintBin, ...args], { stdio: "inherit" }); +process.exitCode = lint.status ?? 2; diff --git a/ts/scripts/standalone/node-hid-runtime.ts b/ts/scripts/standalone/node-hid-runtime.ts index 9119fb1bd..c5037fa4b 100644 --- a/ts/scripts/standalone/node-hid-runtime.ts +++ b/ts/scripts/standalone/node-hid-runtime.ts @@ -10,14 +10,7 @@ interface NativeBinding { devices(...args: unknown[]): unknown[]; } -/** - * Build the small JavaScript facade exposed by `node-hid`. - * - * `node-hid` normally locates its N-API addon through the dynamic `bindings()` helper. A Bun - * executable has no package directory at runtime, so that lookup cannot work. The build plugin - * resolves the addon from Ledger transport's package location and redirects the shim's static - * native import to that exact file; Bun can then embed and extract it with the executable. - */ +// Bun executables need an embedded binding; node-hid's runtime package lookup cannot work. export function createNodeHid(binding: NativeBinding) { class HID extends EventEmitter { private readonly raw: NativeHid; @@ -29,7 +22,7 @@ export function createNodeHid(binding: NativeBinding) { super(); this.raw = new binding.HID(...args); - // Preserve node-hid's public surface without maintaining a second list of native methods. + // Expose the native methods required by node-hid callers. for (const method in binding.HID.prototype) { if (method in this) continue; (this as Record)[method] = ( @@ -78,7 +71,7 @@ export function createNodeHid(binding: NativeBinding) { return { HID, devices: (...args: unknown[]) => binding.devices(...args), - // The standalone Linux build statically selects hidraw; keep this method for API compatibility. + // Linux uses hidraw; retain the method for API compatibility. setDriverType: (_type: string) => {}, }; } diff --git a/ts/scripts/standalone/node-hid.ts b/ts/scripts/standalone/node-hid.ts index be9d5d366..8e5672433 100644 --- a/ts/scripts/standalone/node-hid.ts +++ b/ts/scripts/standalone/node-hid.ts @@ -1,7 +1,6 @@ import { createNodeHid } from "./node-hid-runtime.js"; -// The standalone build plugin resolves this static import from Ledger transport's package path. -// Bun sees the resulting `.node` file during bundling and embeds the exact addon used by transport. +// The build plugin embeds the addon resolved from Ledger transport's dependencies. // eslint-disable-next-line @typescript-eslint/no-require-imports const binding = require("wallet-cli-native-node-hid"); diff --git a/ts/scripts/standalone/resolve-node-hid-addon.ts b/ts/scripts/standalone/resolve-node-hid-addon.ts index cfa46f721..630dcf3a9 100644 --- a/ts/scripts/standalone/resolve-node-hid-addon.ts +++ b/ts/scripts/standalone/resolve-node-hid-addon.ts @@ -15,7 +15,7 @@ interface ResolvedNodeHidAddon { transportPackageJson: string; } -/** Resolve the native addon through the exact dependency tree visible to Ledger transport. */ +// Resolve through Ledger transport so its addon version matches. export function resolveNodeHidAddon( options: ResolveNodeHidAddonOptions = {}, ): ResolvedNodeHidAddon { diff --git a/ts/scripts/verify-package.mjs b/ts/scripts/verify-package.mjs index 37b4afb09..7ee192fa7 100644 --- a/ts/scripts/verify-package.mjs +++ b/ts/scripts/verify-package.mjs @@ -14,7 +14,6 @@ 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 }) => path.startsWith("docs/development/")); @@ -23,9 +22,7 @@ try { 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. + // Links outside the package are exempt from packaged-file checks. const packedPaths = new Set(packed.files.map(({ path }) => path)); const dangling = []; for (const path of packedPaths) { diff --git a/ts/scripts/verify-standalone.mjs b/ts/scripts/verify-standalone.mjs index 1157baa43..0dcc9b4bc 100644 --- a/ts/scripts/verify-standalone.mjs +++ b/ts/scripts/verify-standalone.mjs @@ -35,9 +35,7 @@ if (help.status !== 0 || !help.stdout.includes("Usage: wallet-cli")) { ); } -// Loading the Ledger command forces the embedded node-hid N-API addon to load. CI normally reaches -// `device_not_found`; a developer machine may instead complete the import. Match the stable error -// code, not message wording. Reject `device_unavailable`, which can also wrap addon load failures. +// Ledger import loads the embedded addon; device_unavailable can mask addon load failures. const isolatedHome = mkdtempSync(join(tmpdir(), "wallet-cli-standalone-")); try { const ledger = run(["import", "ledger", "--app", "tron", "--index", "0", "--output", "json"], { @@ -49,7 +47,7 @@ try { try { result = JSON.parse(ledger.stdout); } catch { - // The failure below includes the original output for diagnosis. + // Report non-JSON output in the failure below. } const imported = ledger.status === 0 && result?.success === true; const expectedDeviceError = diff --git a/ts/src/adapters/inbound/cli/arity/index.ts b/ts/src/adapters/inbound/cli/arity/index.ts index 3c4d5e68d..b09ced6fd 100644 --- a/ts/src/adapters/inbound/cli/arity/index.ts +++ b/ts/src/adapters/inbound/cli/arity/index.ts @@ -1,21 +1,11 @@ -/** - * Arity adapter — derive the minimal arity hints yargs needs from a command's zod `fields` - * (boolean→switch, else takes a value). Validation/types/defaults/cross-field checks stay in - * zod only — single source of truth. [replaces FlagSpecRegistry] - */ +// Derive yargs arity from Zod; validation stays in Zod. import type { Argv } from "yargs"; import { z, type ZodObject, type ZodRawShape, type ZodType } from "zod"; -// ── account-ref brand ───────────────────────────────────────────────────────── -// A `string` field that names an existing account (accountId/label/address). Branded so the -// TTY gap-fill can offer an arrow-select of existing accounts instead of free text. The brand -// lives on the FINAL schema instance (zod methods clone), so accountRef applies min+describe -// itself and must be the terminal call — no further chaining. +// Do not chain after accountRef: Zod clones lose the brand used by the account picker. const ACCOUNT_REF = new WeakSet(); export function accountRef(describe: string, opts: { optional?: boolean } = {}): ZodType { const base = z.string().min(1).describe(describe); - // The brand must sit on the instance stored in `fields`, so an optional ref is wrapped HERE — - // chaining `.optional()` at the call site would clone away the brand and silently lose the picker. const s = opts.optional ? base.optional() : base; ACCOUNT_REF.add(s); return s; @@ -24,11 +14,7 @@ export function isAccountRef(schema: unknown): boolean { return typeof schema === "object" && schema !== null && ACCOUNT_REF.has(schema); } -// ── case-insensitive enum ────────────────────────────────────────────────────── -// TRON brands its resources/networks in canonical case (ENERGY/BANDWIDTH, Nile, TRON), so an -// exact-match z.enum rejects the casing users most naturally type. ciEnum lower-cases the input -// before matching; `values` must therefore be the lowercase literals. --help still shows those -// literals — enumOptions() descends the preprocess pipe to find them. +// Supply lowercase literals; input is lowercased before matching. export function ciEnum(values: T) { return z.preprocess((v) => (typeof v === "string" ? v.toLowerCase() : v), z.enum(values)); } @@ -37,13 +23,11 @@ export interface FieldInfo { name: string; kebab: string; baseType: string; - /** the field collects a repeated flag into an array (`--for a --for b`) — drives yargs `array`. */ + // Repeated flags collect array elements. isArray: boolean; optional: boolean; hasDefault: boolean; - /** the default value (when hasDefault) — surfaced verbatim in --help. */ defaultValue?: unknown; - /** literal options when the field is an enum — surfaced as `` in --help. */ choices?: string[]; description?: string; } @@ -71,7 +55,7 @@ function unwrap(schema: ZodType): { if (s.def.type === "optional" || s.def.type === "nullable") optional = true; if (s.def.type === "default") { hasDefault = true; - defaultValue = s.def.defaultValue; // zod v4: plain value + defaultValue = s.def.defaultValue; } description ??= s.description; s = s.def.innerType; @@ -80,9 +64,7 @@ function unwrap(schema: ZodType): { return { base: s, optional, hasDefault, defaultValue, description }; } -/** the type name shown as `<...>` in --help. Descends a preprocess pipe (ciEnum) to its output - * and an array to its per-entry element, so a repeatable `z.array(z.string())` reads as - * `` — never the internal "pipe"/"array". */ +// Help shows the value type, not the pipe or array wrapper. function displayBaseType(base: ZodType): string { let def: any = (base as any)?.def; if (def?.type === "pipe") def = def.out?.def; @@ -90,7 +72,6 @@ function displayBaseType(base: ZodType): string { return def?.type ?? "unknown"; } -/** true when the field collects a repeated flag into an array (after unwrapping optional/default). */ function isArrayField(base: ZodType): boolean { let def: any = (base as any)?.def; if (def?.type === "pipe") def = def.out?.def; @@ -115,7 +96,6 @@ export function introspectFields(fields: ZodObject): FieldInfo[] { }); } -/** literal options of an enum field (after unwrapping optional/default), else undefined. */ export function enumOptions(schema: ZodType): string[] | undefined { const { base } = unwrap(schema as ZodType); let def = ( @@ -123,27 +103,25 @@ export function enumOptions(schema: ZodType): string[] | undefined { def?: { type?: string; entries?: Record; out?: { def?: any } }; } ).def; - // ciEnum() wraps the enum in a preprocess pipe; the literals live on the pipe's output side. + // Preprocessed enums store their literals on the output schema. if (def?.type === "pipe") def = def.out?.def; if (def?.type !== "enum" || !def.entries) return undefined; return Object.values(def.entries); } -/** apply one command's zod fields as yargs options (arity only; requiredness stays in zod). */ function applyArity(y: Argv, fields: ZodObject): Argv { for (const f of introspectFields(fields)) { y.option(f.kebab, { type: f.baseType === "boolean" ? "boolean" : "string", ...(f.kebab === "body-file" ? { nargs: 1 } : {}), - ...(f.isArray ? { array: true } : {}), // repeatable flag → yargs collects into an array + ...(f.isArray ? { array: true } : {}), describe: f.description, - demandOption: false, // requiredness is enforced by zod, not yargs + demandOption: false, // Zod enforces required fields. }); } return y; } -/** union the arity hints of every command in a namespace group (single source = zod). */ export function applyCommands(y: Argv, fields: ZodObject[]): Argv { for (const f of fields) applyArity(y, f); return y; diff --git a/ts/src/adapters/inbound/cli/command-id.ts b/ts/src/adapters/inbound/cli/command-id.ts index 0e50bfe64..0c3825b34 100644 --- a/ts/src/adapters/inbound/cli/command-id.ts +++ b/ts/src/adapters/inbound/cli/command-id.ts @@ -1,7 +1,5 @@ import type { ChainFamily } from "../../../domain/types/index.js"; -/** Canonical id = the logical path. Chain commands are no longer family-qualified; the family - * travels in the envelope's `chain` view, so `tron.` would be redundant with `chain.family`. */ export function commandId(cmd: { family?: ChainFamily; path: string[] }): string { return cmd.path.join("."); } diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts index 08a6d235d..fa8acd055 100644 --- a/ts/src/adapters/inbound/cli/commands/account.ts +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -6,32 +6,7 @@ import type { TronAccountService } from "../../../../application/use-cases/tron/ import { ciEnum } from "../arity/index.js"; import { Schemas } from "../schemas/index.js"; import { TextFormatters } from "../render/index.js"; -import { txModeFields, tronTxModeFields } from "./shared.js"; - -const transactionModeRefine = ( - input: { - dryRun?: boolean; - signOnly?: boolean; - buildOnly?: boolean; - expiration?: number; - }, - context: z.RefinementCtx, -): void => { - if ([input.dryRun, input.signOnly, input.buildOnly].filter(Boolean).length > 1) { - context.addIssue({ - code: "custom", - path: ["dryRun"], - message: "choose at most one of --dry-run, --sign-only, --build-only", - }); - } - if (input.expiration !== undefined && !input.signOnly && !input.buildOnly) { - context.addIssue({ - code: "custom", - path: ["expiration"], - message: "--expiration is only valid with --sign-only or --build-only", - }); - } -}; +import { tronExpirationRefine, txModeFields, tronTxModeFields, txModeExclusive } from "./shared.js"; export const accountActivateSpec: ChainSpec = { path: ["account", "activate"], @@ -45,12 +20,13 @@ export const accountActivateSpec: ChainSpec = { "Create the account on chain, funded by the active account. The target must not already be\n" + "active; use --dry-run to inspect current creation fees. Note: a plain transfer also activates\n" + "the recipient, so use this command only when the address just needs to exist.", + exclusive: [txModeExclusive], baseFields: z.object({ address: Schemas.addressFor("tron").describe("unactivated TRON base58 address"), ...txModeFields, ...tronTxModeFields, }), - baseRefine: transactionModeRefine, + baseRefine: tronExpirationRefine, examples: [ { cmd: "wallet-cli account activate --address T... --dry-run" }, { cmd: "wallet-cli account activate --address T... --wait --password-stdin" }, @@ -84,17 +60,8 @@ export const accountSetSpec: ChainSpec = { ...txModeFields, ...tronTxModeFields, }), - exclusive: [{ label: "what to set", flags: ["name", "id"] }], - baseRefine: (input, context) => { - if ((input.name === undefined) === (input.id === undefined)) { - context.addIssue({ - code: "custom", - path: ["name"], - message: "provide exactly one of --name or --id", - }); - } - transactionModeRefine(input, context); - }, + exclusive: [{ label: "what to set", flags: ["name", "id"] }, txModeExclusive], + baseRefine: tronExpirationRefine, examples: [ { cmd: "wallet-cli account set --name alice --dry-run" }, { cmd: "wallet-cli account set --id alice-001 --wait --password-stdin" }, @@ -113,7 +80,6 @@ export const accountBalanceSpec: ChainSpec = { auth: "none", capability: "account.balance.native", summary: "Show the native coin balance", - // Which coin, and how much of it, depend entirely on the selected network. description: "Show the native coin balance for the selected network", baseFields: z.object({}), examples: [ @@ -123,8 +89,6 @@ export const accountBalanceSpec: ChainSpec = { formatText: TextFormatters.accountBalance, }; -/** Shared by every family: the balance read is family-neutral, so one binding serves them all - * and the family comes from the selected network. */ export const accountBalanceBinding = (svc: AccountBalanceService): FamilyBinding => ({ run: async (ctx, net) => svc.balance(ctx, net, net.family), }); @@ -135,8 +99,6 @@ export const accountInfoSpec: ChainSpec = { wallet: "optional", auth: "none", summary: "Show the account's on-chain state", - // The field SETS differ by family — not the same fields with different values — so the - // help says which fields to expect rather than leaving the reader to discover it. description: "Show the account's on-chain state for the selected network. Fields differ by\n" + "family: TRON reports staked amounts, resources and permissions; EVM reports the\n" + diff --git a/ts/src/adapters/inbound/cli/commands/address.test.ts b/ts/src/adapters/inbound/cli/commands/address.test.ts index b9d70d208..c04a11f61 100644 --- a/ts/src/adapters/inbound/cli/commands/address.test.ts +++ b/ts/src/adapters/inbound/cli/commands/address.test.ts @@ -40,16 +40,12 @@ function schemaFor(path: string[]): any { return JSON.parse(rendered); } -// Omitting --out writes a plaintext private key under the wallet root. The command reports the -// path afterwards, but help is what a reader consults *before* deciding to run this on a shared -// machine or a CI runner — so the location has to be visible up front, not only in the receipt. describe("address generate --out documents its default location", () => { it("names the default path in the flag description", () => { const out = helpFor(["address", "generate"]) .split("\n") .find((line) => line.trimStart().startsWith("--out")) ?? ""; - // shape asserted against the writer in keypair-writer.test.ts ("derives the default location…") expect(out).toContain("generated/keypair-
"); }); @@ -59,8 +55,6 @@ describe("address generate --out documents its default location", () => { expect(properties.out).not.toHaveProperty("default"); }); - // The rendered "[optional, default: X]" tag is derived from zod, so faking one here would put a - // default in help that --json-schema does not have. it("does not fake a default tag on the rendered flag line", () => { const out = helpFor(["address", "generate"]) @@ -70,3 +64,11 @@ describe("address generate --out documents its default location", () => { expect(out).not.toMatch(/\[optional, default:/); }); }); + +describe("address generate help states when the secret is printed", () => { + it("names --print-secret as the way the private key reaches the output", () => { + const help = helpFor(["address", "generate"]).replace(/\s+/g, " "); + expect(help).not.toContain("never printed"); + expect(help).toContain("`--print-secret` prints it"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/address.ts b/ts/src/adapters/inbound/cli/commands/address.ts index 1b52ca5c6..4910b2da9 100644 --- a/ts/src/adapters/inbound/cli/commands/address.ts +++ b/ts/src/adapters/inbound/cli/commands/address.ts @@ -6,9 +6,7 @@ import { TextFormatters } from "../render/index.js"; export function registerAddressCommands(registry: CommandRegistry, service: AddressService): void { const fields = z.object({ - // The default lives in SecureKeypairWriter, not in this schema, so it is stated in prose: - // a "[optional, default: …]" tag is derived from zod and would claim a default --json-schema - // does not have. Readers need the location before running, not only in the receipt. + // The writer derives the default path; the schema has no static default. out: z .string() .min(1) @@ -29,9 +27,9 @@ export function registerAddressCommands(registry: CommandRegistry, service: Addr auth: "none", summary: "Generate a random TRON/EVM keypair locally without adding it to the wallet", description: - "Generate a secp256k1 keypair offline. The private key is written exclusively to a 0600 file,\n" + - "never printed and never added to the keystore — import it with `import private-key` to sign\n" + - "with it.\n" + + "Generate a secp256k1 keypair offline. By default the private key is written exclusively to a\n" + + "0600 file and not printed; `--print-secret` prints it in the result instead of writing a file.\n" + + "It is never added to the keystore — import it with `import private-key` to sign with it.\n" + "The TRON and EVM addresses shown are two encodings of the same generated key.", fields, input: fields, diff --git a/ts/src/adapters/inbound/cli/commands/artifact.test.ts b/ts/src/adapters/inbound/cli/commands/artifact.test.ts index 0cd58da37..62863b10a 100644 --- a/ts/src/adapters/inbound/cli/commands/artifact.test.ts +++ b/ts/src/adapters/inbound/cli/commands/artifact.test.ts @@ -43,8 +43,6 @@ describe("readBoundedTextFile", () => { }); }); - // BUG-040: an editor/`echo`/`jq -r` always leaves a trailing newline, and that newline used to - // travel straight into the transaction hex, breaking the offline-sign → broadcast handoff. it("trims a trailing newline like an editor or `echo` would leave", () => { const root = mkdtempSync(join(tmpdir(), "wallet-cli-artifact-")); roots.push(root); @@ -58,9 +56,6 @@ describe("readBoundedTextFile", () => { const root = mkdtempSync(join(tmpdir(), "wallet-cli-artifact-")); roots.push(root); const path = join(root, "transaction.hex"); - // Two tokens separated by a blank line: a naive "strip all whitespace" fix would silently - // concatenate them into one hex string instead of leaving the malformed content to fail - // downstream parsing. writeFileSync(path, " 0xdead beef \n"); expect(readBoundedTextFile(path, 1024, "transaction hex file")).toBe("0xdead beef"); diff --git a/ts/src/adapters/inbound/cli/commands/artifact.ts b/ts/src/adapters/inbound/cli/commands/artifact.ts index ebc83c677..83574ac32 100644 --- a/ts/src/adapters/inbound/cli/commands/artifact.ts +++ b/ts/src/adapters/inbound/cli/commands/artifact.ts @@ -16,11 +16,7 @@ export function readBoundedTextFile(path: string, maxBytes: number, label: strin "invalid_value", errorMessage`${label} exceeds the ${maxBytes}-byte limit`, ); - // Trimmed, not stripped: a file saved by an editor, `echo` or `jq -r` always carries a - // trailing newline, and every current caller (hex, JSON) treats surrounding whitespace as - // insignificant — but the trim must stop at the edges. Removing *all* whitespace would - // silently concatenate two hex/JSON tokens separated by an internal blank line instead of - // rejecting the file as malformed. + // Preserve internal whitespace so malformed hex or JSON tokens are not joined. return readFileSync(fd, "utf8").trim(); } catch (error) { if (error instanceof UsageError) throw error; diff --git a/ts/src/adapters/inbound/cli/commands/asset.ts b/ts/src/adapters/inbound/cli/commands/asset.ts index e9c756ab3..36041c3c9 100644 --- a/ts/src/adapters/inbound/cli/commands/asset.ts +++ b/ts/src/adapters/inbound/cli/commands/asset.ts @@ -1,17 +1,10 @@ -/** - * `asset` — TRC10, the TRON protocol's own token type (issuance, ICO window, frozen supply). - * TRC20 contracts live under `token`; TRC10 *transfer* is `tx send` with an asset id. - * - * Departures from the command spec for this group: Ledger cannot sign any of these contract - * types, `asset list` defaults to one page rather than the whole chain, and an ambiguous token - * name is an error rather than a differently-shaped result. - */ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronAssetService } from "../../../../application/use-cases/tron/asset-service.js"; -import { txModeFields, tronTxModeFields } from "./shared.js"; +import { txModeFields, tronTxModeFields, txModeExclusive } from "./shared.js"; import { Schemas } from "../schemas/index.js"; import { TextFormatters } from "../render/index.js"; +import { TRC10_MAX_PRECISION } from "../../../../domain/tron/asset/index.js"; const LEDGER_NOTE = "The Ledger TRON app cannot decode TRC10 issuance contracts, so this command needs a software account."; @@ -40,6 +33,7 @@ export const assetIssueSpec: ChainSpec = { "an account that has never issued a TRC10, with balance >= the issuance fee", LEDGER_NOTE, ], + exclusive: [txModeExclusive], baseFields: z.object({ name: z.string().min(1).describe("token name, 1-32 visible ASCII chars"), supply: z.string().min(1).describe("total supply, in whole tokens"), @@ -53,7 +47,13 @@ export const assetIssueSpec: ChainSpec = { end: z.string().min(1).describe("ICO end, same format, must be after --start"), url: z.string().describe("project page, must not be empty"), abbr: z.string().optional().describe("token abbreviation"), - precision: z.coerce.number().int().min(0).max(6).default(0).describe("decimal places"), + precision: z.coerce + .number() + .int() + .min(0) + .max(TRC10_MAX_PRECISION) + .default(0) + .describe("decimal places"), description: z.string().optional().describe("short description, up to 200 bytes"), freeNetPerAccount: z.coerce .number() @@ -67,7 +67,6 @@ export const assetIssueSpec: ChainSpec = { .min(0) .optional() .describe("shared free bandwidth pool for holders"), - // repeatable: the arity layer sets yargs `array: true`, so this always arrives as string[] freeze: z .array(z.string().min(1)) .optional() @@ -99,6 +98,7 @@ export const assetUpdateSpec: ChainSpec = { "Pass only the fields you want to change; the others are read from chain and\n" + "written back unchanged. At least one field is required.", requires: ["an account that has issued a TRC10", LEDGER_NOTE], + exclusive: [txModeExclusive], baseFields: z.object({ description: z.string().optional().describe("new description, up to 200 bytes"), url: z.string().optional().describe("new project page, must not be empty"), @@ -139,6 +139,7 @@ export const assetParticipateSpec: ChainSpec = { "token automatically.", requires: ["an account with enough TRX, other than the token's issuer", LEDGER_NOTE], positionals: [{ field: "assetRef", placeholder: "asset" }], + exclusive: [txModeExclusive], baseFields: z.object({ assetRef: assetReference, pay: z.string().min(1).describe("TRX to spend (decimal, not the number of tokens)"), @@ -164,6 +165,7 @@ export const assetUnfreezeSpec: ChainSpec = { "have not matured yet are untouched — run it again later for those.\n\n" + "This is unrelated to 'stake unfreeze', which releases staked TRX.", requires: ["an account that has issued a TRC10 and has matured frozen supply", LEDGER_NOTE], + exclusive: [txModeExclusive], baseFields: z.object({ ...txModeFields, ...tronTxModeFields }), examples: [{ cmd: "wallet-cli asset unfreeze --wait" }], formatText: TextFormatters.txReceipt, @@ -182,8 +184,7 @@ export const assetInfoSpec: ChainSpec = { "Give exactly one of the argument or --issuer.\n\n" + "Token names are not guaranteed unique. A name matching more than one token is an\n" + "error listing the matching ids — re-run with the id you want.", - // The choice here is between a positional and a flag; `exclusive` groups model flag-vs-flag - // only, so the constraint is stated above and enforced in the service. + // Exclusive groups support flags only; the service checks positional-versus-flag exclusivity. positionals: [{ field: "assetRef", placeholder: "asset" }], baseFields: z.object({ assetRef: assetReference.optional(), @@ -243,7 +244,13 @@ export function assetDefinitions( spec: assetUnfreezeSpec, binding: { run: (ctx, net, input) => svc.unfreeze(ctx, net, input) }, }, - { spec: assetInfoSpec, binding: { run: (_ctx, net, input) => svc.info(net, input) } }, - { spec: assetListSpec, binding: { run: (_ctx, net, input) => svc.list(net, input) } }, + { + spec: assetInfoSpec, + binding: { run: (ctx, net, input) => svc.info(ctx.deadline, net, input) }, + }, + { + spec: assetListSpec, + binding: { run: (ctx, net, input) => svc.list(ctx.deadline, net, input) }, + }, ]; } diff --git a/ts/src/adapters/inbound/cli/commands/bai.test.ts b/ts/src/adapters/inbound/cli/commands/bai.test.ts index 2fd2af85f..0b0c55fc2 100644 --- a/ts/src/adapters/inbound/cli/commands/bai.test.ts +++ b/ts/src/adapters/inbound/cli/commands/bai.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { CommandRegistry } from "../registry/index.js"; import { baiRechargeSpec, registerBaiCommands } from "./bai.js"; import type { BaiService } from "../../../../application/use-cases/bai-service.js"; +import { errorMessage, followUp } from "../../../../domain/errors/index.js"; function service(): BaiService { return { @@ -126,3 +127,77 @@ it.each(["0", "0.000", "-1", "1e3", "9007199254740992", "1." + "0".repeat(100)]) }); }, ); + +it("spells the follow-up command in the recharge result's warning and keeps it a string", async () => { + const registry = new CommandRegistry(); + const recharge = vi.fn(async () => ({ + creditStatus: "unconfirmed", + warning: errorMessage`report it again with \`${followUp("bai-report-recharge")}\``, + payer: "payer", + })); + registerBaiCommands(registry, { ...service(), recharge } as unknown as BaiService); + const command = registry.resolveChain(["bai", "recharge"])!; + const result = await command.families.tron!.run( + { config: { baiApiKey: "test-key" } } as never, + { id: "tron:728126428", family: "tron" } as never, + command.spec.baseFields.parse({ amount: "1" }), + ); + expect(result).toEqual({ + creditStatus: "unconfirmed", + warning: "report it again with `bai report-recharge`", + payer: "payer", + }); + expect(Object.keys(result as object)).toEqual(["creditStatus", "warning", "payer"]); +}); + +it("spells the follow-up command in the report result's warning and keeps it a string", async () => { + const registry = new CommandRegistry(); + const rechargeReport = vi.fn(async () => ({ + creditStatus: "unconfirmed", + code: "timeout", + warning: errorMessage`report it with \`${followUp("bai-report-recharge")}\``, + })); + registerBaiCommands(registry, { ...service(), rechargeReport } as unknown as BaiService); + const command = registry.resolveNeutral(["bai", "report-recharge"])!; + const result = await command.run( + { emit: vi.fn(), deadline: {} } as never, + undefined as never, + command.input.parse({ chain: "base", txHash: `0x${"a".repeat(64)}` }), + ); + expect(result).toEqual({ + creditStatus: "unconfirmed", + code: "timeout", + warning: "report it with `bai report-recharge`", + }); +}); + +it.each([ + ["tron", "a".repeat(64), true], + ["tron", `0x${"a".repeat(64)}`, false], + ["base", `0x${"a".repeat(64)}`, true], + ["bnb", "a".repeat(64), false], +])("checks a %s report hash in that chain's format", (chain, txHash, valid) => { + const registry = new CommandRegistry(); + registerBaiCommands(registry, service()); + const command = registry.resolveNeutral(["bai", "report-recharge"])!; + expect(command.input.safeParse({ chain, txHash }).success).toBe(valid); +}); + +it.each([undefined, "USDT"])( + "refuses more than 6 decimal places at the command boundary (token %s)", + (token) => { + const registry = new CommandRegistry(); + registerBaiCommands(registry, service()); + const command = registry.resolveChain(["bai", "recharge"])!; + const issues: unknown[] = []; + for (const family of ["evm", "tron"] as const) + command.families[family]!.refine!( + { amount: "1.0000001", token } as never, + { + addIssue: (issue: unknown) => issues.push(issue), + } as never, + ); + expect(issues).toHaveLength(2); + expect(issues[0]).toMatchObject({ path: ["amount"], params: { errorCode: "invalid_amount" } }); + }, +); diff --git a/ts/src/adapters/inbound/cli/commands/bai.ts b/ts/src/adapters/inbound/cli/commands/bai.ts index f3467063d..0aab6f587 100644 --- a/ts/src/adapters/inbound/cli/commands/bai.ts +++ b/ts/src/adapters/inbound/cli/commands/bai.ts @@ -1,6 +1,7 @@ import { baiRechargeText, baiUsageText, baiRecordsText, baiOrdersText } from "../render/bai.js"; import { assertBaiRechargeMinimum, + assertBaiRechargePrecision, baiRechargeAmount, } from "../../../../domain/bai/recharge-policy.js"; import { integerLiteral, rawPaymentAmount, paymentAmount } from "../schemas/payment-values.js"; @@ -8,6 +9,10 @@ import { z } from "zod"; import type { CommandDefinition, ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { CommandRegistry } from "../registry/index.js"; import type { BaiService } from "../../../../application/use-cases/bai-service.js"; +import { isBaiTxHash } from "../../../../domain/bai/chains.js"; +import { isUserMessage } from "../../../../domain/errors/index.js"; +import { cliMessage } from "../output/errors.js"; +import { defaultToken } from "../../../../application/services/default-token.js"; const requires = ["config baiApiKey"]; @@ -63,7 +68,7 @@ export const baiRechargeSpec: ChainSpec = { wallet: "optional", auth: "conditional", broadcasts: true, - supportsWait: false, + rejectsWait: "the recharge payment follows its own workflow and is reported to B.AI", capability: "bai.recharge", requires, positionals: [{ field: "amount" }], @@ -71,6 +76,13 @@ export const baiRechargeSpec: ChainSpec = { description: "Recharge B.AI using the selected network and token. Each recharge checks wallet binding with B.AI and signs a binding message if needed before creating an order or paying. Dry-run checks binding but never signs or binds. 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, + exclusive: [ + { + label: "GasFree fee limit", + flags: ["max-gasfree-fee", "max-gasfree-fee-raw"], + select: "at-most-one", + }, + ], examples: [ { cmd: "wallet-cli bai recharge 10 --token USDT --network tron --password-stdin" }, { @@ -82,11 +94,27 @@ export const baiRechargeSpec: ChainSpec = { ], }; +// The result's warning field is a string; follow-up commands are spelled for the CLI. +function spelledWarning(result: T) { + return "warning" in result && isUserMessage(result.warning) + ? { ...result, warning: cliMessage(result.warning).message } + : result; +} + export function baiRechargeBinding(service: BaiService): FamilyBinding { return { refine: (input, ctx) => { + let amount: ReturnType; try { - assertBaiRechargeMinimum(input.token ?? "USDT", baiRechargeAmount(input.amount)); + amount = baiRechargeAmount(input.amount); + } catch { + // The amount schema reports it. + return; + } + try { + // Without a token the network decides the default, so the recharge itself checks the minimum. + if (input.token !== undefined) assertBaiRechargeMinimum(input.token, amount); + assertBaiRechargePrecision(amount); } catch (error) { ctx.addIssue({ code: "custom", @@ -95,18 +123,12 @@ export function baiRechargeBinding(service: BaiService): FamilyBinding { 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, { + const result = await service.recharge(ctx, network, { amount: input.amount, - token: input.token ?? (network.id === "eip155:8453" ? "USDC" : "USDT"), + token: input.token ?? defaultToken("bai.recharge", network.id), to: input.to, apiKey: ctx.config.baiApiKey, dryRun: input.dryRun, @@ -115,6 +137,7 @@ export function baiRechargeBinding(service: BaiService): FamilyBinding { maxGasfreeFee: input.maxGasfreeFee, maxGasfreeFeeRaw: input.maxGasfreeFeeRaw, }); + return spelledWarning(result); }, }; } @@ -156,9 +179,7 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi "Recover a failed B.AI report using the original API key, chain, hash, amount and recipient. For recipient recharge, supply both original --to and --target-id; omit both only for self recharge. Reconcile any unconfirmed candidate hash before reporting. This command does not create an order, sign, pay, or resolve a new recipient. B.AI verifies whether the reported transaction can be credited.", fields: reportFields, input: reportFields.superRefine((value, ctx) => { - if ( - !(value.chain === "tron" ? /^[0-9a-fA-F]{64}$/ : /^0x[0-9a-fA-F]{64}$/).test(value.txHash) - ) + if (!isBaiTxHash(value.chain, value.txHash)) ctx.addIssue({ code: "custom", path: ["txHash"], @@ -179,7 +200,7 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi type: "activity", message: "Submitting the existing transaction hash to B.AI; no new payment will be sent…", }); - return service.rechargeReport(input); + return spelledWarning(await service.rechargeReport(ctx.deadline, input)); }, } satisfies CommandDefinition); @@ -199,7 +220,7 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi examples: [{ cmd: "wallet-cli bai usage-summary" }], run: async (ctx) => { ctx.emit({ type: "activity", message: "Fetching B.AI credit balance and usage summary…" }); - return service.usage(); + return service.usage(ctx.deadline); }, } satisfies CommandDefinition); @@ -224,7 +245,7 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi examples: [{ cmd: "wallet-cli bai usage-records --limit 20" }], run: async (ctx, _network, input) => { ctx.emit({ type: "activity", message: "Fetching B.AI usage records…" }); - return service.usageList(input); + return service.usageList(ctx.deadline, input); }, } satisfies CommandDefinition); @@ -241,7 +262,7 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi examples: [{ cmd: "wallet-cli bai recharge-orders --limit 20" }], run: async (context, _network, input) => { context.emit({ type: "activity", message: "Fetching B.AI recharge orders…" }); - const { warnings, ...result } = await service.rechargeList(input); + const { warnings, ...result } = await service.rechargeList(context.deadline, input); for (const warning of warnings ?? []) context.warn(warning); return result; }, diff --git a/ts/src/adapters/inbound/cli/commands/block.ts b/ts/src/adapters/inbound/cli/commands/block.ts index 84db291c4..40b916dbc 100644 --- a/ts/src/adapters/inbound/cli/commands/block.ts +++ b/ts/src/adapters/inbound/cli/commands/block.ts @@ -31,9 +31,9 @@ export const blockSpec: ChainSpec = { }; export const blockTronBinding = (svc: TronBlockService): FamilyBinding => ({ - run: async (_ctx, net, input) => svc.get(net, input.number), + run: async (ctx, net, input) => svc.get(ctx.deadline, net, input.number), }); export const blockEvmBinding = (svc: EvmBlockService): FamilyBinding => ({ - run: async (_ctx, net, input) => svc.get(net, input.number), + run: async (ctx, net, input) => svc.get(ctx.deadline, net, input.number), }); diff --git a/ts/src/adapters/inbound/cli/commands/chain.ts b/ts/src/adapters/inbound/cli/commands/chain.ts index d2357eb49..aa51ae3bc 100644 --- a/ts/src/adapters/inbound/cli/commands/chain.ts +++ b/ts/src/adapters/inbound/cli/commands/chain.ts @@ -4,9 +4,6 @@ import type { TronChainService } from "../../../../application/use-cases/tron/ch import type { EvmChainService } from "../../../../application/use-cases/evm/chain-service.js"; import { TextFormatters } from "../render/index.js"; -/** `chain node` is its own export, not part of the TRON bundle below: every family can report - * node status, so the spec is shared and each family brings its own binding. */ -/** `chain prices` is shared: every family prices transactions somehow, though the fields differ. */ export const chainPricesSpec: ChainSpec = { path: ["chain", "prices"], network: "optional", @@ -26,11 +23,11 @@ export const chainPricesSpec: ChainSpec = { }; export const chainPricesTronBinding = (service: TronChainService): FamilyBinding => ({ - run: async (_ctx, net) => service.prices(net), + run: async (ctx, net) => service.prices(ctx.deadline, net), }); export const chainPricesEvmBinding = (service: EvmChainService): FamilyBinding => ({ - run: async (_ctx, net) => service.prices(net), + run: async (ctx, net) => service.prices(ctx.deadline, net), }); export const chainNodeSpec: ChainSpec = { @@ -52,11 +49,11 @@ export const chainNodeSpec: ChainSpec = { }; export const chainNodeTronBinding = (service: TronChainService): FamilyBinding => ({ - run: async (_ctx, net) => service.node(net), + run: async (ctx, net) => service.node(ctx.deadline, net), }); export const chainNodeEvmBinding = (service: EvmChainService): FamilyBinding => ({ - run: async (_ctx, net) => service.node(net), + run: async (ctx, net) => service.node(ctx.deadline, net), }); export function chainDefinitions( @@ -83,7 +80,7 @@ export function chainDefinitions( ], formatText: TextFormatters.chainParams, }, - binding: { run: async (_ctx, net, input) => service.params(net, input.key) }, + binding: { run: async (ctx, net, input) => service.params(ctx.deadline, net, input.key) }, }, ]; } diff --git a/ts/src/adapters/inbound/cli/commands/change-password.test.ts b/ts/src/adapters/inbound/cli/commands/change-password.test.ts index 86476e305..aafb3018c 100644 --- a/ts/src/adapters/inbound/cli/commands/change-password.test.ts +++ b/ts/src/adapters/inbound/cli/commands/change-password.test.ts @@ -3,23 +3,16 @@ import { registerWalletCommands } from "./wallet.js"; import { CommandRegistry } from "../registry/index.js"; import type { ExecutionContext } from "../contracts/index.js"; import type { WalletService } from "../../../../application/use-cases/wallet-service.js"; -import type { LedgerDevice } from "../../../../application/ports/ledger-device.js"; const OLD = "OldPw1!aa"; const NEW = "NewPw2@bb"; -// change-password is TTY-only (secretsTtyOnly): the old password is primed from the TTY by dispatch, -// the new password comes from a hidden prompt. --password-stdin / non-TTY are rejected upstream in -// the shell dispatch (secretsTtyOnly pre-check), exercised by the stdin regression smoke, not here — -// this unit test covers the run handler in isolation. +// This harness bypasses dispatch, which enforces TTY-only secret input. function setup(opts: { newPrompt?: string; confirm?: boolean } = {}) { const changePassword = vi.fn(() => ({ wallets: ["seed", "hot"], count: 2 })); const wallets = { changePassword, - list: () => [ - { accountId: "wlt_seed.0", type: "seed" }, - { accountId: "wlt_hot", type: "privateKey" }, - ], + softwareWalletCount: () => 2, } as unknown as WalletService; const secrets = { read: (kind: string) => (kind === "password" ? OLD : undefined) }; const prompt = { @@ -29,7 +22,7 @@ function setup(opts: { newPrompt?: string; confirm?: boolean } = {}) { }; const ctx = { secrets, prompt } as unknown as ExecutionContext; const registry = new CommandRegistry(); - registerWalletCommands(registry, { walletService: wallets, ledger: {} as LedgerDevice }); + registerWalletCommands(registry, { walletService: wallets }); const command = registry.resolveNeutral(["change-password"]!); if (!command) throw new Error("change-password command not registered"); return { command, ctx, changePassword, prompt }; diff --git a/ts/src/adapters/inbound/cli/commands/config.test.ts b/ts/src/adapters/inbound/cli/commands/config.test.ts new file mode 100644 index 000000000..9866c0a7c --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/config.test.ts @@ -0,0 +1,64 @@ +import { expect, it, vi } from "vitest"; +import { registerConfigCommands } from "./config.js"; +import { CommandRegistry } from "../registry/index.js"; +import type { ConfigService } from "../../../../application/use-cases/config-service.js"; + +function fixture(hasKey: boolean) { + const execute = vi.fn(() => ({ value: "********" })); + const registry = new CommandRegistry(); + registerConfigCommands(registry, { execute } as unknown as ConfigService); + const ctx = { + secrets: { has: () => hasKey, require: () => "fake-credential" }, + config: {}, + networkRegistry: {}, + }; + return { command: registry.resolveNeutral(["config"])!, ctx, execute }; +} + +const CREDENTIAL_KEYS = [ + "tronlinkSecretId", + "tronlinkSecretKey", + "gasfreeApiKey", + "gasfreeApiSecret", + "baiApiKey", + "networks.nile.apiKey", +]; + +it.each(CREDENTIAL_KEYS)("rejects a %s value given on the command line", async (key) => { + const { command, ctx, execute } = fixture(false); + await expect( + command.run(ctx as never, undefined, { key, value: "fake-credential" }), + ).rejects.toMatchObject({ code: "invalid_option" }); + expect(execute).not.toHaveBeenCalled(); +}); + +it.each(CREDENTIAL_KEYS)("sets %s from --api-key-stdin", async (key) => { + const { command, ctx, execute } = fixture(true); + await command.run(ctx as never, undefined, { key }); + expect(execute).toHaveBeenCalledWith( + { key, value: "fake-credential" }, + ctx.config, + ctx.networkRegistry, + ); +}); + +it.each(["tronlinkChannel", "timeoutMs", "networks.nile.httpEndpoint", undefined])( + "refuses --api-key-stdin for the non-credential key %s", + async (key) => { + const { command, ctx, execute } = fixture(true); + await expect(command.run(ctx as never, undefined, { key })).rejects.toMatchObject({ + code: "invalid_option", + }); + expect(execute).not.toHaveBeenCalled(); + }, +); + +it("still accepts a non-credential value on the command line", async () => { + const { command, ctx, execute } = fixture(false); + await command.run(ctx as never, undefined, { key: "tronlinkChannel", value: "wallet-cli" }); + expect(execute).toHaveBeenCalledWith( + { key: "tronlinkChannel", value: "wallet-cli" }, + ctx.config, + ctx.networkRegistry, + ); +}); diff --git a/ts/src/adapters/inbound/cli/commands/config.ts b/ts/src/adapters/inbound/cli/commands/config.ts index d2eef9d98..34afe8ea2 100644 --- a/ts/src/adapters/inbound/cli/commands/config.ts +++ b/ts/src/adapters/inbound/cli/commands/config.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { CommandDefinition } from "../contracts/index.js"; import { CONFIG_KEYS, + isCredentialKey, NETWORK_CONFIG_FIELDS, type ConfigService, } from "../../../../application/use-cases/config-service.js"; @@ -11,9 +12,7 @@ import { UsageError } from "../../../../domain/errors/index.js"; export function registerConfigCommands(registry: CommandRegistry, service: ConfigService): void { const fields = z.object({ - // Not an enum: `networks.[.]` is a nested path, and the id segment is - // open-ended (any canonical id or alias). The service validates the key and names the - // supported ones, so a typo gets a precise message rather than a yargs enum dump. + // Network IDs and aliases make config paths open-ended; the service validates them. key: z .string() .min(1) @@ -21,7 +20,11 @@ export function registerConfigCommands(registry: CommandRegistry, service: Confi .describe( `config key to read or set (${CONFIG_KEYS.join(", ")}, or networks. / networks..{${NETWORK_CONFIG_FIELDS.join(" | ")}}); omit to show the whole effective config`, ), - value: z.string().min(1).optional().describe("new value; omit to read the key"), + value: z + .string() + .min(1) + .optional() + .describe("new value; omit to read the key; credentials use --api-key-stdin instead"), }); registry.add({ @@ -34,7 +37,7 @@ export function registerConfigCommands(registry: CommandRegistry, service: Confi stdin: "apiKey", summary: "Show / get / set configuration values", description: - "Read or update configuration. Setting baiApiKey only saves the key locally; no network, account, password or wallet binding is required. Recharge checks binding when used.", + "Read or update configuration. Credentials (tronlinkSecretId, tronlinkSecretKey, gasfreeApiKey, gasfreeApiSecret, baiApiKey, networks..apiKey) are read only from --api-key-stdin, never from the command line, and are masked in output; a config file holding one must have mode 0600. timeoutMs is the whole-command deadline in milliseconds (0 = no limit). Setting baiApiKey only saves the key locally; no network, account, password or wallet binding is required. Recharge checks binding when used.", positionals: [{ field: "key" }, { field: "value" }], fields, input: fields, @@ -51,20 +54,21 @@ export function registerConfigCommands(registry: CommandRegistry, service: Confi formatText: TextFormatters.config, run: async (ctx, _network, input) => { const hasApiKeyInput = ctx.secrets.has("apiKey"); - if (input.key === "baiApiKey" && input.value !== undefined) { + const credential = input.key !== undefined && isCredentialKey(input.key); + if (credential && input.value !== undefined) { throw new UsageError( "invalid_option", - "B.AI API key must not be passed on the command line; use --api-key-stdin", + `${input.key} must not be passed on the command line; use --api-key-stdin`, ); } - if (hasApiKeyInput && input.key !== "baiApiKey") { + if (hasApiKeyInput && !credential) { throw new UsageError( "invalid_option", - "--api-key-stdin is accepted only with config baiApiKey", + "--api-key-stdin is accepted only with a credential key: tronlinkSecretId, tronlinkSecretKey, gasfreeApiKey, gasfreeApiSecret, baiApiKey or networks..apiKey", ); } const effectiveInput = hasApiKeyInput - ? { key: "baiApiKey", value: ctx.secrets.require("apiKey") } + ? { key: input.key, value: ctx.secrets.require("apiKey") } : input; return service.execute(effectiveInput, ctx.config, ctx.networkRegistry); }, diff --git a/ts/src/adapters/inbound/cli/commands/contact.ts b/ts/src/adapters/inbound/cli/commands/contact.ts index c7f3b913a..d52220b69 100644 --- a/ts/src/adapters/inbound/cli/commands/contact.ts +++ b/ts/src/adapters/inbound/cli/commands/contact.ts @@ -10,8 +10,6 @@ export function registerContactCommands(registry: CommandRegistry, service: Cont .string() .min(1) .max(256) - // The rule the domain enforces (contactName): a name that looked like an - // address would make `--to ` ambiguous with the address it resembles. .describe( "local name for this recipient; 1-64 safe characters and must not look like a chain address. Usable anywhere an address is accepted", ), diff --git a/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts b/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts index 9d0c55c58..8413ff861 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { mkdtempSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { chainInputSchema } from "../shell/index.js"; +import type { FamilyBinding } from "../contracts/index.js"; import { contractDeployEvmBinding, contractDeploySpec, @@ -10,19 +12,6 @@ import { import type { EvmContractService } from "../../../../application/use-cases/evm/contract-service.js"; import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; -/** - * `--artifact`, `--constructor-signature` and `--constructor-args`. - * - * The defect these replace: `--constructor-params` was the only way to pass constructor arguments - * on EVM, and it could not work — the encoder was handed an empty ABI, so every argument failed - * with "expectedCount=0", and `--abi` (which would have supplied one) is a TRON-only flag. There - * was no input that deployed a contract with constructor arguments on an EVM network. - * - * The rule that came out of it: the constructor's TYPES come from the compiler's ABI, or from a - * signature the caller states — never from the values. A mistyped argument encodes cleanly and - * deploys a contract built from the wrong arguments, and a deployment cannot be taken back. - */ - const CONSTRUCTOR_ABI = [ { type: "constructor", @@ -41,7 +30,6 @@ function artifactFile(body: unknown, name = "Counter.json"): string { return path; } -/** Foundry nests the bytecode under `{object}`; Hardhat, sunhat and TronBox store the string. */ const foundryArtifact = () => artifactFile({ abi: CONSTRUCTOR_ABI, bytecode: { object: "0x6080" } }); const hardhatArtifact = () => @@ -111,8 +99,6 @@ describe("contract deploy — reading a compiler artifact", () => { }); }); - // solc emits "0x" for an interface or an abstract contract: a real artifact for something that - // cannot be deployed. Deploying it would succeed and produce a contract with no code. it("refuses an interface or abstract contract instead of deploying nothing", async () => { const { run } = evmHarness(); @@ -142,12 +128,9 @@ describe("contract deploy — where the constructor's types come from", () => { source: "signature", signature: "constructor(uint256,string)", values: ["42", "hello"], - flag: "--constructor-signature", }); }); - // The shape this command shipped with. It still works — the types are simply read off the - // entries and turned into the signature they describe, instead of being discarded. it("builds the signature from --constructor-params' inline types", async () => { const { run, deploy } = evmHarness(); await run({ @@ -156,10 +139,9 @@ describe("contract deploy — where the constructor's types come from", () => { }); expect(deploy.mock.calls[0]![2].constructorArgs).toEqual({ - source: "signature", - signature: "constructor(uint256,string)", + source: "typed-params", + types: ["uint256", "string"], values: ["42", "hello"], - flag: "--constructor-params", }); }); @@ -171,15 +153,14 @@ describe("contract deploy — where the constructor's types come from", () => { }); }); -/** - * Schema rules are asserted against the schema: calling a binding directly bypasses zod, so a - * refine could say anything and the call would still succeed. - */ describe("contract deploy — input rules", () => { const parse = (input: Record) => - contractDeploySpec.baseFields - .superRefine(contractDeploySpec.baseRefine!) - .safeParse({ dryRun: false, signOnly: false, buildOnly: false, ...input }); + chainInputSchema(contractDeploySpec, {} as FamilyBinding).input.safeParse({ + dryRun: false, + signOnly: false, + buildOnly: false, + ...input, + }); const message = (input: Record) => parse(input) @@ -197,11 +178,10 @@ describe("contract deploy — input rules", () => { it("refuses two argument lists at once", () => { expect(message({ code: "6080", constructorArgs: "[]", constructorParams: "[]" })).toMatch( - /mutually exclusive/, + /cannot be combined with --constructor-params/, ); }); - // Both of these would mean encoding against one type source while the caller supplied two. it("refuses inline types beside an artifact rather than picking one", () => { expect(message({ artifact: "./a.json", constructorParams: "[]" })).toMatch( /types come from its ABI/, @@ -220,11 +200,8 @@ describe("contract deploy — input rules", () => { ); }); - // --abi is TRON-only and IS the type source there. Leaving it out of this rule sent a TRON - // caller to --constructor-signature, the one flag TRON refuses. it("counts --abi as a type source, since TRON encodes bare values against it", () => { - // The shell parses baseFields EXTENDED with the family's own fields, so the refine sees --abi - // on TRON. Parsing the base fields alone would strip it and prove nothing. + // Include family fields so parsing retains --abi for the shared refine. const tronParse = contractDeploySpec.baseFields .extend(contractDeployTronBinding({} as never).fields!.shape) .superRefine(contractDeploySpec.baseRefine!) @@ -253,12 +230,6 @@ describe("contract deploy — input rules", () => { }); }); -/** - * TRON is the one place the families genuinely differ: TronWeb's createSmartContract needs the - * whole ABI, not just the constructor's types, so a signature cannot stand in for it. `--abi` - * therefore stays required — but an artifact now satisfies it, which is the point: a TRON - * developer using TronBox or sunhat already has that ABI in a file. - */ describe("contract deploy — TRON's ABI requirement", () => { const tronRefine = contractDeployTronBinding({} as never).refine!; const check = (input: Record) => { @@ -267,8 +238,30 @@ describe("contract deploy — TRON's ABI requirement", () => { return issues.map((i) => i.message).join(" | "); }; + const tronIssues = (input: Record) => + ( + chainInputSchema(contractDeploySpec, contractDeployTronBinding({} as never)).input.safeParse({ + dryRun: false, + signOnly: false, + buildOnly: false, + feeLimit: "1", + permissionId: 0, + ...input, + }).error?.issues ?? [] + ).map((i) => ({ + path: i.path.join("."), + message: i.message, + code: (i as { params?: { errorCode?: string } }).params?.errorCode, + })); + it("still demands an ABI when neither flag supplies one", () => { - expect(check({ code: "6080" })).toMatch(/TRON needs the contract's ABI/); + expect(tronIssues({ code: "6080" })).toEqual([ + { + path: "abi", + message: "provide exactly one of --abi or --artifact", + code: "invalid_option", + }, + ]); }); it("is satisfied by --artifact", () => { @@ -280,7 +273,13 @@ describe("contract deploy — TRON's ABI requirement", () => { }); it("refuses both at once rather than choosing", () => { - expect(check({ abi: "[]", artifact: "./a.json" })).toMatch(/pass one/); + expect(tronIssues({ abi: "[]", artifact: "./a.json" })).toEqual([ + { + path: "abi", + message: "provide exactly one of --abi or --artifact", + code: "invalid_option", + }, + ]); }); it("says plainly that a signature cannot replace the ABI here", () => { @@ -310,11 +309,6 @@ describe("contract deploy — TRON's ABI requirement", () => { }); }); -/** - * `--permission-id` and `--expiration` are TRON's multi-signature concepts. They sat in the - * shared base fields, so an EVM `--help` listed them untagged beside the flags that are tagged - * `(TRON only)` — a reader had no way to tell they do nothing here. - */ describe("contract deploy — TRON-only transaction flags are tagged", () => { it("keeps them off the EVM binding", () => { const keys = Object.keys(contractDeployEvmBinding({} as never).fields?.shape ?? {}); diff --git a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts index 98de56997..82664c8ee 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { mkdtempSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { chainInputSchema } from "../shell/index.js"; +import type { FamilyBinding } from "../contracts/index.js"; import { contractDeployEvmBinding, contractDeploySpec, @@ -11,19 +13,6 @@ import { } from "./contract.js"; import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; -/** - * `contract deploy` input guards. - * - * The contract under test is ALIGNMENT: every input TronWeb's createSmartContract encoder accepts - * must still reach it, and every input it refuses must still be refused. The guards only change how - * two of those refusals are worded — one of which TronWeb does not word at all, but crashes on. - * - * Each expectation below was first measured against the real encoder - * (tronweb/lib/commonjs/lib/TransactionBuilder/TransactionBuilder.js:541, - * `'payable' === func.stateMutability.toLowerCase()`), and the measured behaviour is named in the - * test so a TronWeb upgrade that moves the boundary shows up as a failure here. - */ - function deployWith(input: { abi: string; params?: string }) { const deploy = vi.fn(async (_ctx: unknown, _net: unknown, _input: { parameters: unknown[] }) => ({ kind: "tx-receipt" as const, @@ -33,8 +22,7 @@ function deployWith(input: { abi: string; params?: string }) { binding.run( {} as never, {} as never, - // `code` is the flag; `bytecode` is what the binding derives from it. The fixture states - // the flag, because an input with no code source at all cannot get past the schema. + // Supply CLI flags; the binding derives bytecode from --code. { code: "6080", feeLimit: "1000000", ...input } as never, ); return { run, deploy }; @@ -43,106 +31,19 @@ function deployWith(input: { abi: string; params?: string }) { const ctor = (over: Record = {}) => JSON.stringify([{ type: "constructor", inputs: [{ name: "x", type: "uint256" }], ...over }]); -describe("contract deploy — ABI constructor guard", () => { - // Measured: TronWeb only reads stateMutability on constructor entries (its && short-circuits), - // so an ABI without one encodes fine no matter what else it carries. - it("passes an ABI with no constructor straight through", async () => { - const { run, deploy } = deployWith({ abi: JSON.stringify([{ type: "function", name: "f" }]) }); - await expect(run()).resolves.toBeDefined(); - expect(deploy).toHaveBeenCalledOnce(); - }); - - it("passes a constructor carrying stateMutability", async () => { - const { run, deploy } = deployWith({ abi: ctor({ stateMutability: "nonpayable" }) }); - await expect(run()).resolves.toBeDefined(); - expect(deploy).toHaveBeenCalledOnce(); - }); - - it("passes a payable constructor", async () => { - const { run, deploy } = deployWith({ abi: ctor({ stateMutability: "payable" }) }); - await expect(run()).resolves.toBeDefined(); - expect(deploy).toHaveBeenCalledOnce(); - }); - - // Measured: TronWeb SUCCEEDS on "" — ''.toLowerCase() is legal, the constructor is just not - // payable. Rejecting it would make this CLI stricter than the encoder it fronts. - it("passes an empty-string stateMutability, which TronWeb accepts", async () => { - const { run, deploy } = deployWith({ abi: ctor({ stateMutability: "" }) }); - await expect(run()).resolves.toBeDefined(); - expect(deploy).toHaveBeenCalledOnce(); - }); - - // Measured: each of these crashes the encoder — "Cannot read properties of undefined/null - // (reading 'toLowerCase')" or "func.stateMutability.toLowerCase is not a function" — surfacing as - // rpc_error/exit 1 with no request ever sent. - it.each([ - ["absent", {}], - ["null", { stateMutability: null }], - ["a number", { stateMutability: 1 }], - ["a boolean", { stateMutability: false }], - ["an object", { stateMutability: {} }], - ])("rejects a constructor whose stateMutability is %s", async (_label, over) => { - const { run, deploy } = deployWith({ abi: ctor(over) }); - await expect(run()).rejects.toMatchObject({ - code: "invalid_value", - message: expect.stringContaining("stateMutability"), - }); - expect(deploy).not.toHaveBeenCalled(); - }); - - // The crash does not depend on the constructor taking arguments — TronWeb reads the key before - // it ever looks at `inputs`. - it.each([ - ["an empty inputs list", JSON.stringify([{ type: "constructor", inputs: [] }])], - ["no inputs key at all", JSON.stringify([{ type: "constructor" }])], - ])("rejects a stateMutability-less constructor with %s", async (_label, abi) => { - const { run } = deployWith({ abi }); - await expect(run()).rejects.toMatchObject({ code: "invalid_value" }); - }); - - // Measured: TronWeb reads `abi.entrys` when present, so the guard has to see through that - // wrapper or it would wave through an ABI that still crashes. - it("looks inside the { entrys } wrapper TronWeb also accepts", async () => { - const { run } = deployWith({ abi: JSON.stringify({ entrys: [{ type: "constructor" }] }) }); - await expect(run()).rejects.toMatchObject({ code: "invalid_value" }); - }); - - it("passes an { entrys } wrapper whose constructor is well-formed", async () => { - const abi = JSON.stringify({ - entrys: [{ type: "constructor", stateMutability: "nonpayable" }], - }); - const { run, deploy } = deployWith({ abi }); +describe("contract deploy — ABI input", () => { + it("passes a parsed ABI through to the use case", async () => { + const { run, deploy } = deployWith({ abi: ctor() }); await expect(run()).resolves.toBeDefined(); - expect(deploy).toHaveBeenCalledOnce(); + expect(deploy.mock.calls[0]![2]).toMatchObject({ abi: JSON.parse(ctor()) }); }); - // Measured: TronWeb answers this one clearly by itself ("Invalid options.abi provided"), so - // adding our own rejection would only move the goalposts. - it("leaves an ABI that is neither array nor { entrys } to TronWeb", async () => { - const { run, deploy } = deployWith({ abi: JSON.stringify({ foo: 1 }) }); - await expect(run()).resolves.toBeDefined(); - expect(deploy).toHaveBeenCalledOnce(); - }); - - it("still rejects an ABI that is not JSON at all", async () => { + it("rejects an ABI that is not JSON at all", async () => { const { run } = deployWith({ abi: "{not json" }); await expect(run()).rejects.toMatchObject({ code: "invalid_value", message: /valid JSON/ }); }); }); -/** - * The deploy inputs were renamed and the parameter form changed. - * - * `--params` meant `{type,value}` on `contract call`/`send` but bare positional values on - * `deploy` — one flag name, two incompatible formats across sibling commands, which is why a - * guard existed to explain the difference. `--constructor-params` unifies the form, so that - * guard now points the other way: the typed form is the accepted one. - * - * `--abi` stays REQUIRED on TRON and is tagged (TRON only). TronWeb's createSmartContract derives - * constructor types from the ABI and takes only bare values; ethers needs no ABI at all. - * Synthesising an ABI from the caller's inline types would hand TronWeb something nothing can - * check — a mistyped parameter would encode cleanly and deploy a wrong contract. - */ function deployTyped(input: Record) { const deploy = vi.fn(async (_c: unknown, _n: unknown, _i: { parameters: unknown[] }) => ({ kind: "tx-receipt" as const, @@ -163,7 +64,6 @@ describe("contract deploy — --constructor-params takes the typed form", () => }); await expect(run()).resolves.toBeDefined(); - // TronWeb takes bare values alongside the ABI, so the values are unwrapped here. expect(deploy.mock.calls[0]![2]).toMatchObject({ parameters: ["100", "My Token"] }); }); @@ -173,7 +73,6 @@ describe("contract deploy — --constructor-params takes the typed form", () => expect(deploy.mock.calls[0]![2]).toMatchObject({ parameters: [] }); }); - // The inverted guard: bare values were the old deploy form and are now the wrong one. it("rejects the bare positional form that --params used to take", async () => { const { run, deploy } = deployTyped({ abi: ABI, constructorParams: '[100, "My Token"]' }); @@ -183,11 +82,6 @@ describe("contract deploy — --constructor-params takes the typed form", () => }); expect(deploy).not.toHaveBeenCalled(); }); - - it("still refuses an ABI whose constructor TronWeb would crash on", async () => { - const { run } = deployTyped({ abi: ctor({ stateMutability: 42 }), constructorParams: "[]" }); - await expect(run()).rejects.toMatchObject({ code: "invalid_value" }); - }); }); describe("contract deploy — code input channel", () => { @@ -199,12 +93,14 @@ describe("contract deploy — code input channel", () => { expect(deploy.mock.calls[0]![2]).toMatchObject({ bytecode: "6080" }); }); - // These are schema rules, so they are asserted against the schema: calling the binding - // directly bypasses zod entirely and would pass no matter what the refine said. const parse = (input: Record) => - contractDeploySpec.baseFields - .superRefine(contractDeploySpec.baseRefine!) - .safeParse({ dryRun: false, signOnly: false, buildOnly: false, permissionId: 0, ...input }); + chainInputSchema(contractDeploySpec, {} as FamilyBinding).input.safeParse({ + dryRun: false, + signOnly: false, + buildOnly: false, + permissionId: 0, + ...input, + }); it("refuses both --code and --code-file at once", () => { expect(parse({ code: "6080", codeFile: "./Token.bin" }).success).toBe(false); @@ -220,19 +116,6 @@ describe("contract deploy — code input channel", () => { }); }); -/** - * The bytecode is checked locally before anything is built or sent. - * - * `--code` and `--code-file` both arrive through the one helper the EVM and TRON bindings share, - * so the rule is stated once and holds for both families: after trimming the SURROUNDING - * whitespace, it must be non-empty, even-length hex with an optional `0x`. - * - * The check otherwise refuses only what cannot succeed — interior whitespace, which ethers - * cannot even serialise. The empty-string rejection is the one deliberate exception, and it - * pairs with the acceptance of `0x`: both deploy empty code, but only `0x` STATES that, while an - * empty file is what a failed compile leaves behind. See the helper's comment for that trade-off - * and for why the trim is `.trim()` and not `create2.ts`'s `\s+`. - */ describe("contract deploy — creation bytecode is validated locally", () => { const ABI = ctor({ stateMutability: "nonpayable", inputs: [] }); @@ -249,16 +132,9 @@ describe("contract deploy — creation bytecode is validated locally", () => { ["not hex at all", "xyz"], ["odd-length hex", "600"], ["hex with a stray non-hex digit", "60806040zz"], - // Interior whitespace is refused rather than stripped, and that choice is load-bearing: - // stripping it would silently JOIN two bytecode fragments a caller left on separate lines - // into one valid hex string and deploy a contract nobody asked for. Refusing convicts no - // one, because ethers already dies on it ("invalid BytesLike value (value=\"0x6080 604052\")") - // — so this is a loud failure kept loud. Do not "tidy" this into a `\s+` strip. + // Reject interior whitespace to avoid joining separate bytecode fragments. ["hex split by an interior space", "6080 604052"], ["hex split across lines", "6080\n604052"], - // Empty is refused even though an empty deployment would succeed on chain: see the paired - // "accepts an explicit 0x" test below for why stated intent, not chain outcome, draws - // this line. The two are deliberately a pair — do not "fix" one without the other. ["nothing but whitespace", " \n "], ])("refuses --code that is %s, without reaching the chain", async (_label, code) => { const { run, deploy } = evmDeployWith({ code }); @@ -269,10 +145,6 @@ describe("contract deploy — creation bytecode is validated locally", () => { expect(deploy).not.toHaveBeenCalled(); }); - // The other half of the pair above. Measured before the check existed: `--code 0x --build-only` - // produced a complete, signable, broadcastable deployment (tx.data "0x") — an empty deployment - // is a real thing with real uses, such as occupying a CREATE2 address. Writing `0x` STATES that - // intent, which an empty input does not, so this one is accepted and passed through untouched. it("accepts an explicit 0x, the empty deployment stated on purpose", async () => { const { run, deploy } = evmDeployWith({ code: "0x" }); await expect(run()).resolves.toBeDefined(); @@ -298,9 +170,6 @@ describe("contract deploy — creation bytecode is validated locally", () => { expect(deploy.mock.calls[0]![2]).toMatchObject({ bytecode: expected }); }); - // Before: checkedCreationCode stripped /^0x/i for the hex check but returned the value as - // given, so ethers refused it later as invalid_transaction at exit 1 — the very - // "local check misses it, downstream misclassifies it" shape this validation exists to kill. it("normalises an uppercase 0X prefix on --code instead of leaking it downstream", async () => { const { run, deploy } = evmDeployWith({ code: "0X6080604052" }); await expect(run()).resolves.toBeDefined(); @@ -327,9 +196,6 @@ describe("contract deploy — creation bytecode is validated locally", () => { expect(deploy.mock.calls[0]![2].bytecode).toBe("0x"); }); - // Paired with the test above, and the pair is the point: a file holding `0x` deploys empty code - // on purpose, an empty file is what a failed compile leaves behind. Waving the empty one - // through would quietly deploy an empty contract and burn a real CREATE's gas for it. it.each([ ["empty", ""], ["nothing but whitespace", " \n "], @@ -361,9 +227,6 @@ describe("contract deploy — creation bytecode is validated locally", () => { }); describe("contract deploy — EVM flag surface", () => { - // A flag that is offered but ignored is worse than an absent one: the caller believes the - // value was applied. `deploy` hardcodes value 0, and the usage line does not list - // --call-value, so it must not appear here — unlike `contract send`, which does use it. it("offers no --call-value, which deploy would ignore", () => { expect(Object.keys(contractDeployEvmBinding({} as never).fields?.shape ?? {})).not.toContain( "callValue", @@ -375,11 +238,6 @@ describe("contract deploy — EVM flag surface", () => { expect(keys).toEqual(expect.arrayContaining(["gasLimit", "maxFee", "priorityFee", "nonce"])); }); - /** - * The call value moved to the SHARED spec as `--value`: the concept - * is the same on every chain, so it is one flag with one unit rather than a per-family name. - * `contract deploy` still offers none — its value is always zero. - */ it("takes its call value from the shared --value, not a family flag", () => { expect(Object.keys(contractSendEvmBinding({} as never).fields?.shape ?? {})).not.toContain( "callValue", diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index 8d74a65c6..ab83b9f26 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -3,20 +3,28 @@ import { readFile } from "node:fs/promises"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import { errorMessage, UsageError } from "../../../../domain/errors/index.js"; import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; -import type { DeployConstructorArgs } from "../../../../application/ports/chain/gateway-provider.js"; +import type { DeployConstructorArgs } from "../../../../application/ports/chain/evm/evm-gateway.js"; import type { EvmContractService } from "../../../../application/use-cases/evm/contract-service.js"; -import type { TronContractParameter } from "../../../../application/ports/chain/tron-gateway.js"; +import type { TronContractParameter } from "../../../../application/ports/chain/tron/tron-gateway.js"; import { Schemas, addressFieldsFor } from "../schemas/index.js"; -import { gweiToWei } from "../../../../domain/fees/evm-gas.js"; -import { toBaseUnits } from "../../../../domain/amounts/index.js"; +import { gweiToWei } from "../../../../domain/evm/evm-gas.js"; +import { + isZeroAmount, + toBaseUnits, + sunAmount, + type Sun, +} from "../../../../domain/amounts/index.js"; import { FAMILIES } from "../../../../domain/family/index.js"; import { governanceTxModeFields, - governanceTxRefine, + tronExpirationRefine, tronTxModeFields, + txModeExclusive, txModeFields, } from "./shared.js"; import { TextFormatters } from "../render/index.js"; +import { isOriginEnergyLimit, MAX_PERCENT } from "../../../../domain/tron/governance/limits.js"; +import { DEFAULT_FEE_LIMIT_SUN } from "../../../../domain/fees/tron.js"; function jsonArray(raw: string | undefined, flag = "--params"): unknown[] { if (!raw) return []; @@ -29,9 +37,7 @@ function jsonArray(raw: string | undefined, flag = "--params"): unknown[] { throw new UsageError("invalid_value", `${flag} must be a JSON array`); } -// Contract parameters are ABI-encoded from {type, value} entries. Validate the shape at the -// command boundary so a malformed entry fails as invalid_value here, not as an opaque family -// encoder/RPC error later. +// Validate here so malformed entries produce a usage error before encoding. const typedParam = z .object({ type: z.string().min(1), value: z.unknown() }) .refine((e) => e.value !== undefined, { message: "value is required" }); @@ -47,53 +53,6 @@ function typedParams(raw: string | undefined): TronContractParameter[] { return arr as TronContractParameter[]; } -// ── deploy input guards ──────────────────────────────────────────────────────── -// Both guards below only restate a rejection TronWeb already makes — the accepted input set is -// unchanged. They exist because TronWeb states these two in terms of its own internals, and one -// of them not as a rejection at all but as a crash. - -/** the ABI's entry list, in either shape TronWeb reads (`abi` itself, or `abi.entrys`). */ -function abiEntries(abi: unknown): unknown[] | undefined { - if (Array.isArray(abi)) return abi; - const wrapped = (abi as { entrys?: unknown } | null)?.entrys; - return Array.isArray(wrapped) ? wrapped : undefined; -} - -/** - * TronWeb decides whether a constructor may take call value with an unguarded read — - * `'payable' === func.stateMutability.toLowerCase()` (TransactionBuilder.js) — so a constructor - * entry whose `stateMutability` is not a string dies inside the encoder as "Cannot read properties - * of undefined (reading 'toLowerCase')", naming neither the ABI nor the missing key, and arriving - * as rpc_error/exit 1 despite no request having been sent. - * - * Rejected here iff that read would throw — i.e. the value is not a string. An empty string is - * left alone on purpose: TronWeb accepts it (`''.toLowerCase()` is fine, the constructor is simply - * not payable), and rejecting it would make this CLI stricter than the encoder it fronts. - * A non-array, non-`entrys` ABI is likewise left alone — TronWeb's own "Invalid options.abi - * provided" already says that plainly. - */ -function assertConstructorEncodable(abi: unknown): void { - for (const entry of abiEntries(abi) ?? []) { - const e = entry as { type?: unknown; stateMutability?: unknown } | null; - if (e?.type !== "constructor") continue; // TronWeb's && short-circuits the same way - if (typeof e.stateMutability !== "string") { - throw new UsageError( - "invalid_value", - '--abi constructor entry needs a string "stateMutability" ("nonpayable" or "payable"); ' + - "solc emits it — add it by hand if the ABI was trimmed or came from solc < 0.5", - ); - } - } -} - -/** - * `--constructor-params` entries, as `{type, value}` — the same form `contract call` and - * `contract send` take. - * - * Deploy used to take bare positional values here while its siblings took typed entries: one - * flag name, two incompatible formats. That is what unifying the flag fixed, so the guard that used to - * reject the typed form now rejects the bare one. - */ function typedConstructorParams(raw: string | undefined): TronContractParameter[] { const values = jsonArray(raw, "--constructor-params"); if (!z.array(typedParam).safeParse(values).success) { @@ -120,8 +79,6 @@ export const contractCallSpec: ChainSpec = { auth: "none", capability: "contract.call", summary: "Read-only contract call", - // No ABI is fetched — the caller supplies the types. Without this the reader has no way - // to know why a signature is required, or why the result comes back undecoded. description: "Read-only contract call. The function signature and parameter types are supplied\n" + "explicitly; no ABI lookup is performed.", @@ -139,14 +96,14 @@ export const contractCallSpec: ChainSpec = { export const contractCallTronBinding = (svc: TronContractService): FamilyBinding => ({ refine: addressFieldsFor("tron", "contract"), - run: async (_ctx, net, input) => - svc.call(net, input.contract, input.method, typedParams(input.params)), + run: async (ctx, net, input) => + svc.call(ctx.deadline, net, input.contract, input.method, typedParams(input.params)), }); export const contractCallEvmBinding = (svc: EvmContractService): FamilyBinding => ({ refine: addressFieldsFor("evm", "contract"), - run: async (_ctx, net, input) => - svc.call(net, input.contract, input.method, typedParams(input.params)), + run: async (ctx, net, input) => + svc.call(ctx.deadline, net, input.contract, input.method, typedParams(input.params)), }); const sendFields = z.object({ @@ -156,11 +113,7 @@ const sendFields = z.object({ .string() .optional() .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), - // Family-neutral and in WHOLE COINS, like `tx send --amount`. The concept — native coin - // attached to a call — is the same on every chain, so it gets one flag and one unit; the unit - // in `--call-value-sun`'s name is what made it unusable off TRON. - // Zero is a legitimate call value (it is the default), so this is not the transfer amount's - // "must be greater than zero" schema. + // Whole native coins; zero is valid for calls. value: z .string() .regex(/^\d+(\.\d+)?$/, "must be a non-negative decimal string") @@ -170,24 +123,19 @@ const sendFields = z.object({ buildOnly: z .boolean() .default(false) - .describe( - "build an unsigned transaction without signing or broadcasting; mutually exclusive with --dry-run/--sign-only", - ), + .describe("build an unsigned transaction without signing or broadcasting"), }); -/** TRON prices a contract call in SUN and burns energy up to a fee limit; both flag names say so. */ const tronContractWriteFields = z.object({ callValueSun: Schemas.uintString() .optional() .describe("deprecated alias for --value, in SUN; removed next release"), feeLimit: Schemas.positiveIntString() - .default("100000000") + .default(DEFAULT_FEE_LIMIT_SUN) .describe("maximum energy fee to burn, in SUN"), ...tronTxModeFields, }); -/** EVM prices it in gas. `--call-value` is in whole coins, matching `tx send --amount`; the - * per-gas fields are gwei, the unit every wallet and explorer uses. */ const evmGasFields = z.object({ gasLimit: Schemas.positiveIntString() .optional() @@ -208,15 +156,12 @@ const evmGasFields = z.object({ .describe("transaction nonce; defaults to the account's pending nonce"), }); -/** `contract send`'s call value is the shared `--value`; `contract deploy` has none — a - * deployment's value is always zero here, and offering a flag the command ignores is worse than - * omitting it. */ const evmContractWrite = { fields: evmGasFields, refine: addressFieldsFor("evm", "contract"), }; -/** gwei on the flag, wei below it. */ +// CLI fees are in gwei; service fees are in wei. const withEvmFees = (input: Record) => ({ ...input, ...(input.maxFee === undefined ? {} : { maxFee: gweiToWei(String(input.maxFee)) }), @@ -234,8 +179,9 @@ export const contractSendSpec: ChainSpec = { description: "Call a contract method that changes state, signing and broadcasting the transaction.\n" + "Flags marked (TRON only) or (EVM only) are accepted only on networks of that family; using one on the other family is rejected.", + exclusive: [txModeExclusive], baseFields: sendFields, - baseRefine: governanceTxRefine, + baseRefine: tronExpirationRefine, examples: [ { cmd: `wallet-cli contract send --contract TR7... --method "transfer(address,uint256)" --params '[...]' --network nile`, @@ -257,7 +203,6 @@ export const contractSendEvmBinding = (svc: EvmContractService): FamilyBinding = }), }); -/** the creation bytecode, from `--code` or `--code-file`. */ async function creationBytecode(input: { code?: string; codeFile?: string }): Promise { if (!input.codeFile) return checkedCreationCode(input.code!, "--code"); let text: string; @@ -272,37 +217,7 @@ async function creationBytecode(input: { code?: string; codeFile?: string }): Pr return checkedCreationCode(text, `--code-file ${input.codeFile}`); } -/** - * The bytecode with its surrounding whitespace trimmed, refused unless it is even-length hex. - * - * The rule is: trim, then even-length hex with an optional `0x`, and the trimmed string must not - * be empty. The `0x` prefix is left on when it was given: normalising it is the encoder's job. - * - * That empty-string rejection is a DELIBERATE exception to this command's usual principle of - * refusing only what cannot succeed. An empty deployment does succeed — `0x` builds a valid - * transaction the EVM runs (empty init code, empty runtime code) with real uses, such as - * occupying a CREATE2 address — so the line here is drawn at stated intent rather than at - * chain outcome. Writing `0x` states it; an empty or whitespace-only file states nothing, and is - * far more often what a failed compile leaves behind than something anybody meant. Waving that - * through would quietly deploy an empty contract and burn a real CREATE's gas for it. Rejecting - * it also puts the three ways in on the same footing: an empty VALUE is refused everywhere - * (`--code ''` already fails the schema's `min(1)`), and an explicit `0x` is accepted everywhere. - * - * `contract create2` strips whitespace with `\s+` (domain/governance/create2.ts); this is - * deliberately `.trim()` instead, not an oversight. A file holding two bytecode fragments on two - * lines would be silently JOINED into one valid hex string by `\s+`, deploying a contract nobody - * asked for — turning a loud failure into a quiet wrong answer. Interior whitespace already - * cannot succeed (ethers refuses `"0x6080 604052"` at RLP serialisation), so refusing it here - * convicts no one. A trailing newline is different: a code file all but always has one — an - * editor's save, `echo`, `jq -r` — and it is objectively not bytecode. - * - * The `0x`/`0X` prefix is normalised to lowercase rather than accepted as-is: `0X` is valid hex - * notation with no semantic difference from `0x`, but ethers rejects it downstream as - * invalid_transaction — a local check that let it through while the encoder refused it would be - * exactly the "misses it locally, misclassifies it downstream" failure this validation exists to - * prevent. Normalising, not rejecting, is the fix: this CLI's own acceptance policy is "refuse - * only what cannot succeed", and `0X...` bytecode succeeds once spelled the way the encoder wants. - */ +// Allow explicit 0x for empty deployment; reject empty input and internal whitespace. function checkedCreationCode(raw: string, source: string): string { const code = raw.trim(); const hex = code.replace(/^0x/i, ""); @@ -317,23 +232,10 @@ function checkedCreationCode(raw: string, source: string): string { interface DeploySource { bytecode: string; - /** present only when the source was an artifact; it is the compiler's own ABI. */ + // Compiler ABI, present only for artifact input. abi?: unknown; } -/** - * A compiler artifact — the bytecode and the ABI, from the file the compiler already wrote. - * - * Every toolchain in both families emits the same two fields: Foundry (`out/X.sol/X.json`), - * Hardhat and its TRON plugin sunhat (`artifacts/…/X.json`), and TronBox - * (`build/contracts/X.json`). Only the bytecode's shape differs — Foundry nests it under - * `{object}`, the others store the string directly — so both are accepted. - * - * This matters most on TRON, where `--abi` is required: without it the caller has to open the - * artifact and paste a multi-kilobyte ABI onto the command line, which is transcription, not - * input. It also removes the one way a correct deployment can still go wrong — types typed by - * hand — because the ABI comes from the compiler that produced the bytecode. - */ async function readArtifact(path: string): Promise { let text: string; try { @@ -358,8 +260,7 @@ async function readArtifact(path: string): Promise { errorMessage`artifact has no creation bytecode: ${path} (looked at .bytecode.object, .bytecode and .evm.bytecode.object)`, ); } - // solc emits "0x" for an interface or an abstract contract: a real artifact for something that - // cannot be deployed. Saying so beats letting an empty deployment reach the chain. + // solc emits empty bytecode for interfaces and abstract contracts. if (bytecode.replace(/^0x/, "") === "") { throw new UsageError( "invalid_value", @@ -369,7 +270,6 @@ async function readArtifact(path: string): Promise { return { bytecode, ...(artifact.abi === undefined ? {} : { abi: artifact.abi }) }; } -/** the bytecode, and the ABI when the caller pointed at an artifact. */ async function deploySource(input: { code?: string; codeFile?: string; @@ -386,37 +286,21 @@ interface DeployArgInput { constructorParams?: string; } -/** bare constructor values, from `--constructor-args` or unwrapped from `--constructor-params`. */ function constructorValues(input: DeployArgInput): unknown[] { if (input.constructorArgs !== undefined) return jsonArray(input.constructorArgs, "--constructor-args"); return typedConstructorParams(input.constructorParams).map((entry) => entry.value); } -/** - * Where the constructor's TYPES come from, in order of authority: the compiler's ABI, then a - * signature the caller stated, then — only because it is the shape this command shipped with — - * the types inlined beside each value. - */ function deployConstructorArgs(input: DeployArgInput, abi: unknown): DeployConstructorArgs { const values = constructorValues(input); if (abi !== undefined) return { source: "abi", abi, values }; if (input.constructorSignature !== undefined) { - return { - source: "signature", - signature: input.constructorSignature, - values, - flag: "--constructor-signature", - }; + return { source: "signature", signature: input.constructorSignature, values }; } if (input.constructorParams === undefined) return { source: "none" }; const types = typedConstructorParams(input.constructorParams).map((entry) => entry.type); - return { - source: "signature", - signature: `constructor(${types.join(",")})`, - values, - flag: "--constructor-params", - }; + return { source: "typed-params", types, values }; } export const contractDeployEvmBinding = (svc: EvmContractService): FamilyBinding => ({ @@ -438,19 +322,13 @@ export const contractSendTronBinding = (svc: TronContractService): FamilyBinding svc.send(ctx, net, { ...input, callValueSun: tronCallValueSun(input), + feeLimit: sunAmount(input.feeLimit), parameters: typedParams(input.params), }), }); -/** - * The call value in SUN, from either flag. - * - * `--value` is the family-neutral form and takes whole TRX; `--call-value-sun` is the old - * TRON-only spelling, kept working for one release. Both at once is refused rather than - * silently preferring one — they can disagree, and picking a winner would move an amount of - * money the caller did not ask for. - */ -function tronCallValueSun(input: { value?: string; callValueSun?: string }): string { +// --value is whole TRX; --call-value-sun is SUN. Reject conflicting amount sources. +function tronCallValueSun(input: { value?: string; callValueSun?: string }): Sun { if (input.value !== undefined && input.callValueSun !== undefined) { throw new UsageError( "invalid_option", @@ -458,9 +336,9 @@ function tronCallValueSun(input: { value?: string; callValueSun?: string }): str ); } if (input.value !== undefined) { - return toBaseUnits(input.value, FAMILIES.tron.nativeDecimals, "call value"); + return sunAmount(toBaseUnits(input.value, FAMILIES.tron.nativeDecimals, "call value")); } - return input.callValueSun ?? "0"; + return sunAmount(input.callValueSun ?? "0"); } const deployFields = z.object({ @@ -471,13 +349,7 @@ const deployFields = z.object({ .describe( "path to a compiler artifact (Foundry, Hardhat/sunhat, TronBox) holding both the bytecode and the ABI; the preferred source, because the constructor's types then come from the compiler", ), - code: z - .string() - .min(1) - .optional() - .describe( - "contract creation bytecode, hex-encoded; provide exactly one of --artifact, --code or --code-file", - ), + code: z.string().min(1).optional().describe("contract creation bytecode, hex-encoded"), codeFile: z .string() .min(1) @@ -508,14 +380,9 @@ const deployFields = z.object({ buildOnly: z .boolean() .default(false) - .describe( - "build an unsigned transaction without signing or broadcasting; mutually exclusive with --dry-run/--sign-only", - ), + .describe("build an unsigned transaction without signing or broadcasting"), }); -/** the spec's two base rules: the shared governance modes, plus exactly one bytecode source. - * Written out rather than composed generically because the two refines read different field - * sets, and a generic combinator would have to erase one of their types to fit them together. */ function deployRefine( value: { artifact?: string; @@ -529,18 +396,11 @@ function deployRefine( }, ctx: z.RefinementCtx, ): void { - governanceTxRefine(value as never, ctx); - codeSourceRefine(value, ctx); + tronExpirationRefine(value, ctx); constructorArgsRefine(value, ctx); } -/** - * The constructor's arguments must have exactly one form, and their types exactly one source. - * - * Both rules exist because the alternative is silence: two argument lists means one is ignored, - * and an ABI beside hand-written types means one of the two is not being used to encode. A - * deployment cannot be undone, so neither is left to a precedence rule the caller cannot see. - */ +// Reject competing argument or type sources rather than silently ignoring one. function constructorArgsRefine( value: { artifact?: string; @@ -551,13 +411,6 @@ function constructorArgsRefine( }, ctx: z.RefinementCtx, ): void { - if (value.constructorArgs !== undefined && value.constructorParams !== undefined) { - ctx.addIssue({ - code: "custom", - path: ["constructorArgs"], - message: "--constructor-args and --constructor-params are mutually exclusive", - }); - } if (value.constructorParams !== undefined && value.artifact !== undefined) { ctx.addIssue({ code: "custom", @@ -573,8 +426,6 @@ function constructorArgsRefine( message: "--constructor-signature is not needed with --artifact; its ABI declares the types", }); } - // `--abi` counts here: it is TRON-only, and on TRON it is the type source — naming only the - // family-neutral flags would send a TRON caller to --constructor-signature, which TRON refuses. if ( value.constructorArgs !== undefined && value.artifact === undefined && @@ -590,67 +441,16 @@ function constructorArgsRefine( } } -/** exactly one bytecode source, matching the rule `contract create2` already applies. */ -function codeSourceRefine( - value: { code?: string; codeFile?: string; artifact?: string }, - ctx: z.RefinementCtx, -): void { - if ( - [value.code !== undefined, value.codeFile !== undefined, value.artifact !== undefined].filter( - Boolean, - ).length !== 1 - ) { - ctx.addIssue({ - code: "custom", - path: ["code"], - message: "provide exactly one of --artifact, --code or --code-file", - params: { errorCode: "invalid_option" }, - }); - } -} - -/** - * TRON's deploy inputs. - * - * `--abi` stays REQUIRED here rather than becoming optional: TronWeb's createSmartContract - * derives the constructor's types from the ABI and takes only bare values, so without it there - * is nothing to encode against. Synthesising an ABI from the caller's inline types would hand - * TronWeb something no one can check — a mistyped parameter would encode cleanly and deploy a - * contract built from the wrong arguments. ethers needs no ABI, which is why this is `(TRON only)`. - */ const tronDeployFields = z.object({ - abi: z - .string() - .min(1) - .optional() - .describe("contract ABI as a JSON array string; required unless --artifact supplies one"), + abi: z.string().min(1).optional().describe("contract ABI as a JSON array string"), feeLimit: Schemas.positiveIntString() - .default("100000000") + .default(DEFAULT_FEE_LIMIT_SUN) .describe("maximum energy fee to burn, in SUN"), ...tronTxModeFields, }); -/** TronWeb needs the whole ABI, not just the constructor's types, so a signature cannot stand in - * for it — the one place where the two families genuinely need different inputs. */ -function tronDeployRefine( - value: { abi?: string; artifact?: string; constructorSignature?: string }, - ctx: z.RefinementCtx, -): void { - if (value.abi === undefined && value.artifact === undefined) { - ctx.addIssue({ - code: "custom", - path: ["abi"], - message: - "TRON needs the contract's ABI to encode a deployment: pass --artifact, or --abi with the JSON", - }); - } - if (value.abi !== undefined && value.artifact !== undefined) { - ctx.addIssue({ - code: "custom", - path: ["abi"], - message: "--abi and --artifact both supply the ABI; pass one", - }); - } +// TronWeb requires the full ABI; a constructor signature cannot replace it. +function tronDeployRefine(value: { constructorSignature?: string }, ctx: z.RefinementCtx): void { if (value.constructorSignature !== undefined) { ctx.addIssue({ code: "custom", @@ -673,6 +473,17 @@ export const contractDeploySpec: ChainSpec = { "Deploy contract creation bytecode and report the new contract's address.\n" + "Flags marked (TRON only) or (EVM only) are accepted only on networks of that family; using one on the other family is rejected.", baseFields: deployFields, + exclusive: [ + { label: "the creation bytecode", flags: ["artifact", "code", "code-file"] }, + { + label: "the constructor values", + flags: ["constructor-args", "constructor-params"], + select: "at-most-one", + }, + // --abi is a TRON field, so on EVM the group reduces to --artifact alone. + { label: "the contract ABI on TRON", flags: ["abi", "artifact"] }, + txModeExclusive, + ], baseRefine: deployRefine, examples: [ { @@ -701,13 +512,12 @@ export const contractDeployTronBinding = (svc: TronContractService): FamilyBindi throw new UsageError("invalid_value", "--abi must be valid JSON"); } } - assertConstructorEncodable(abi); return svc.deploy(ctx, net, { ...input, + feeLimit: sunAmount(input.feeLimit), abi, bytecode: source.bytecode, - // TronWeb takes bare values beside the ABI, so only the values travel. The TYPES come from - // the ABI in every case — which is why --artifact is the better way in. + // TronWeb takes bare values; their types come from the ABI. parameters: constructorValues(input), }); }, @@ -730,16 +540,17 @@ export const contractInfoSpec: ChainSpec = { }; export const contractInfoTronBinding = (svc: TronContractService): FamilyBinding => ({ - run: async (_ctx, net, input) => svc.info(net, input.contract), + run: async (ctx, net, input) => svc.info(ctx.deadline, net, input.contract), }); const contractGovernanceBase = { network: "optional" as const, wallet: "optional" as const, - auth: "required" as const, + auth: "conditional" as const, broadcasts: true, capability: "contract.governance", - baseRefine: governanceTxRefine, + exclusive: [txModeExclusive], + baseRefine: tronExpirationRefine, formatText: TextFormatters.governanceReceipt, }; @@ -777,7 +588,7 @@ export const contractSetOriginEnergyLimitSpec: ChainSpec = { address: governedContract, energy: Schemas.positiveIntString() .refine( - (value) => !/^\d+$/.test(value) || BigInt(value) <= (1n << 63n) - 1n, + (value) => !/^\d+$/.test(value) || isZeroAmount(value) || isOriginEnergyLimit(value), "must not exceed signed int64 max", ) .describe("deployer energy contribution limit; integer > 0"), @@ -807,8 +618,8 @@ export const contractSetUserResourcePercentSpec: ChainSpec = { .number() .int() .min(0) - .max(100) - .describe("percentage of energy paid by the caller (0-100)"), + .max(MAX_PERCENT) + .describe(`percentage of energy paid by the caller (0-${MAX_PERCENT})`), ...governanceTxModeFields, }), examples: [{ cmd: "wallet-cli contract set-user-resource-percent TQ5... 100 --wait" }], @@ -820,12 +631,6 @@ export const contractSetUserResourcePercentTronBinding = ( run: async (ctx, net, input) => svc.setUserResourcePercent(ctx, net, input), }); -function create2Refine(value: { code?: string; codeFile?: string }, ctx: z.RefinementCtx): void { - if ([value.code !== undefined, value.codeFile !== undefined].filter(Boolean).length !== 1) { - ctx.addIssue({ code: "custom", message: "provide exactly one of --code or --code-file" }); - } -} - export const contractCreate2Spec: ChainSpec = { path: ["contract", "create2"], network: "optional", @@ -848,7 +653,7 @@ export const contractCreate2Spec: ChainSpec = { .regex(/^-?\d+$/) .describe("signed decimal 64-bit salt"), }), - baseRefine: create2Refine, + exclusive: [{ label: "the creation bytecode", flags: ["code", "code-file"] }], examples: [ { cmd: "wallet-cli contract create2 --deployer TQk... --code-file ./Token.creation.hex --salt 1", diff --git a/ts/src/adapters/inbound/cli/commands/erc8004.ts b/ts/src/adapters/inbound/cli/commands/erc8004.ts index 3708c03b9..e7240ae46 100644 --- a/ts/src/adapters/inbound/cli/commands/erc8004.ts +++ b/ts/src/adapters/inbound/cli/commands/erc8004.ts @@ -3,8 +3,10 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { AgentService } from "../../../../application/use-cases/agent-service.js"; import { Schemas, addressFamilyFieldsFor } from "../schemas/index.js"; -import { governanceTxRefine, tronTxModeFields, txModeFields } from "./shared.js"; +import { tronExpirationRefine, tronTxModeFields, txModeExclusive, txModeFields } from "./shared.js"; import { TextFormatters } from "../render/index.js"; +import type { ChainFamily } from "../../../../domain/family/index.js"; +import { DEFAULT_FEE_LIMIT_SUN } from "../../../../domain/fees/tron.js"; const agentId = z .string() @@ -36,11 +38,17 @@ const address = Schemas.address(); const tronWriteFields = z.object({ feeLimit: Schemas.positiveIntString() - .default("100000000") + .default(DEFAULT_FEE_LIMIT_SUN) .describe("maximum energy fee to burn, in SUN"), ...tronTxModeFields, }); +// Fields each family adds to an ERC-8004 write. +const WRITE_FIELDS: Record = { + evm: undefined, + tron: tronWriteFields, +}; + function writeSpec( verb: string, fields: z.ZodObject, @@ -57,7 +65,8 @@ function writeSpec( positionals, summary, baseFields: fields.extend(txModeFields), - baseRefine: governanceTxRefine, + exclusive: [txModeExclusive], + baseRefine: tronExpirationRefine, examples: [], formatText: TextFormatters.txReceipt, }; @@ -115,7 +124,7 @@ export const approveSpec = writeSpec( "Approve or revoke an operator for one Agent", ); approveSpec.baseRefine = (value, context) => { - governanceTxRefine(value, context); + tronExpirationRefine(value, context); if (!value.revoke && !value.operator) { context.addIssue({ code: "custom", @@ -166,14 +175,15 @@ export const operatorCheckSpec: ChainSpec = { }; function binding( - family: "evm" | "tron", + family: ChainFamily, run: FamilyBinding["run"], addressFields: string[] = [], write = false, ): FamilyBinding { + const writeFields = write ? WRITE_FIELDS[family] : undefined; return { run, - ...(write && family === "tron" ? { fields: tronWriteFields } : {}), + ...(writeFields ? { fields: writeFields } : {}), ...(addressFields.length ? { refine: addressFamilyFieldsFor(family, ...addressFields) } : {}), }; } @@ -181,7 +191,7 @@ export function showEvmBinding(service: AgentService): FamilyBinding { return binding( "evm", async (ctx, net, input) => { - const { warnings, ...result } = await service.show(net, input.id); + const { warnings, ...result } = await service.show(ctx.deadline, net, input.id); for (const warning of warnings ?? []) ctx.warn(warning); return result; }, @@ -193,7 +203,7 @@ export function showTronBinding(service: AgentService): FamilyBinding { return binding( "tron", async (ctx, net, input) => { - const { warnings, ...result } = await service.show(net, input.id); + const { warnings, ...result } = await service.show(ctx.deadline, net, input.id); for (const warning of warnings ?? []) ctx.warn(warning); return result; }, @@ -280,7 +290,8 @@ export function operatorRemoveTronBinding(service: AgentService): FamilyBinding export function operatorCheckEvmBinding(service: AgentService): FamilyBinding { return binding( "evm", - async (_ctx, net, input) => service.operatorCheck(net, input.owner, input.operator), + async (ctx, net, input) => + service.operatorCheck(ctx.deadline, net, input.owner, input.operator), ["owner", "operator"], false, ); @@ -288,7 +299,8 @@ export function operatorCheckEvmBinding(service: AgentService): FamilyBinding { export function operatorCheckTronBinding(service: AgentService): FamilyBinding { return binding( "tron", - async (_ctx, net, input) => service.operatorCheck(net, input.owner, input.operator), + async (ctx, net, input) => + service.operatorCheck(ctx.deadline, net, input.owner, input.operator), ["owner", "operator"], false, ); diff --git a/ts/src/adapters/inbound/cli/commands/exchange.ts b/ts/src/adapters/inbound/cli/commands/exchange.ts index 5205cb051..cfd21fb16 100644 --- a/ts/src/adapters/inbound/cli/commands/exchange.ts +++ b/ts/src/adapters/inbound/cli/commands/exchange.ts @@ -1,18 +1,7 @@ -/** - * `exchange` — TRON's protocol-level Bancor market maker for TRX and TRC10. - * - * Four facts that run against intuition, and shape every command here: - * - only the pair's creator may inject or withdraw; this is private market-making, not a pool - * anyone can join, and the binding cannot be transferred; - * - TRX's on-chain token id is `_`; we accept `TRX`, `_` or a numeric TRC10 id; - * - `--min-received` is a floor that reverts the trade, not an expected return; - * - the protocol takes no fee — only `create` costs anything beyond bandwidth. - - */ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronExchangeService } from "../../../../application/use-cases/tron/exchange-service.js"; -import { txModeFields, tronTxModeFields } from "./shared.js"; +import { txModeFields, tronTxModeFields, txModeExclusive } from "./shared.js"; import { TextFormatters } from "../render/index.js"; const NO_NAMES = @@ -44,7 +33,10 @@ export const exchangeCreateSpec: ChainSpec = { "initial amounts is the pair's starting price. Sides keep the order you type.\n\n" + NO_NAMES, requires: ["an account with enough TRX for the fee and enough of both tokens"], - exclusive: [{ label: "how to size both sides", flags: ["amounts", "raw-amounts"] }], + exclusive: [ + { label: "how to size both sides", flags: ["amounts", "raw-amounts"] }, + txModeExclusive, + ], baseFields: z.object({ pair: z.string().min(1).describe("the two sides as :, TRX or a TRC10 id"), amounts: z @@ -82,7 +74,10 @@ export const exchangeInjectSpec: ChainSpec = { NO_NAMES, requires: ["the account that created the pair, holding enough of both tokens"], positionals: [{ field: "id" }], - exclusive: [{ label: "how to size the amount", flags: ["amount", "raw-amount"] }], + exclusive: [ + { label: "how to size the amount", flags: ["amount", "raw-amount"] }, + txModeExclusive, + ], baseFields: z.object({ id: exchangeId, token: tokenField("the side you are specifying"), @@ -112,7 +107,10 @@ export const exchangeWithdrawSpec: ChainSpec = { NO_NAMES, requires: ["the account that created the pair"], positionals: [{ field: "id" }], - exclusive: [{ label: "how to size the amount", flags: ["amount", "raw-amount"] }], + exclusive: [ + { label: "how to size the amount", flags: ["amount", "raw-amount"] }, + txModeExclusive, + ], baseFields: z.object({ id: exchangeId, token: tokenField("the side you are specifying"), @@ -151,6 +149,7 @@ export const exchangeTradeSpec: ChainSpec = { flags: ["min-received", "raw-min-received", "slippage"], select: "at-most-one", }, + txModeExclusive, ], baseFields: z.object({ id: exchangeId, @@ -247,7 +246,13 @@ export function exchangeDefinitions( binding: { run: (ctx, net, input) => svc.withdraw(ctx, net, input) }, }, { spec: exchangeTradeSpec, binding: { run: (ctx, net, input) => svc.trade(ctx, net, input) } }, - { spec: exchangeShowSpec, binding: { run: (_ctx, net, input) => svc.show(net, input) } }, - { spec: exchangeListSpec, binding: { run: (_ctx, net, input) => svc.list(net, input) } }, + { + spec: exchangeShowSpec, + binding: { run: (ctx, net, input) => svc.show(ctx.deadline, net, input) }, + }, + { + spec: exchangeListSpec, + binding: { run: (ctx, net, input) => svc.list(ctx.deadline, net, input) }, + }, ]; } diff --git a/ts/src/adapters/inbound/cli/commands/family-fields.test.ts b/ts/src/adapters/inbound/cli/commands/family-fields.test.ts index 5645aeca5..fe64d4589 100644 --- a/ts/src/adapters/inbound/cli/commands/family-fields.test.ts +++ b/ts/src/adapters/inbound/cli/commands/family-fields.test.ts @@ -1,17 +1,5 @@ -/** - * Family-scoped command fields. - * - * A ChainSpec's `baseFields` is shared by every family, so it may only declare what every family - * actually has. Two consequences this file pins down: - * - * - TRC10 (`--asset-id`) is a TRON concept. It belongs to the TRON binding, not to the base. - * - An address flag stays a plain string in the base and is validated by the family's own - * `refine`, so help/catalog show one flag while each family still rejects the other's format. - * (Merging two same-named family fields would collapse in help — `mergedFields` is - * last-writer-wins — so the base-plus-refine shape is the one that survives EVM registration.) - */ import { describe, it, expect } from "vitest"; -import { composeRefines, parseInputSchema } from "../shell/index.js"; +import { chainInputSchema, parseInputSchema } from "../shell/index.js"; import { tokenBalanceSpec, tokenBalanceTronBinding, @@ -25,10 +13,8 @@ import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; const TRON_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; const EVM_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; -/** the schema dispatch actually parses against: base + family fields + both refines. */ function effectiveSchema(spec: ChainSpec, binding: FamilyBinding) { - const fields = binding.fields ? spec.baseFields.extend(binding.fields.shape) : spec.baseFields; - return composeRefines(fields, spec.baseRefine, binding.refine); + return chainInputSchema(spec, binding).input; } const svc = {} as never; @@ -52,8 +38,6 @@ describe("token selector fields", () => { expect(schema.safeParse({ assetId: "1002000" }).success).toBe(true); }); - // BUG-V413-008: both selectors given is a flag conflict, not a bad value — machine-interface.md - // reserves invalid_option for exactly this ("a flag used in an invalid combination"). it("reports both selectors given as invalid_option, not invalid_value", () => { const schema = effectiveSchema(tokenBalanceSpec, tokenBalanceTronBinding(svc)); expect(() => @@ -88,8 +72,6 @@ describe("address flags shared across families", () => { expect(result.success).toBe(true); }); - // BUG-V413-018: a malformed address is invalid_address, not invalid_value — - // machine-interface.md defines invalid_address as "not a valid address for the relevant chain". it.each(cases)( "%s reports a non-TRON --contract as invalid_address", (_n, spec, binding, base) => { @@ -100,7 +82,6 @@ describe("address flags shared across families", () => { ); }); -// BUG-V413-010: --amount and --raw-amount both given is a flag conflict, not a bad value. describe("amount selector", () => { it("reports both --amount and --raw-amount as invalid_option", () => { const schema = effectiveSchema(txSendSpec, txSendTronBinding(svc)); @@ -110,10 +91,9 @@ describe("amount selector", () => { }); }); -// BUG-V413-016: two bytecode sources given is a flag conflict, not a bad value. describe("contract deploy bytecode source selector", () => { it("reports two bytecode sources as invalid_option", () => { - const schema = contractDeploySpec.baseFields.superRefine(contractDeploySpec.baseRefine!); + const schema = effectiveSchema(contractDeploySpec, {} as FamilyBinding); expect(() => parseInputSchema(schema, { artifact: "a.json", code: "6080", feeLimit: "1000000" }), ).toThrowError(expect.objectContaining({ code: "invalid_option" })); diff --git a/ts/src/adapters/inbound/cli/commands/gasfree.ts b/ts/src/adapters/inbound/cli/commands/gasfree.ts index 95274b983..d174c5aa9 100644 --- a/ts/src/adapters/inbound/cli/commands/gasfree.ts +++ b/ts/src/adapters/inbound/cli/commands/gasfree.ts @@ -1,7 +1,9 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; -import type { GasFreeService } from "../../../../application/use-cases/tron/gasfree-service.js"; +import type { GasFreeService } from "../../../../application/use-cases/gasfree/gasfree-service.js"; import { TextFormatters } from "../render/index.js"; +import { isZeroAmount } from "../../../../domain/amounts/index.js"; +import { defaultToken } from "../../../../application/services/default-token.js"; export const gasFreeInfoSpec: ChainSpec = { path: ["gasfree", "info"], @@ -27,14 +29,14 @@ const transferFields = z.object({ amount: z .string() .regex(/^\d+(\.\d+)?$/, "must be a positive decimal amount") - .refine((value) => !/^0+(\.0+)?$/.test(value), "must be greater than zero") + .refine((value) => !isZeroAmount(value), "must be greater than zero") .describe("human token amount to transfer, in token units"), token: z .string() .trim() .min(1) .max(32) - .default("USDT") + .default(defaultToken("gasfree.transfer")) .describe("token symbol supported by the GasFree provider"), dryRun: z .boolean() @@ -56,21 +58,12 @@ export const gasFreeTransferSpec: ChainSpec = { description: "Sign a GasFree PermitTransfer and submit it to the provider. No TRX is needed; --dry-run checks the token balance and fee breakdown without unlocking or signing.", baseFields: transferFields, - baseRefine: (value, context) => { - if (value.dryRun && value.wait) { - context.addIssue({ - code: "custom", - path: ["dryRun"], - message: "--dry-run cannot be combined with --wait", - }); - } - }, examples: [ { cmd: "wallet-cli gasfree transfer --to T... --amount 25 --password-stdin", }, { - cmd: "wallet-cli gasfree transfer --to alice --amount 25 --wait --password-stdin", + cmd: "wallet-cli gasfree transfer --to alice --amount 25 --no-wait --password-stdin", }, ], formatText: TextFormatters.gasFreeTransfer, @@ -111,5 +104,5 @@ export const gasFreeTraceSpec: ChainSpec = { }; export const gasFreeTraceTronBinding = (service: GasFreeService): FamilyBinding => ({ - run: async (_context, network, input) => service.trace(network, input.traceId), + run: async (context, network, input) => service.trace(context.deadline, network, input.traceId), }); diff --git a/ts/src/adapters/inbound/cli/commands/irreversible-help.test.ts b/ts/src/adapters/inbound/cli/commands/irreversible-help.test.ts index f75661a8b..02a6b152b 100644 --- a/ts/src/adapters/inbound/cli/commands/irreversible-help.test.ts +++ b/ts/src/adapters/inbound/cli/commands/irreversible-help.test.ts @@ -2,23 +2,14 @@ import { describe, it, expect } from "vitest"; import { accountActivateSpec, accountSetSpec } from "./account.js"; import { permissionUpdateSpec } from "./permission.js"; -// Both commands are one-way doors, and neither help text said so. `account set` is rejected with -// name_already_set / id_already_set on a second attempt (account-service.ts), and `permission -// update` only warns about a lockout — permissionSafetyWarnings goes to scope.warn and execution -// continues, with no confirmation prompt anywhere. A reader has to learn that before running, not -// from the error afterwards. describe("help for the irreversible commands says so", () => { it("account set warns the field can never be changed, and is not a local rename", () => { const text = accountSetSpec.description ?? ""; expect(text).toMatch(/only once|never be changed/i); expect(text).toMatch(/--dry-run/); - // `rename` changes the local label and is the command people actually mean most of the time expect(text).toMatch(/rename/); }); - // Activation is a paid transaction, and a plain transfer to an unactivated address already - // activates it. Someone who is going to send funds anyway does not need this command at all — - // without the hint they pay for two transactions where one would do. it("account activate points out that a plain transfer already activates the target", () => { const text = accountActivateSpec.description ?? ""; expect(text).toMatch(/transfer/i); diff --git a/ts/src/adapters/inbound/cli/commands/message.sign.test.ts b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts index db1807530..271fd876c 100644 --- a/ts/src/adapters/inbound/cli/commands/message.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect } from "vitest"; import { messageSignSpec, messageSignBinding } from "./shared.js"; -// SecretResolver.pick is an exactly-one selector: both sources → invalid_option, neither → -// missing_option. That constraint used to live only in the runtime and in prose inside the -// --message description, so `--help` listed a lone "[optional]" flag and never named --message-stdin -// as the alternative. describe("message sign exclusive group", () => { it("declares the inline flag and the stdin channel as jointly required", () => { expect(messageSignSpec.exclusive).toEqual([ diff --git a/ts/src/adapters/inbound/cli/commands/network.ts b/ts/src/adapters/inbound/cli/commands/network.ts index 76b6fdb1b..994606227 100644 --- a/ts/src/adapters/inbound/cli/commands/network.ts +++ b/ts/src/adapters/inbound/cli/commands/network.ts @@ -1,6 +1,3 @@ -/** - * Network command — list known networks. Neutral and not bound to one family. - */ import { z } from "zod"; import type { CommandDefinition } from "../contracts/index.js"; import { CommandRegistry } from "../registry/index.js"; @@ -10,15 +7,12 @@ import { endpointHost } from "../../../../domain/types/index.js"; export function registerNetworkCommands(reg: CommandRegistry): void { const empty = z.object({}); - // ── networks ──────────────────────────────────────────────────────────────── reg.add({ path: ["networks"], network: "none", wallet: "none", auth: "none", summary: "List known networks", - // Adjusted to the six-column table this actually prints: the canonical id and the - // alias are separate columns, so the description names both rather than only one. description: "List known networks with their family, chain id, fee model and endpoint host.\n" + "Network is the canonical CAIP-2 id (namespace:reference); Alias is the short name\n" + @@ -34,8 +28,7 @@ export function registerNetworkCommands(reg: CommandRegistry): void { family: n.family, chainId: n.chainId, feeModel: n.feeModel, - // host only: an endpoint may carry an API key in its path, and this output is not a - // secret surface. `config get networks` is the place to confirm a full URL. + // Hide URL paths because endpoints may contain API keys. endpoint: endpointHost(n.httpEndpoint), })), } satisfies CommandDefinition); diff --git a/ts/src/adapters/inbound/cli/commands/permission.ts b/ts/src/adapters/inbound/cli/commands/permission.ts index d4802990e..6ee1c7413 100644 --- a/ts/src/adapters/inbound/cli/commands/permission.ts +++ b/ts/src/adapters/inbound/cli/commands/permission.ts @@ -4,8 +4,8 @@ import type { TronPermissionService } from "../../../../application/use-cases/tr import { UsageError } from "../../../../domain/errors/index.js"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import { TextFormatters } from "../render/index.js"; -import { exactlyOne, readBoundedTextFile } from "./artifact.js"; -import { txModeFields, tronTxModeFields } from "./shared.js"; +import { readBoundedTextFile } from "./artifact.js"; +import { tronExpirationRefine, txModeFields, tronTxModeFields, txModeExclusive } from "./shared.js"; const showFields = z.object({}); @@ -38,8 +38,6 @@ const updateFields = z.object({ .default(false) .describe("build and sign, then output complete transaction hex"), buildOnly: txModeFields.buildOnly, - // dry-run/sign-only wording is specific to a permission replacement, but the permission group - // and expiration semantics are the shared ones — reuse them rather than keep a second copy. permissionId: tronTxModeFields.permissionId, expiration: tronTxModeFields.expiration, }); @@ -58,30 +56,8 @@ export const permissionUpdateSpec: ChainSpec = { "There is no confirmation prompt: it warns about a permanent lockout but does not block the\n" + "submission, so rehearse with --dry-run first.", baseFields: updateFields, - exclusive: [{ label: "the new permission structure", flags: ["file", "json"] }], - baseRefine: (input, context) => { - if ([input.file, input.json].filter((value) => value !== undefined).length !== 1) { - context.addIssue({ - code: "custom", - path: ["file"], - message: "provide exactly one of --file or --json", - }); - } - if ([input.dryRun, input.signOnly, input.buildOnly].filter(Boolean).length > 1) { - context.addIssue({ - code: "custom", - path: ["dryRun"], - message: "choose at most one of --dry-run, --sign-only, --build-only", - }); - } - if (input.expiration !== undefined && !input.signOnly && !input.buildOnly) { - context.addIssue({ - code: "custom", - path: ["expiration"], - message: "--expiration is only valid with --sign-only or --build-only", - }); - } - }, + exclusive: [{ label: "the new permission structure", flags: ["file", "json"] }, txModeExclusive], + baseRefine: tronExpirationRefine, examples: [ { cmd: "wallet-cli permission update --file permissions.json --dry-run" }, { cmd: "wallet-cli permission update --file permissions.json --wait --password-stdin" }, @@ -90,12 +66,11 @@ export const permissionUpdateSpec: ChainSpec = { }; export const permissionShowTronBinding = (service: TronPermissionService): FamilyBinding => ({ - run: async (ctx, network) => service.show(network, ctx.resolveAddress("tron")), + run: async (ctx, network) => service.show(ctx.deadline, network, ctx.resolveAddress("tron")), }); export const permissionUpdateTronBinding = (service: TronPermissionService): FamilyBinding => ({ run: async (ctx, network, input) => { - exactlyOne([input.file, input.json], "provide exactly one of --file or --json"); const raw = input.file ? readBoundedTextFile(input.file, 1024 * 1024, "permission JSON file") : input.json; diff --git a/ts/src/adapters/inbound/cli/commands/proposal.ts b/ts/src/adapters/inbound/cli/commands/proposal.ts index 07b49e097..549416e6b 100644 --- a/ts/src/adapters/inbound/cli/commands/proposal.ts +++ b/ts/src/adapters/inbound/cli/commands/proposal.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronProposalService } from "../../../../application/use-cases/tron/proposal-service.js"; import { ciEnum } from "../arity/index.js"; -import { governanceTxModeFields, governanceTxRefine } from "./shared.js"; +import { governanceTxModeFields, tronExpirationRefine, txModeExclusive } from "./shared.js"; import { TextFormatters } from "../render/index.js"; export const proposalListSpec: ChainSpec = { @@ -32,7 +32,7 @@ export const proposalListSpec: ChainSpec = { }; export const proposalListTronBinding = (service: TronProposalService): FamilyBinding => ({ - run: async (_ctx, net, input) => service.list(net, input), + run: async (ctx, net, input) => service.list(ctx.deadline, net, input), }); export const proposalShowSpec: ChainSpec = { @@ -59,16 +59,17 @@ export const proposalShowSpec: ChainSpec = { }; export const proposalShowTronBinding = (service: TronProposalService): FamilyBinding => ({ - run: async (_ctx, net, input) => service.show(net, input.id), + run: async (ctx, net, input) => service.show(ctx.deadline, net, input.id), }); const proposalWriteBase = { network: "optional" as const, wallet: "optional" as const, - auth: "required" as const, + auth: "conditional" as const, broadcasts: true, capability: "proposal.write", - baseRefine: governanceTxRefine, + exclusive: [txModeExclusive], + baseRefine: tronExpirationRefine, formatText: TextFormatters.governanceReceipt, }; diff --git a/ts/src/adapters/inbound/cli/commands/r4-validation.test.ts b/ts/src/adapters/inbound/cli/commands/r4-validation.test.ts index 8d0281ba4..502ca2013 100644 --- a/ts/src/adapters/inbound/cli/commands/r4-validation.test.ts +++ b/ts/src/adapters/inbound/cli/commands/r4-validation.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect } from "vitest"; import { CommandRegistry } from "../registry/index.js"; import { registerX402Commands } from "./x402.js"; -import { parseInputSchema } from "../shell/index.js"; +import { neutralInputSchema, parseInputSchema } from "../shell/index.js"; const registry = new CommandRegistry(); registerX402Commands(registry, {} as never); function parse(verb: string, values: Record) { - return parseInputSchema(registry.resolveNeutral(["x402", verb])!.input, values); + return parseInputSchema(neutralInputSchema(registry.resolveNeutral(["x402", verb])!), values); } describe("R4 payment input contracts", () => { it.each([ diff --git a/ts/src/adapters/inbound/cli/commands/reward.ts b/ts/src/adapters/inbound/cli/commands/reward.ts index 9138ecb6a..49558fabd 100644 --- a/ts/src/adapters/inbound/cli/commands/reward.ts +++ b/ts/src/adapters/inbound/cli/commands/reward.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronRewardService } from "../../../../application/use-cases/tron/reward-service.js"; -import { txModeFields, tronTxModeFields } from "./shared.js"; +import { txModeFields, tronTxModeFields, txModeExclusive } from "./shared.js"; import { TextFormatters } from "../render/index.js"; export const rewardBalanceSpec: ChainSpec = { @@ -35,6 +35,7 @@ export const rewardWithdrawSpec: ChainSpec = { description: "Withdraw accrued voting/block rewards into your available balance.\n" + "Rewards can be withdrawn at most once every 24 hours.", + exclusive: [txModeExclusive], baseFields: z.object({ ...txModeFields, ...tronTxModeFields }), examples: [{ cmd: "wallet-cli reward withdraw" }, { cmd: "wallet-cli reward withdraw --wait" }], formatText: TextFormatters.txReceipt, diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts index 09fa15e13..00eefa180 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -1,43 +1,34 @@ -/** - * Shared chain-command factories — only for commands whose intent and input shape are - * identical across families. Divergent commands (for example send-native, - * with chain-specific amount units + build/estimate) live explicitly in each chain module. - */ import { z } from "zod"; -import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { ChainSpec, ExclusiveGroup, FamilyBinding } from "../contracts/index.js"; import { Schemas } from "../schemas/index.js"; import { TextFormatters } from "../render/index.js"; import type { MessageService } from "../../../../application/use-cases/message-service.js"; +import { isZeroAmount } from "../../../../domain/amounts/index.js"; +import { + MAX_PERMISSION_ID, + OWNER_PERMISSION_ID, +} from "../../../../domain/tron/permission/index.js"; +import { MAX_TRON_EXPIRATION_MS } from "../../../../domain/transaction/index.js"; -/** Help line for SunSwap/SunPump write commands: the TRON app settings a Ledger account needs. - * Approvals and other contract calls need "Custom contracts"; Permit2 grants and transactions - * too large for the device need "Sign by Hash". The error names the missing one at signing time. */ export const LEDGER_TRON_SETTINGS_NOTE = 'Ledger: enable "Custom contracts" and "Sign by Hash" in the TRON app settings.\n'; -// ── execution-mode flags shared by every signing command ───────────────────────── -/** Transaction execution fields; default (no mode flag) = sign and broadcast on-chain. */ -/** TRON multi-signature concepts: a permission group to sign under, and a longer expiry while - * signatures are collected. Neither exists on a single-signature chain, so they belong to the - * TRON binding rather than to every family's flag set. */ export const tronTxModeFields = { permissionId: z.coerce .number() .int() - .min(0) - .max(9) - .default(0) + .min(OWNER_PERMISSION_ID) + .max(MAX_PERMISSION_ID) + .default(OWNER_PERMISSION_ID) .describe("TRON permission group to sign with (0=owner, 1=witness, 2-9=active)"), - // The 24h bound is the chain's, enforced by max() above; the omitted case is the node's own - // ~60s, which is why extending it is the whole point of this flag when collecting signatures. expiration: z.coerce .number() .int() .min(1) - .max(86_400_000) + .max(MAX_TRON_EXPIRATION_MS) .optional() .describe( - "transaction expiration in ms, up to 86400000 (24h); only with --sign-only or --build-only; omitted = node default (~60s)", + `transaction expiration in ms, up to ${MAX_TRON_EXPIRATION_MS} (24h); only with --sign-only or --build-only; omitted = node default (~60s), at least 10 minutes when signed on a Ledger`, ), }; @@ -50,8 +41,6 @@ export const txModeFields = { .boolean() .default(false) .describe("sign and output complete transaction hex without broadcasting"), - // Both multi-sig routes start from this artifact: the hex relay (`tx sign --file --out`) and the - // TronLink queue (`tx multisig --create`). Naming only one would read as "service path only". buildOnly: z .boolean() .default(false) @@ -60,28 +49,31 @@ export const txModeFields = { ), }; -/** Full transaction controls required by governance/administrative writes. */ +export const txModeExclusive: ExclusiveGroup = { + label: "the transaction mode; omit all for sign and broadcast", + flags: ["dry-run", "sign-only", "build-only"], + select: "at-most-one", +}; + +// For commands without --sign-only. +export const unsignedTxModeExclusive: ExclusiveGroup = { + ...txModeExclusive, + flags: ["dry-run", "build-only"], +}; + export const governanceTxModeFields = { ...txModeFields, ...tronTxModeFields, buildOnly: z .boolean() .default(false) - .describe( - "build an unsigned transaction without signing or broadcasting; mutually exclusive with --dry-run/--sign-only", - ), + .describe("build an unsigned transaction without signing or broadcasting"), }; -export function governanceTxRefine( - value: { dryRun?: boolean; signOnly?: boolean; buildOnly?: boolean; expiration?: number }, +export function tronExpirationRefine( + value: { signOnly?: boolean; buildOnly?: boolean; expiration?: number }, ctx: z.RefinementCtx, ): void { - if ([value.dryRun, value.signOnly, value.buildOnly].filter(Boolean).length > 1) { - ctx.addIssue({ - code: "custom", - message: "choose at most one of --dry-run, --sign-only, --build-only", - }); - } if (value.expiration !== undefined && !value.signOnly && !value.buildOnly) { ctx.addIssue({ code: "custom", @@ -90,16 +82,12 @@ export function governanceTxRefine( }); } } -// ── unified --amount / --raw-amount selector (shared by every chain's `tx send`) ──── -// A transfer of 0 is meaningless on any chain — reject it here (exit 2) rather than let the node -// reject it with an opaque error. regex-based zero check (never BigInt): zod v4 keeps running -// refinements after the regex fails, so a throwing check would escape safeParse. +// Use regex for zero checks: Zod runs refinements even after the format check fails. const positiveDecimalAmount = z .string() .regex(/^\d+(\.\d+)?$/, "must be a non-negative decimal string") - .refine((v) => !/^0+(\.0+)?$/.test(v), { message: "must be greater than zero" }); + .refine((v) => !isZeroAmount(v), { message: "must be greater than zero" }); -/** the `--amount`/`--raw-amount` field pair; descriptions vary per chain (units differ). */ export function unifiedAmountFields(amountDesc: string, rawDesc: string) { return { amount: positiveDecimalAmount.optional().describe(amountDesc), @@ -107,21 +95,6 @@ export function unifiedAmountFields(amountDesc: string, rawDesc: string) { }; } -/** superRefine: exactly one of --amount or --raw-amount must be present. */ -export function amountSelector( - v: { amount?: string; rawAmount?: string }, - ctx: z.RefinementCtx, -): void { - const n = [v.amount !== undefined, v.rawAmount !== undefined].filter(Boolean).length; - if (n !== 1) - ctx.addIssue({ - code: "custom", - path: ["amount"], - message: "provide exactly one of --amount or --raw-amount", - params: { errorCode: "invalid_option" }, - }); -} - const messageSignFields = z.object({ message: z.string().min(1).optional().describe("message text to sign"), }); @@ -134,13 +107,10 @@ export const messageSignSpec: ChainSpec = { auth: "required", capability: "message.sign", summary: "Sign an arbitrary message (TIP-191/V2 · EIP-191)", - // The summary can only put the two standard names side by side; which one applies is what the - // caller actually needs, so the leaf page spells the mapping out. description: "Sign an arbitrary message. The prefix follows the selected network's family:\n" + "TIP-191/V2 for TRON, EIP-191 for EVM.", baseFields: messageSignFields, - // SecretResolver.pick enforces this: both sources → invalid_option, neither → missing_option. exclusive: [ { label: "the message to sign", flags: ["message", "message-stdin"], select: "exactly-one" }, ], @@ -154,6 +124,6 @@ export const messageSignSpec: ChainSpec = { export const messageSignBinding = (service: MessageService): FamilyBinding => ({ run: async (ctx, net, input) => { const message = ctx.secrets.pick(input.message, "message", "message"); - return service.sign(ctx, net.family, ctx.activeAccount, message); + return service.sign(ctx, net, ctx.activeAccount, message); }, }); diff --git a/ts/src/adapters/inbound/cli/commands/stake.ts b/ts/src/adapters/inbound/cli/commands/stake.ts index 484c4beff..5cd02aad3 100644 --- a/ts/src/adapters/inbound/cli/commands/stake.ts +++ b/ts/src/adapters/inbound/cli/commands/stake.ts @@ -2,10 +2,10 @@ import { z } from "zod"; import type { NetworkDescriptor } from "../../../../domain/types/index.js"; import type { ChainSpec, ExecutionContext, FamilyBinding } from "../contracts/index.js"; import type { TronStakeService } from "../../../../application/use-cases/tron/stake-service.js"; -import { RESOURCES } from "../../../../domain/resources/index.js"; +import { RESOURCES } from "../../../../domain/tron/resources/index.js"; import { Schemas } from "../schemas/index.js"; import { ciEnum } from "../arity/index.js"; -import { txModeFields, tronTxModeFields } from "./shared.js"; +import { txModeExclusive, txModeFields, tronTxModeFields } from "./shared.js"; import { TextFormatters } from "../render/index.js"; const resourceField = (description: string) => @@ -42,6 +42,7 @@ function stakeCommand( summary, requires: options.requires, baseFields: fields, + exclusive: [txModeExclusive], baseRefine: options.refine, examples: [{ cmd: `wallet-cli stake ${action}` }], formatText: TextFormatters.txReceipt, @@ -81,8 +82,7 @@ export function stakeDefinitions( (context, network, input) => service.cancelUnfreeze(context, network, input), {}, { - // The Ledger TRON app firmware rejects CancelAllUnfreezeV2Contract (APDU 0x6a80), - // even with blind-signing enabled; software accounts sign it fine. + // Ledger rejects this contract type even with blind signing enabled. requires: [ "a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type", ], diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/buy.ts b/ts/src/adapters/inbound/cli/commands/sunpump/buy.ts index 6117869fa..2798899d5 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/buy.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/buy.ts @@ -1,10 +1,11 @@ import { z } from "zod"; import { addressFieldsFor, allRefines, Schemas, slippageField } from "../../schemas/index.js"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunPumpCurveTradeService } from "../../../../../application/use-cases/tron/sunpump/curve-trade-service.js"; +import type { SunPumpCurveTradeService } from "../../../../../application/use-cases/sunpump/curve-trade-service.js"; import { TextFormatters } from "../../render/index.js"; -import { LEDGER_TRON_SETTINGS_NOTE } from "../shared.js"; +import { LEDGER_TRON_SETTINGS_NOTE, unsignedTxModeExclusive } from "../shared.js"; import { CURVE_TRADE_NOTES, curveTradeFields, refuseQuoteWithSendingFlags } from "./shared.js"; +import { sunAmount } from "../../../../../domain/amounts/index.js"; const fields = z.object({ token: Schemas.address().describe("the SunPump token's contract address"), @@ -15,14 +16,10 @@ const fields = z.object({ export const sunpumpBuySpec: ChainSpec = { path: ["sunpump", "buy"], network: "optional", - // `none` because `--quote` must work with no account at all, and the framework - // resolves the active account up front for anything else. Every mode that signs resolves it in - // the use case instead and fails with the same `missing_wallet_address` when there is none — - // later, but identically. + // Quotes need no account; signing modes resolve it in the use case. wallet: "none", auth: "conditional", broadcasts: true, - // A quote sends nothing, so there is no confirmation for --wait or --wait-timeout to wait on. rejectsWaitWith: "quote", capability: "sunpump.curve", summary: "Buy a token on the SunPump bonding curve", @@ -33,13 +30,11 @@ export const sunpumpBuySpec: ChainSpec = { "--trx is the total: the platform fee is taken out of it, not added to it.\n\n" + CURVE_TRADE_NOTES + "\n\nAn account is needed for every mode except --quote, which reads only.", + exclusive: [unsignedTxModeExclusive], baseFields: fields, - // A malformed address is `invalid_address` at exit 2, refused here rather than carried to a - // node that answers with a base58 complaint at exit 1. baseRefine: allRefines( addressFieldsFor("tron", "token"), refuseQuoteWithSendingFlags, - // A tolerance is pure input, so it is checked here rather than after a launchpad read. slippageField(), ), positionals: [{ field: "token", placeholder: "address" }], @@ -64,6 +59,6 @@ export const sunpumpBuyTronBinding = (service: SunPumpCurveTradeService): Family ...(input.quote === undefined ? {} : { quote: input.quote }), ...(input.dryRun === undefined ? {} : { dryRun: input.dryRun }), ...(input.buildOnly === undefined ? {} : { buildOnly: input.buildOnly }), - ...(input.feeLimit === undefined ? {} : { feeLimit: input.feeLimit }), + ...(input.feeLimit === undefined ? {} : { feeLimit: sunAmount(input.feeLimit) }), }), }); diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/launch.test.ts b/ts/src/adapters/inbound/cli/commands/sunpump/launch.test.ts index 5f56ae524..6c8a1f7e8 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/launch.test.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/launch.test.ts @@ -1,17 +1,9 @@ -/** - * Everything `sunpump launch` refuses before a byte is sent, and the declarations that keep it out - * of the transaction machinery. - * - * Every refusal here depends on nothing remote, so it belongs to the schema. The reason is this - * service's defining behaviour: it does not reject what it does not understand, it stores it. A - * link that is not a URL is therefore not an error but a permanent token with a broken link, and a - * refusal that is not made here is never made at all. - */ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import type { z } from "zod"; import { sunpumpLaunchSpec, sunpumpLaunchTronBinding } from "./launch.js"; -import type { SunPumpTokenLaunchService } from "../../../../../application/use-cases/tron/sunpump/token-launch-service.js"; +import { chainInputSchema } from "../../shell/index.js"; +import type { SunPumpTokenLaunchService } from "../../../../../application/use-cases/sunpump/token-launch-service.js"; import type { NetworkDescriptor } from "../../../../../domain/types/index.js"; import type { ExecutionContext } from "../../contracts/execution-context.js"; @@ -21,11 +13,10 @@ interface SafeParse { error?: { issues: (z.core.$ZodIssue & { params?: { errorCode?: string } })[] }; } -const schema = ( - sunpumpLaunchSpec.baseFields as unknown as { - superRefine: (refine: unknown) => { safeParse: (value: unknown) => SafeParse }; - } -).superRefine(sunpumpLaunchSpec.baseRefine as unknown); +const schema = chainInputSchema( + sunpumpLaunchSpec, + sunpumpLaunchTronBinding({} as SunPumpTokenLaunchService), +).input as unknown as { safeParse: (value: unknown) => SafeParse }; function refusals(value: Record): { path: string; code?: string }[] { return (schema.safeParse(value).error?.issues ?? []).map((issue) => ({ @@ -43,20 +34,12 @@ describe("the required options", () => { expect(parsed.data).toMatchObject({ ...BASE, dryRun: false }); }); - // Absent → `missing_option` (exit 2), which the shell derives from the field being required and - // missing from argv. The schema's job is only to declare it required. it.each(["name", "symbol", "description"])("requires --%s", (field) => { const value = { ...BASE } as Record; delete value[field]; expect(refusals(value)).toEqual([{ path: field, code: undefined }]); }); - /** - * No local rule on the name or the symbol. - * - * Their limits are the service's and are not published. A local guess would refuse names SunPump - * accepts; a rejection comes back as `provider_error` carrying the service's own message. - */ it("leaves the name and symbol rules to the service", () => { expect( schema.safeParse({ ...BASE, name: "a".repeat(300), symbol: "lower case $$" }).success, @@ -79,10 +62,9 @@ describe("the optional options", () => { }, ); - // Two logos, one token: whichever won would be a coin toss the caller did not know they made. it("refuses --image and --image-base64 together as invalid_option", () => { expect(refusals({ ...BASE, image: "./logo.png", imageBase64: "QQ==" })).toEqual([ - { path: "imageBase64", code: "invalid_option" }, + { path: "image", code: "invalid_option" }, ]); expect(schema.safeParse({ ...BASE, image: "./logo.png" }).success).toBe(true); expect(schema.safeParse({ ...BASE, imageBase64: "QQ==" }).success).toBe(true); @@ -93,14 +75,6 @@ describe("the optional options", () => { }); }); -/** - * The declarations, checked because each one of them is load-bearing. - * - * Nothing about this command signs, broadcasts or belongs to an account, and each of those facts is - * carried by one field. `broadcasts` would hand it `--wait` and a three-stage receipt it has no - * transaction for; `auth` would demand a master password to run a remote create; `rejectsAccount` is - * what makes `--account` an error instead of a silently ignored flag that implies ownership. - */ describe("what the command declares", () => { it("needs no account, no password and no broadcast", () => { expect(sunpumpLaunchSpec.wallet).toBe("none"); @@ -124,7 +98,6 @@ describe("what the command declares", () => { expect(flags).not.toContain("feeLimit"); }); - // The SDK's `tweetUsername` is deliberately not exposed as a flag. it("offers exactly the documented flags", () => { expect(Object.keys(sunpumpLaunchSpec.baseFields.shape).sort()).toEqual([ "description", @@ -154,7 +127,7 @@ describe("reading the logo file", () => { function bindingCapturing() { const calls: Record[] = []; const service = { - launch: async (_n: NetworkDescriptor, input: Record) => { + launch: async (_d: unknown, _n: NetworkDescriptor, input: Record) => { calls.push(input); return { kind: "sunpump-launch", mode: "dry-run" }; }, @@ -162,8 +135,6 @@ describe("reading the logo file", () => { return { calls, binding: sunpumpLaunchTronBinding(service) }; } - // A preview whose file does not exist has validated nothing, so the file is read even for a dry - // run — and a path that is not there is `file_not_found`, not a provider failure. it("reads --image during a dry run, and reports a missing file as file_not_found", async () => { const { calls, binding } = bindingCapturing(); await expect( diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/launch.ts b/ts/src/adapters/inbound/cli/commands/sunpump/launch.ts index 6b89315b9..eb49fd189 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/launch.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/launch.ts @@ -1,42 +1,21 @@ -/** - * `sunpump launch` — create a token on the SunPump launchpad. - * - * Unlike every other write in this group, the SERVICE creates the token: nothing is signed here, - * nothing is broadcast, no fee is paid locally, and the request body carries no address at all. So - * this command needs no account and no unlock, has no `--wait`, no `--build-only` and no three-stage - * receipt — and it REFUSES `--account`, because the new token's creator is chosen by SunPump and is - * not the local account. - * - * Everything refused below is refused before anything is sent. It has to be: this service ignores - * what it does not understand and accepts the rest, so a link it cannot use is not an error, it is - * a token created with a broken link. - */ import { z } from "zod"; import type { RefinementCtx } from "zod"; import { readFile } from "node:fs/promises"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunPumpTokenLaunchService } from "../../../../../application/use-cases/tron/sunpump/token-launch-service.js"; -import type { SunPumpLaunchImage } from "../../../../../application/use-cases/tron/sunpump/token-launch-service.js"; +import type { + SunPumpLaunchImage, + SunPumpTokenLaunchService, +} from "../../../../../application/use-cases/sunpump/token-launch-service.js"; import { errorMessage, UsageError } from "../../../../../domain/errors/index.js"; import { TextFormatters } from "../../render/index.js"; -/** - * The name, symbol and description carry NO local rules. - * - * Their limits are the service's — length, character set, whether a symbol is already taken — and - * they are not published anywhere we can read. A local guess would refuse a name SunPump accepts, - * or accept one it rejects, and the second is honest while the first is a lie. A rejection comes - * back as `provider_error` with the service's own message. - */ +// SunPump validates name, symbol, and description constraints. const fields = z.object({ name: z.string().describe("token name"), symbol: z.string().describe("token symbol"), description: z.string().describe("token description"), image: z.string().optional().describe("path to a logo image file, sent as base64"), - imageBase64: z - .string() - .optional() - .describe("the logo as a base64 string; mutually exclusive with --image"), + imageBase64: z.string().optional().describe("the logo as a base64 string"), twitterUrl: z.string().optional().describe("Twitter URL"), telegramUrl: z.string().optional().describe("Telegram URL"), websiteUrl: z.string().optional().describe("website URL"), @@ -45,23 +24,7 @@ const fields = z.object({ const URL_FIELDS = ["twitterUrl", "telegramUrl", "websiteUrl"] as const; -/** - * Two refusals, both knowable with no network at all. - * - * A link that is not `http://` or `https://` is refused rather than sent: the service stores what - * it is given, so `example.com` becomes a token whose website link goes nowhere and cannot be - * changed afterwards. Two image sources are refused rather than one silently winning — the caller - * who gave both does not know which logo their token would carry. - */ function refuseUnusableOptions(value: Record, ctx: RefinementCtx): void { - if (value.image !== undefined && value.imageBase64 !== undefined) { - ctx.addIssue({ - code: "custom", - path: ["imageBase64"], - message: "cannot be given with --image; they are two ways of supplying one logo", - params: { errorCode: "invalid_option" }, - }); - } for (const field of URL_FIELDS) { const url = value[field]; if (typeof url === "string" && !/^https?:\/\//.test(url)) { @@ -77,8 +40,6 @@ function refuseUnusableOptions(value: Record, ctx: RefinementCt export const sunpumpLaunchSpec: ChainSpec = { path: ["sunpump", "launch"], network: "optional", - // Nothing local signs, and there is no owner to resolve: the token's creator comes back from the - // service. Accepting `--account` would imply the token is that account's, so it is refused. wallet: "none", auth: "none", rejectsAccount: @@ -97,6 +58,7 @@ export const sunpumpLaunchSpec: ChainSpec = { "that the service would accept.\n\n" + "--dry-run sends nothing to the create endpoint: it validates the options, reads the logo file,\n" + "and prints the request that would be made.", + exclusive: [{ label: "the logo", flags: ["image", "image-base64"], select: "at-most-one" }], baseFields: fields, baseRefine: refuseUnusableOptions, examples: [ @@ -112,8 +74,8 @@ export const sunpumpLaunchSpec: ChainSpec = { }; export const sunpumpLaunchTronBinding = (service: SunPumpTokenLaunchService): FamilyBinding => ({ - run: async (_ctx, net, input) => - service.launch(net, { + run: async (ctx, net, input) => + service.launch(ctx.deadline, net, { name: input.name, symbol: input.symbol, description: input.description, @@ -125,13 +87,7 @@ export const sunpumpLaunchTronBinding = (service: SunPumpTokenLaunchService): Fa }), }); -/** - * The logo, read here because reading a path is this layer's job. - * - * `--image` is read even for a dry run — a preview whose file does not exist has validated nothing - * — and the bytes are base64-encoded as they are, with no `data:` prefix and no re-encoding: what - * the file holds is what the token's logo will be. - */ +// Read files even for dry runs; encode their bytes as base64 without a data: prefix. async function imageOf(input: { image?: string; imageBase64?: string; diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/market-shared.test.ts b/ts/src/adapters/inbound/cli/commands/sunpump/market-shared.test.ts index efba153c9..4c18f6d3f 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/market-shared.test.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/market-shared.test.ts @@ -1,12 +1,3 @@ -/** - * The refusals the three SunPump catalogue commands make before sending anything. - * - * All of them depend on nothing remote, so they belong to the schema — and each of them guards - * against the same failure mode: this service answers a bad request with HTTP 200 and a plausible - * list, so a value that is not caught here is never caught at all. - * - * Exercised through each spec's own fields and refine, which is what the shell composes. - */ import { describe, expect, it } from "vitest"; import type { z } from "zod"; import type { ChainSpec } from "../../contracts/command.js"; @@ -61,8 +52,6 @@ describe("paging", () => { expect(schema.safeParse({ ...base, limit: 50 }).success).toBe(true); }); - // The service pages by page number and size: offset 5 with limit 4 names rows 5..8, which no - // page contains. Rounding to the nearest page would return rows nobody asked for. it("refuses an offset that is not a whole number of pages", () => { expect(refusals(list, { limit: 20, offset: 5 })).toEqual([{ path: "offset", code: undefined }]); expect(list.safeParse({ limit: 20, offset: 40 }).success).toBe(true); @@ -76,8 +65,6 @@ describe("paging", () => { }); describe("ordering", () => { - // Every name here was verified to actually sort the service's rows. `launched` was not: the - // service answers it with marketCap order and a 200, so it is not on the list. it.each(["created", "market-cap", "volume-24h", "price-change-24h"])( "accepts --order-by %s", (orderBy) => { @@ -96,11 +83,6 @@ describe("ordering", () => { expect(list.safeParse({ sort: "asc" }).success).toBe(true); }); - /** - * `--owner` answers from `/token/search/by_owner`, which drops `sort` rather than rejecting it - * and always returns newest-created first. Accepting the flags would print a creation-ordered - * list under a market-cap heading. - */ it("refuses an ordering that cannot be applied to a creator's tokens", () => { expect(refusals(list, { owner: CREATOR, orderBy: "market-cap" })).toEqual([ { path: "orderBy", code: "invalid_option" }, @@ -108,7 +90,6 @@ describe("ordering", () => { expect(refusals(list, { owner: CREATOR, sort: "asc" })).toEqual([ { path: "sort", code: "invalid_option" }, ]); - // the one ordering it does apply is the command's own default, so --owner alone is fine expect(list.safeParse({ owner: CREATOR }).success).toBe(true); }); }); @@ -127,8 +108,6 @@ describe("addresses and keywords", () => { expect(info.safeParse({ address: CREATOR }).success).toBe(true); }); - // A blank keyword is "no filter" to this service: it answers with the entire ranked catalogue - // at HTTP 200, which reads exactly like a search that matched everything. it("refuses a keyword that is empty or only whitespace", () => { for (const keyword of ["", " ", "\t\n"]) { expect(search.safeParse({ keyword }).success).toBe(false); diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/market-shared.ts b/ts/src/adapters/inbound/cli/commands/sunpump/market-shared.ts index b4a342828..7aa31c2cf 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/market-shared.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/market-shared.ts @@ -1,15 +1,3 @@ -/** - * Zod fragments shared by the SunPump listing commands. - * - * Kept in one place so the paging and ordering flags cannot drift apart between `token-list` and - * `token-search`: an agent that learned `--limit` on one listing should not find a different - * ceiling on the next. - * - * Everything here is refused at PARSE time, because none of it depends on the service. A page - * that is too large, an offset that no page boundary contains, an ordering the service does not - * apply and an empty keyword are all knowable before a request is sent — and each of them is a - * failure this particular service would answer with a confident 200 rather than an error. - */ import { z } from "zod"; import type { RefinementCtx } from "zod"; import { @@ -17,12 +5,6 @@ import { SUNPUMP_SORT_DIRECTIONS, } from "../../../../../domain/sunpump/market.js"; -/** - * The largest page this CLI asks for. - * - * A ceiling of our own: the service will serve a page of 200, but a listing that large is not a - * CLI answer, so the limit is 50 for both commands. - */ export const MAX_PAGE_SIZE = 50; export const ORDER_BY_LIST = SUNPUMP_ORDER_BY_NAMES.join(", "); @@ -41,23 +23,11 @@ export const sortField = z .default("desc") .describe(`sort direction: ${SORT_LIST}`); -/** - * The orderings the service really applies. - * - * `launched` is absent on purpose. It is a real field on every token and the service answers an - * ordering by it with a 200 — having quietly sorted by market cap instead, while echoing the - * field name back. It cannot be served, so it is not offered. - */ +// Do not offer launched ordering: the service silently substitutes market-cap ordering. export const orderByField = (fallback: (typeof SUNPUMP_ORDER_BY_NAMES)[number]) => z.enum(SUNPUMP_ORDER_BY_NAMES).default(fallback).describe(`order by: ${ORDER_BY_LIST}`); -/** - * `--limit` and `--offset`, checked together. - * - * The offset has to be a whole number of pages: the service pages by page number and size, so - * `offset 5, limit 4` names rows 5..8, which no page contains. Rounding to the nearest page - * would hand back rows the caller did not ask for while looking like success. - */ +// Offsets must align to whole pages because the service accepts page number and size. export function pageWindowRefine(value: { limit: number; offset: number }, ctx: RefinementCtx) { const { limit, offset } = value; if (!Number.isInteger(limit) || limit <= 0) { diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/sell.ts b/ts/src/adapters/inbound/cli/commands/sunpump/sell.ts index e943ff27b..757ddd1ef 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/sell.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/sell.ts @@ -1,10 +1,11 @@ import { z } from "zod"; import { addressFieldsFor, allRefines, Schemas, slippageField } from "../../schemas/index.js"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunPumpCurveTradeService } from "../../../../../application/use-cases/tron/sunpump/curve-trade-service.js"; +import type { SunPumpCurveTradeService } from "../../../../../application/use-cases/sunpump/curve-trade-service.js"; import { TextFormatters } from "../../render/index.js"; -import { LEDGER_TRON_SETTINGS_NOTE } from "../shared.js"; +import { LEDGER_TRON_SETTINGS_NOTE, unsignedTxModeExclusive } from "../shared.js"; import { CURVE_TRADE_NOTES, curveTradeFields, refuseQuoteWithSendingFlags } from "./shared.js"; +import { sunAmount } from "../../../../../domain/amounts/index.js"; const fields = z.object({ token: Schemas.address().describe("the SunPump token's contract address"), @@ -15,14 +16,10 @@ const fields = z.object({ export const sunpumpSellSpec: ChainSpec = { path: ["sunpump", "sell"], network: "optional", - // `none` because `--quote` must work with no account at all, and the framework - // resolves the active account up front for anything else. Every mode that signs resolves it in - // the use case instead and fails with the same `missing_wallet_address` when there is none — - // later, but identically. + // Quotes need no account; signing modes resolve it in the use case. wallet: "none", auth: "conditional", broadcasts: true, - // A quote sends nothing, so there is no confirmation for --wait or --wait-timeout to wait on. rejectsWaitWith: "quote", capability: "sunpump.curve", summary: "Sell a token on the SunPump bonding curve", @@ -36,13 +33,11 @@ export const sunpumpSellSpec: ChainSpec = { "The platform fee comes out of the proceeds, so the minimum is applied to what you receive.\n\n" + CURVE_TRADE_NOTES + "\n\nAn account is needed for every mode except --quote, which reads only.", + exclusive: [unsignedTxModeExclusive], baseFields: fields, - // A malformed address is `invalid_address` at exit 2, refused here rather than carried to a - // node that answers with a base58 complaint at exit 1. baseRefine: allRefines( addressFieldsFor("tron", "token"), refuseQuoteWithSendingFlags, - // A tolerance is pure input, so it is checked here rather than after a launchpad read. slippageField(), ), positionals: [{ field: "token", placeholder: "address" }], @@ -67,6 +62,6 @@ export const sunpumpSellTronBinding = (service: SunPumpCurveTradeService): Famil ...(input.quote === undefined ? {} : { quote: input.quote }), ...(input.dryRun === undefined ? {} : { dryRun: input.dryRun }), ...(input.buildOnly === undefined ? {} : { buildOnly: input.buildOnly }), - ...(input.feeLimit === undefined ? {} : { feeLimit: input.feeLimit }), + ...(input.feeLimit === undefined ? {} : { feeLimit: sunAmount(input.feeLimit) }), }), }); diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/shared.test.ts b/ts/src/adapters/inbound/cli/commands/sunpump/shared.test.ts index a9a92e15b..ddb70c870 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/shared.test.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/shared.test.ts @@ -15,10 +15,6 @@ function issues(input: Record) { })); } -/** - * `--quote` sends no transaction, so it refuses every flag that describes one — the floor flags - * included, since a floor given to a quote would be accepted and then protect nothing. - */ describe("--quote", () => { it.each([ ["slippage", { slippage: "0.0001" }, "a tolerance"], diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/shared.ts b/ts/src/adapters/inbound/cli/commands/sunpump/shared.ts index e630261cb..a459ec200 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/shared.ts @@ -1,12 +1,7 @@ import { z, type RefinementCtx } from "zod"; import { Schemas } from "../../schemas/index.js"; +import { DEFAULT_FEE_LIMIT_SUN } from "../../../../../domain/fees/tron.js"; -/** - * The flags a curve trade shares in both directions. - * - * `--quote` is not a mode of the transaction pipeline: it answers from contract reads alone, with - * no account and no password, so it excludes every flag that implies a transaction. - */ export const curveTradeFields = { quote: z .boolean() @@ -29,31 +24,20 @@ export const curveTradeFields = { .optional() .describe("least to accept, in the smallest unit; excludes --slippage"), feeLimit: Schemas.positiveIntString() - .default("100000000") + .default(DEFAULT_FEE_LIMIT_SUN) .describe( "maximum energy fee to burn, in SUN; the dry run's estimate is a lower bound, so a limit set from it can fail", ), }; -/** Flags that describe a transaction, and so cannot travel with `--quote`. */ const SENDING_FLAGS = ["dryRun", "buildOnly"] as const; -/** Flags that set a floor, which a quote never enforces; each named as its refusal reads. */ const FLOOR_FLAGS = [ ["slippage", "a tolerance"], ["minOut", "a minimum"], ] as const; -/** - * `--quote` against the flags that describe a transaction, and the two floors against each other. - * - * A quote that also carried `--dry-run` would be two answers to one question, and a floor given to - * a quote would be accepted and then protect nothing: a caller would believe they had set one - * (the wording is `sunswap swap`'s). `--wait` and `--wait-timeout` are global flags the - * schema cannot see, so the spec's `rejectsWaitWith` refuses those. The pair of floor flags are - * two ways of saying the same thing, so a caller who gave both does not know which they are - * getting. Nothing silently wins. - */ +// Quotes enforce no floor; global wait flags are rejected by the command spec. export function refuseQuoteWithSendingFlags( value: Record, ctx: RefinementCtx, @@ -91,7 +75,6 @@ export function refuseQuoteWithSendingFlags( } } -/** The shared tail of both descriptions: the fee, the state gate, and what a quote costs. */ export const CURVE_TRADE_NOTES = "The platform fee is 1% of the TRX with a 0.01 TRX MINIMUM, taken out of the TRX on both\n" + "sides — so a small trade pays far more than 1%, and the dry run says what rate it worked\n" + diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/token-info.ts b/ts/src/adapters/inbound/cli/commands/sunpump/token-info.ts index 30548c23f..ac78f4f00 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/token-info.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/token-info.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { Schemas, addressFieldsFor } from "../../schemas/index.js"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunPumpMarketQueryService } from "../../../../../application/use-cases/tron/sunpump/market-query-service.js"; +import type { SunPumpMarketQueryService } from "../../../../../application/use-cases/sunpump/market-query-service.js"; import { TextFormatters } from "../../render/index.js"; const fields = z.object({ @@ -32,5 +32,5 @@ export const sunpumpTokenInfoSpec: ChainSpec = { }; export const sunpumpTokenInfoTronBinding = (service: SunPumpMarketQueryService): FamilyBinding => ({ - run: async (_ctx, net, input) => service.tokenInfo(net, input.address), + run: async (ctx, net, input) => service.tokenInfo(ctx.deadline, net, input.address), }); diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/token-list.ts b/ts/src/adapters/inbound/cli/commands/sunpump/token-list.ts index 147f38a9a..36600c86f 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/token-list.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/token-list.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { addressFieldsFor, allRefines } from "../../schemas/index.js"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; import type { RefinementCtx } from "zod"; -import type { SunPumpMarketQueryService } from "../../../../../application/use-cases/tron/sunpump/market-query-service.js"; +import type { SunPumpMarketQueryService } from "../../../../../application/use-cases/sunpump/market-query-service.js"; import { TextFormatters } from "../../render/index.js"; import { limitField, @@ -22,14 +22,7 @@ const fields = z.object({ offset: offsetField, }); -/** - * `--owner` answers from a different endpoint, and that endpoint does not order. - * - * `/token/search/by_owner` returns newest-created first whatever `sort` it is given — it does not - * reject the parameter, it drops it. Accepting `--order-by market-cap` alongside `--owner` would - * therefore print a creation-ordered list under a market-cap heading, so the combination is - * refused instead. Ordering a creator's tokens is not available; listing them is. - */ +// The owner endpoint ignores sort and always returns newest-created first. function refuseOrderingWithOwner(value: Record, ctx: RefinementCtx): void { if (value.owner === undefined) return; for (const flag of ["orderBy", "sort"] as const) { @@ -79,8 +72,8 @@ export const sunpumpTokenListSpec: ChainSpec = { }; export const sunpumpTokenListTronBinding = (service: SunPumpMarketQueryService): FamilyBinding => ({ - run: async (_ctx, net, input) => - service.tokenList(net, { + run: async (ctx, net, input) => + service.tokenList(ctx.deadline, net, { orderBy: input.orderBy, sort: input.sort, limit: input.limit, diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/token-search.ts b/ts/src/adapters/inbound/cli/commands/sunpump/token-search.ts index 25762ee85..c402396d3 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/token-search.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/token-search.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { allRefines } from "../../schemas/index.js"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunPumpMarketQueryService } from "../../../../../application/use-cases/tron/sunpump/market-query-service.js"; +import type { SunPumpMarketQueryService } from "../../../../../application/use-cases/sunpump/market-query-service.js"; import { TextFormatters } from "../../render/index.js"; import { limitField, @@ -13,13 +13,7 @@ import { } from "./market-shared.js"; const fields = z.object({ - /** - * Empty is refused here rather than sent. - * - * The service treats a missing or blank search term as "no filter" and answers with the whole - * catalogue, ranked, at HTTP 200 — which reads exactly like a successful search that happened - * to match everything. - */ + // A blank keyword returns the unfiltered catalogue. keyword: z .string() .refine((value) => value.trim() !== "", { message: "must not be empty" }) @@ -67,8 +61,8 @@ export const sunpumpTokenSearchSpec: ChainSpec = { export const sunpumpTokenSearchTronBinding = ( service: SunPumpMarketQueryService, ): FamilyBinding => ({ - run: async (_ctx, net, input) => - service.tokenSearch(net, { + run: async (ctx, net, input) => + service.tokenSearch(ctx.deadline, net, { keyword: input.keyword, orderBy: input.orderBy, sort: input.sort, diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/add-liquidity.test.ts b/ts/src/adapters/inbound/cli/commands/sunswap/add-liquidity.test.ts index 2cfa37590..dfd468506 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/add-liquidity.test.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/add-liquidity.test.ts @@ -1,15 +1,9 @@ -/** - * The V4 pool refusals keep the code the domain chose. - * - * Exercised through the same composition and parser the shell uses, because the code a caller sees - * is decided there: an issue's declared code survives only when it is a usage code, and an exit-1 - * code cannot ride an issue at all. - */ import { describe, expect, it } from "vitest"; import type { ZodObject, ZodRawShape } from "zod"; import { CliError } from "../../../../../domain/errors/index.js"; import { composeRefines, parseInputSchema } from "../../shell/index.js"; import { sunswapAddLiquiditySpec } from "./add-liquidity.js"; +import { exitCodeOf } from "../../output/errors.js"; const schema = composeRefines( sunswapAddLiquiditySpec.baseFields as ZodObject, @@ -30,7 +24,6 @@ function refusal(argv: Record): CliError { } describe("sunswap add-liquidity — V4 pool refusals", () => { - // --create-pool without --sqrt-price is missing_option, and the reverse is invalid_option. it("refuses --sqrt-price without --create-pool as invalid_option", () => { const error = refusal({ protocol: "V4", @@ -40,7 +33,7 @@ describe("sunswap add-liquidity — V4 pool refusals", () => { sqrtPrice: "79228162514264337593543950336", }); expect(error).toMatchObject({ code: "invalid_option" }); - expect(error.exitCode()).toBe(2); + expect(exitCodeOf(error)).toBe(2); expect(error.message).toMatch(/^invalid --sqrt-price: /); }); @@ -57,7 +50,6 @@ describe("sunswap add-liquidity — V4 pool refusals", () => { expect(error).toMatchObject({ code: "missing_option" }); }); - // Exit 1, like the same refusal on V2 and on every other command that raises `same_token`. it("refuses a pool whose two currencies are the same token as same_token, exit 1", () => { const error = refusal({ protocol: "V4", @@ -68,10 +60,9 @@ describe("sunswap add-liquidity — V4 pool refusals", () => { tickSpacing: 10, }); expect(error).toMatchObject({ code: "same_token" }); - expect(error.exitCode()).toBe(1); + expect(exitCodeOf(error)).toBe(1); }); - // An earlier refusal is still the one reported; the domain's check does not jump the queue. it("reports an earlier flag refusal before same_token", () => { const error = refusal({ protocol: "V4", @@ -87,13 +78,6 @@ describe("sunswap add-liquidity — V4 pool refusals", () => { }); }); -/** - * A malformed `--recipient` is the caller's typo, not our crash. - * - * Unchecked, it travelled to the ABI encoder and came back as `internal_error` ("Invalid checksum") - * at exit 1 — after the position had already been read. Refused here, it is `invalid_address` at - * exit 2 before anything remote is asked. An EVM address is malformed on TRON for the same reason. - */ describe("sunswap add-liquidity — a malformed --recipient", () => { it.each(["TNotAnAddress", "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"])( "refuses %s as invalid_address before any network call", @@ -106,7 +90,7 @@ describe("sunswap add-liquidity — a malformed --recipient", () => { recipient, }); expect(error).toMatchObject({ code: "invalid_address" }); - expect(error.exitCode()).toBe(2); + expect(exitCodeOf(error)).toBe(2); expect(error.message).toMatch(/^invalid --recipient: /); }, ); diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/add-liquidity.ts b/ts/src/adapters/inbound/cli/commands/sunswap/add-liquidity.ts index 40b0717ef..fa54cd613 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/add-liquidity.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/add-liquidity.ts @@ -1,18 +1,21 @@ import { z, type RefinementCtx } from "zod"; import { addressFieldsFor, allRefines, Schemas } from "../../schemas/index.js"; import { CliError } from "../../../../../domain/errors/index.js"; +import { cliMessage, exitCodeOf } from "../../output/errors.js"; import { resolveV4Pool } from "../../../../../domain/sunswap/v4-pool.js"; +import { + liquidityProtocolOf, + parseLiquidityProtocol, +} from "../../../../../domain/sunswap/protocol.js"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunSwapLiquidityService } from "../../../../../application/use-cases/tron/sunswap/liquidity-service.js"; +import type { SunSwapLiquidityService } from "../../../../../application/use-cases/sunswap/liquidity-service.js"; import { TextFormatters } from "../../render/index.js"; -import { LEDGER_TRON_SETTINGS_NOTE } from "../shared.js"; - -const PROTOCOLS = ["V2", "V3", "V4"] as const; +import { LEDGER_TRON_SETTINGS_NOTE, unsignedTxModeExclusive } from "../shared.js"; +import { sunAmount } from "../../../../../domain/amounts/index.js"; +import { DEFAULT_FEE_LIMIT_SUN } from "../../../../../domain/fees/tron.js"; const fields = z.object({ - // Deliberately NOT the shared txModeFields: that set includes --sign-only, which this group - // does not offer. Declaring a flag the command will not honour is worse than - // omitting it. + // Do not use txModeFields: this command does not support sign-only. dryRun: z .boolean() .default(false) @@ -26,12 +29,9 @@ const fields = z.object({ .string() .optional() .describe( - "one side of the pair, symbol or contract address; on V3, TRX is wrapped to WTRX (not for --position-id)", + "one side of the pair, symbol or contract address; on V3, TRX is wrapped to WTRX; with --position-id: refused on V3, required on V4", ), - token1: z - .string() - .optional() - .describe("the other side of the pair, same rules (not for --position-id)"), + token1: z.string().optional().describe("the other side of the pair, same rules"), positionId: z .string() .optional() @@ -78,11 +78,6 @@ const fields = z.object({ .number() .optional() .describe("Unix seconds the transaction stops being valid; default 30 minutes from now"), - // A TRON contract call cannot be built without a fee limit — the node refuses - // `triggerSmartContract` outright. Every other TRON write command in - // this CLI exposes it with the same default, and hard-coding a spend cap on a command that - // moves money is worse than an optional flag. The default is a constant and is never derived - // from an estimate: TRON's estimate is a lower bound (see the reference page). createPool: z .boolean() .default(false) @@ -114,43 +109,19 @@ const fields = z.object({ "tolerance on the deposit CEILING, e.g. 0.005; default none, so the ceiling is exactly the computed amounts (V4 only)", ), feeLimit: Schemas.positiveIntString() - .default("100000000") + .default(DEFAULT_FEE_LIMIT_SUN) .describe( "maximum energy fee to burn, in SUN; the dry run's estimate is a lower bound, so a limit set from it can fail", ), }); -/** Flags that belong only to a V3 position: rejected on V2, where there is no range to describe. */ const V3_ONLY = ["positionId", "fee", "tickLower", "tickUpper"] as const; -/** - * Flags that belong only to V4. - * - * A V4 pool's identity includes the tick spacing and the hook on top of the pair and the tier, which - * is why naming one — existing or new — takes more flags than a fee tier. - */ const V4_ONLY = ["createPool", "sqrtPrice", "tickSpacing", "hooks", "slippage"] as const; -/** - * Flags V4 does not have, because its bound points the other way. - * - * V2 and V3 bound a deposit from BELOW with `--min0` / `--min1`: at least this much must go in. V4 - * bounds it from ABOVE — the contract takes the liquidity and spends what it needs, up to a ceiling — - * so a minimum is not a weaker version of the same protection, it is the opposite one. V4 has no - * `--min0` / `--min1` and takes `--slippage` in their place. Silently ignoring a - * `--min0` here would leave a caller believing they had set a floor on a path that has none. - */ +// V4 bounds deposit costs from above; min0/min1 express floors and cannot apply. const NOT_ON_V4 = ["min0", "min1"] as const; -/** - * Flags a V4 INCREASE cannot take, because the position already fixes them. - * - * Not the same list as V3's: `--token0` / `--token1` are REQUIRED here rather than refused, and - * `--fee` is accepted. See `refuseV4Flags`. - * - * `--tick-spacing` and `--hooks` are refused for the same reason: they describe a pool, and the - * position has already named one. - */ const NOT_WITH_V4_POSITION_ID = [ "tickLower", "tickUpper", @@ -161,7 +132,6 @@ const NOT_WITH_V4_POSITION_ID = [ "hooks", ] as const; -/** Flags a V3 INCREASE cannot take, because the position already fixes them. */ const NOT_WITH_POSITION_ID = [ "token0", "token1", @@ -171,26 +141,6 @@ const NOT_WITH_POSITION_ID = [ "recipient", ] as const; -/** - * V4's own matrix. - * - * THREE scenarios, not two: a deposit into a pool that exists, one that creates the pool first, and - * an increase — a deposit into a position the caller already holds. - * - * The increase is where V4 diverges from V3. On V3 an increase REFUSES `--token0` / `--token1`, - * because the position already fixes the pair. On V4 both are REQUIRED. They still select nothing — - * the position names its pool — so what they are is a cross-check, run in the use case against the - * pair the position reports and refused with `invalid_value` naming both when the two disagree. The - * same treatment `remove-liquidity` gives them on the same protocol, and the same reason: a - * mistyped position id must not fund a market the caller never named. - * - * The range stays OPTIONAL on a mint, with the same default as V3. V3 derives its default band from - * the spacing its FEE TIER implies; V4 has no tier, but the spacing is part of the pool's IDENTITY, - * which means it is KNOWN — read from the pool key, or given with `--tick-spacing` on a creation. - * Different source, same number. A caller who omits the - * range is asking for a sensible band around the price, and that question does not change with where - * the spacing came from; answering it differently per protocol would make one command behave two ways. - */ function refuseV4Flags(value: Record, ctx: RefinementCtx): void { for (const flag of NOT_ON_V4) { if (value[flag] !== undefined) { @@ -207,17 +157,7 @@ function refuseV4Flags(value: Record, ctx: RefinementCtx): void refuseV4IncreaseFlags(value, ctx); return; } - /** - * How the pool is named, resolved HERE so the refusal is deterministic. - * - * `resolveV4Pool` is pure — a pool key is five flags and a starting price is a number — so none of - * it needs chain state. Left to the use case it would sit behind the account requirement, and a caller - * who forgot `--sqrt-price` would be asked for an account first: the same flake the `--slippage` - * bounds had, for the same reason. - * - * The domain keeps the check. It is still what runs for anything calling the use case directly, and - * it is the one implementation — this calls it rather than restating its rules. - */ + // Validate the pure pool-key rules before wallet resolution. try { resolveV4Pool({ createPool: value.createPool === true, @@ -229,50 +169,27 @@ function refuseV4Flags(value: Record, ctx: RefinementCtx): void ...(value.hooks === undefined ? {} : { hooks: String(value.hooks) }), }); } catch (error) { - /** - * The domain's code is kept, whatever it is. - * - * A usage code rides the issue. An exit-1 code cannot — `parseInputSchema` turns any declared - * code that is not a usage code into `invalid_value` — so it is rethrown as it was raised: - * `same_token` is exit 1 here as it is on V2 and on every other command. Only when nothing - * earlier has been refused, so the first refusal is still the one reported. - */ - if (error instanceof CliError && error.exitCode() === 1) { + // Rethrow exit-1 domain errors: schema issues preserve only usage codes. + // Keep any earlier validation refusal first. + if (error instanceof CliError && exitCodeOf(error) === 1) { if (ctx.issues.length === 0) throw error; return; } const code = (error as { code?: string }).code; ctx.addIssue({ code: "custom", - // Pointed at the flag the message is about, where the message names one, so the error reads as - // being about that flag rather than about the command. path: [flagFrom(error) ?? "token0"], - /** - * The domain's message, whole. - * - * The framework prefixes the path's flag, so this reads slightly redundantly — - * "invalid --sqrt-price: --sqrt-price is required with --create-pool". Stripping the leading - * flag was tried and is worse: these messages are written to be read standalone, so - * "--token0 and --token1 are the wrong way round" becomes "and --token1 are the wrong way - * round". Redundant beats ungrammatical, and neither of them misleads. - */ + // Keep whole messages: removing a leading flag can break compound subjects. message: messageOf(error), params: { errorCode: code ?? "invalid_value" }, }); } } -/** - * The V4 increase scenario, whose two halves point in opposite directions. - * - * What the position fixes is REFUSED — its range, its holder, its pool. What the position is checked - * AGAINST is REQUIRED — the pair. Neither half is something to ignore: a dropped `--tick-lower` would - * suggest a position's range can be moved, and a dropped `--token0` would remove the only guard - * against adding to a position the caller did not mean. - */ +// The pair cross-check prevents funding a mistyped position ID. function refuseV4IncreaseFlags(value: Record, ctx: RefinementCtx): void { for (const flag of NOT_WITH_V4_POSITION_ID) { - // `--create-pool` is a boolean with a default, so "not given" is `false` rather than undefined. + // An omitted createPool defaults to false. if (value[flag] !== undefined && value[flag] !== false) { ctx.addIssue({ code: "custom", @@ -295,31 +212,22 @@ function refuseV4IncreaseFlags(value: Record, ctx: RefinementCt } } -/** The flag a domain refusal is about, taken from its own message rather than re-derived. */ function flagFrom(error: unknown): string | undefined { - const named = /--([a-z0-9-]+)/.exec(messageOf(error))?.[1]; - if (named === undefined) return undefined; - return named.replace(/-([a-z])/g, (_all, letter: string) => letter.toUpperCase()); + if (!(error instanceof CliError)) return undefined; + for (const part of error.parts) { + if (typeof part === "object" && "input" in part) return part.input; + } + return undefined; } function messageOf(error: unknown): string { + if (error instanceof CliError) return cliMessage(error.userMessage).message; return error instanceof Error ? error.message : String(error); } -/** - * The flag × scenario matrix. A flag outside its scenario is `invalid_option`, not - * something to ignore: silently dropping `--fee` would deposit at a tier the caller did not - * choose, and silently dropping `--tick-lower` on an increase would suggest a position's range - * can be changed, which it cannot. - * - * V4's flags ARE declared now, so a V2 or V3 deposit that carries one is refused here by name — and - * refused first, because a caller who passed `--tick-spacing` to V3 has the wrong protocol rather - * than a stray flag, and hearing about the flag alone would send them hunting for a V3 setting that - * does not exist. - */ function refuseFlagsOutsideScenario(value: Record, ctx: RefinementCtx): void { - const protocol = String(value.protocol).toUpperCase(); - if (!PROTOCOLS.includes(protocol as (typeof PROTOCOLS)[number])) { + const protocol = liquidityProtocolOf(String(value.protocol)); + if (protocol === undefined) { ctx.addIssue({ code: "custom", path: ["protocol"], @@ -328,9 +236,7 @@ function refuseFlagsOutsideScenario(value: Record, ctx: Refinem }); return; } - // V4's flags on a V2 or V3 deposit, said before anything else: a caller who passed --tick-spacing - // to V3 has the wrong protocol rather than a stray flag, and hearing about the flag first would - // send them looking for a V3 setting that does not exist. + // Report protocol mismatches before other scenario errors. if (protocol !== "V4") { for (const flag of V4_ONLY) { if (value[flag] !== undefined && value[flag] !== false) { @@ -425,9 +331,9 @@ export const sunswapAddLiquiditySpec: ChainSpec = { "'sunswap position-list' is mainnet-only and cannot tell you afterwards on Nile.\n\n" + "--dry-run validates everything — balances, the pool, the amounts, the allowances — without a\n" + "password, and works for a watch-only account.", + exclusive: [unsignedTxModeExclusive], baseFields: fields, - // The scenario matrix first, so a flag refused outright is reported as such; then a malformed - // `--recipient` is `invalid_address` at exit 2 rather than an encoder crash at exit 1. + // Reject unsupported flags before validating recipient addresses. baseRefine: allRefines(refuseFlagsOutsideScenario, addressFieldsFor("tron", "recipient")), examples: [ { @@ -460,7 +366,7 @@ export const sunswapAddLiquidityTronBinding = ( ): FamilyBinding => ({ run: async (ctx, net, input) => service.addLiquidity(ctx, net, { - protocol: String(input.protocol).toUpperCase(), + protocol: parseLiquidityProtocol(input.protocol), ...(input.token0 === undefined ? {} : { token0: input.token0 }), ...(input.token1 === undefined ? {} : { token1: input.token1 }), ...(input.positionId === undefined ? {} : { positionId: input.positionId }), @@ -473,13 +379,9 @@ export const sunswapAddLiquidityTronBinding = ( ...(input.min1 === undefined ? {} : { min1: input.min1 }), ...(input.recipient === undefined ? {} : { recipient: input.recipient }), ...(input.deadline === undefined ? {} : { deadline: input.deadline }), - ...(input.feeLimit === undefined ? {} : { feeLimit: input.feeLimit }), + ...(input.feeLimit === undefined ? {} : { feeLimit: sunAmount(input.feeLimit) }), ...(input.dryRun === undefined ? {} : { dryRun: input.dryRun }), ...(input.buildOnly === undefined ? {} : { buildOnly: input.buildOnly }), - // V4's own. Forwarded explicitly like the rest rather than by spreading `input`, so a flag that - // is declared but never passed on is a visible omission instead of a silent one — which is - // exactly what happened here: the flags parsed, the help advertised them, and the service saw - // none of them until this block named them. ...(input.createPool === undefined ? {} : { createPool: input.createPool }), ...(input.sqrtPrice === undefined ? {} : { sqrtPrice: input.sqrtPrice }), ...(input.tickSpacing === undefined ? {} : { tickSpacing: input.tickSpacing }), diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/collect-fees.test.ts b/ts/src/adapters/inbound/cli/commands/sunswap/collect-fees.test.ts index f9250d3a8..243591333 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/collect-fees.test.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/collect-fees.test.ts @@ -1,16 +1,10 @@ -/** - * The flag × scenario matrix, checked where it is decided. - * - * These refusals depend on nothing remote, so they belong to the schema: they fire - * deterministically, before a wallet is opened or a node is asked anything. Exercised through the - * spec's own fields and refine, which is exactly what the shell composes. - */ import { describe, expect, it } from "vitest"; import type { z } from "zod"; import type { ZodObject, ZodRawShape } from "zod"; import { CliError } from "../../../../../domain/errors/index.js"; import { composeRefines, parseInputSchema } from "../../shell/index.js"; import { sunswapCollectFeesSpec } from "./collect-fees.js"; +import { exitCodeOf } from "../../output/errors.js"; const schema = ( sunswapCollectFeesSpec.baseFields as unknown as { @@ -39,12 +33,6 @@ describe("sunswap collect-fees — which protocols have claimable fees", () => { expect(schema.safeParse({ protocol, positionId: "1" }).success).toBe(true); }); - /** - * V2 is `invalid_value`, not an unknown protocol. - * - * Its fees are real; they are simply not separable. "Unknown protocol" would send a caller - * looking for a spelling error instead of telling them how a V2 pool works. - */ it("refuses V2 by explaining that its fees are not separable", () => { const parsed = schema.safeParse({ protocol: "V2", positionId: "1" }); expect(refusals({ protocol: "V2", positionId: "1" })).toContainEqual({ @@ -71,12 +59,6 @@ describe("sunswap collect-fees — V4's flag matrix", () => { expect(schema.safeParse({ ...V4, deadline: 1790000000 }).success).toBe(true); }); - /** - * `--recipient` is refused rather than ignored. - * - * A V4 collection settles to the signing account and nowhere else, so a flag that was accepted - * and dropped would tell a caller their money was going somewhere it was not. - */ it("refuses --recipient", () => { expect(refusals({ ...V4, recipient: "TM56HhEWoaw2UevQh86k9AUjJqj9QVvmFC" })).toContainEqual({ path: "recipient", @@ -84,7 +66,6 @@ describe("sunswap collect-fees — V4's flag matrix", () => { }); }); - // One side names no pair at all. The pair is optional; half of it is a mistake. it.each([ ["token0", "token1"], ["token1", "token0"], @@ -118,20 +99,13 @@ describe("sunswap collect-fees — V4's flags elsewhere", () => { }); }); -/** - * A malformed `--recipient` is the caller's typo, not our crash. - * - * Unchecked, it travelled to the ABI encoder and came back as `internal_error` ("Invalid checksum") - * at exit 1 — after the position had already been read. Refused here, it is `invalid_address` at - * exit 2 before anything remote is asked. An EVM address is malformed on TRON for the same reason. - */ describe("sunswap collect-fees — a malformed --recipient", () => { it.each(["TNotAnAddress", "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"])( "refuses %s as invalid_address before any network call", (recipient) => { const error = recipientRefusal({ protocol: "V3", positionId: "686", recipient }); expect(error).toMatchObject({ code: "invalid_address" }); - expect(error.exitCode()).toBe(2); + expect(exitCodeOf(error)).toBe(2); expect(error.message).toMatch(/^invalid --recipient: /); }, ); diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/collect-fees.ts b/ts/src/adapters/inbound/cli/commands/sunswap/collect-fees.ts index 385c471a4..ee861737c 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/collect-fees.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/collect-fees.ts @@ -1,14 +1,15 @@ import { z, type RefinementCtx } from "zod"; import { addressFieldsFor, allRefines, Schemas } from "../../schemas/index.js"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunSwapCollectFeesService } from "../../../../../application/use-cases/tron/sunswap/collect-fees-service.js"; +import type { SunSwapCollectFeesService } from "../../../../../application/use-cases/sunswap/collect-fees-service.js"; import { TextFormatters } from "../../render/index.js"; -import { LEDGER_TRON_SETTINGS_NOTE } from "../shared.js"; +import { LEDGER_TRON_SETTINGS_NOTE, unsignedTxModeExclusive } from "../shared.js"; +import { sunAmount } from "../../../../../domain/amounts/index.js"; +import { DEFAULT_FEE_LIMIT_SUN } from "../../../../../domain/fees/tron.js"; -/** Flags V4 adds, and which mean nothing on V3. */ const V4_ONLY = ["token0", "token1", "fee", "deadline"] as const; -/** Flags V4 does not have. A V4 collection settles to the signing account and nowhere else. */ +// V4 settles fees to the signing account only. const NOT_ON_V4 = ["recipient"] as const; const fields = z.object({ @@ -46,24 +47,12 @@ const fields = z.object({ "Unix seconds the transaction stops being valid; default 30 minutes from now (V4 only)", ), feeLimit: Schemas.positiveIntString() - .default("100000000") + .default(DEFAULT_FEE_LIMIT_SUN) .describe( "maximum energy fee to burn, in SUN; the dry run's estimate is a lower bound, so a limit set from it can fail", ), }); -/** - * The flag × scenario matrix, and why V2 is absent. - * - * V2 is `invalid_value`, not an unknown protocol: a V2 pool's fees are real; they are simply not - * separable — they accrue into the LP token's own value and come out when the liquidity does. - * Telling a caller "unknown protocol" would suggest they mistyped, when what they need to know is - * that there is nothing to claim. - * - * Every refusal here depends on nothing remote, so it fires deterministically and without a - * wallet. The checks that need the chain — the pair and the tier against what the position - * reports — belong to the use case. - */ function refuseFlagsOutsideScenario(value: Record, ctx: RefinementCtx): void { const protocol = String(value.protocol).toUpperCase(); if (protocol !== "V3" && protocol !== "V4") { @@ -95,13 +84,6 @@ function refuseFlagsOutsideScenario(value: Record, ctx: Refinem refuseV4Flags(value, ctx); } -/** - * V4's own matrix. - * - * The pair is OPTIONAL here, unlike on `remove-liquidity` — it is a cross-check a caller may - * skip, because a collection cannot be sized wrong the way a withdrawal can. What it - * cannot be is half-given: one side names no pair at all, and `--fee` on its own checks nothing. - */ function refuseV4Flags(value: Record, ctx: RefinementCtx): void { for (const flag of NOT_ON_V4) { if (value[flag] !== undefined) { @@ -161,9 +143,9 @@ export const sunswapCollectFeesSpec: ChainSpec = { "reports the unclaimed value in that case (mainnet only).\n\n" + "On V3, 'sunswap remove-liquidity' already collects the fees alongside the principal, so this\n" + "command is for the case where the principal should stay in the pool.", + exclusive: [unsignedTxModeExclusive], baseFields: fields, - // The scenario matrix first, so a flag refused outright is reported as such; then a malformed - // `--recipient` is `invalid_address` at exit 2 rather than an encoder crash at exit 1. + // Reject unsupported flags before validating recipient addresses. baseRefine: allRefines(refuseFlagsOutsideScenario, addressFieldsFor("tron", "recipient")), examples: [ { cmd: "wallet-cli sunswap collect-fees --protocol V3 --position-id 686 --dry-run" }, @@ -183,13 +165,11 @@ export const sunswapCollectFeesTronBinding = ( protocol: String(input.protocol).toUpperCase(), positionId: input.positionId, ...(input.recipient === undefined ? {} : { recipient: input.recipient }), - // V4's own. Forwarded explicitly like the rest rather than by spreading `input`, so a flag - // that is declared but never passed on is a visible omission instead of a silent one. ...(input.token0 === undefined ? {} : { token0: input.token0 }), ...(input.token1 === undefined ? {} : { token1: input.token1 }), ...(input.fee === undefined ? {} : { fee: input.fee }), ...(input.deadline === undefined ? {} : { deadline: input.deadline }), - ...(input.feeLimit === undefined ? {} : { feeLimit: input.feeLimit }), + ...(input.feeLimit === undefined ? {} : { feeLimit: sunAmount(input.feeLimit) }), ...(input.dryRun === undefined ? {} : { dryRun: input.dryRun }), ...(input.buildOnly === undefined ? {} : { buildOnly: input.buildOnly }), }), diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/pool-list.ts b/ts/src/adapters/inbound/cli/commands/sunswap/pool-list.ts index 849ca5658..d37a71af2 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/pool-list.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/pool-list.ts @@ -1,6 +1,6 @@ -import { z, type RefinementCtx } from "zod"; +import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/tron/sunswap/market-query-service.js"; +import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/sunswap/market-query-service.js"; import { TextFormatters } from "../../render/index.js"; import { limitField, offsetField, protocolFilterField } from "./shared.js"; @@ -28,29 +28,6 @@ const fields = z.object({ offset: offsetField, }); -/** - * The service refuses `poolAddress` and `tokenAddress` together, and so must we — but as a usage - * error, not as its error. - * - * Passed through, the refusal arrives as `provider_error`: exit 1, retry "same". That tells a - * caller retrying the identical command might work, when it never can, and blames the service - * for what the command line got wrong. Only this layer knows whose fault it is. - */ -function refuseBothFilters(value: { pool?: string; token?: string }, ctx: RefinementCtx): void { - if (value.pool !== undefined && value.token !== undefined) { - ctx.addIssue({ - code: "custom", - // the path names the offending flag, which the parser puts in front of this message - path: ["pool"], - message: "cannot be combined with --token; narrow by one or by neither", - // invalid_option is the code for two flags that cannot be given together. Left - // undeclared, a refine arrives as invalid_value, which describes a bad VALUE rather than a - // bad combination of flags that are each fine on their own. - params: { errorCode: "invalid_option" }, - }); - } -} - export const sunswapPoolListSpec: ChainSpec = { path: ["sunswap", "pool-list"], network: "optional", @@ -78,7 +55,6 @@ export const sunswapPoolListSpec: ChainSpec = { select: "at-most-one", }, ], - baseRefine: refuseBothFilters, examples: [ { cmd: "wallet-cli sunswap pool-list --token USDT" }, { cmd: "wallet-cli sunswap pool-list --order-by apr --min-tvl 100000" }, @@ -89,7 +65,7 @@ export const sunswapPoolListSpec: ChainSpec = { export const sunswapPoolListTronBinding = (service: SunSwapMarketQueryService): FamilyBinding => ({ run: async (ctx, net, input) => - service.poolList(ctx, net, { + service.poolList(ctx.deadline, ctx, net, { orderBy: input.orderBy, sort: input.sort, limit: input.limit, diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/pool-search.ts b/ts/src/adapters/inbound/cli/commands/sunswap/pool-search.ts index d1202ab1c..c2ec64197 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/pool-search.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/pool-search.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/tron/sunswap/market-query-service.js"; +import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/sunswap/market-query-service.js"; import { TextFormatters } from "../../render/index.js"; import { limitField, offsetField, protocolFilterField } from "./shared.js"; @@ -41,8 +41,8 @@ export const sunswapPoolSearchSpec: ChainSpec = { export const sunswapPoolSearchTronBinding = ( service: SunSwapMarketQueryService, ): FamilyBinding => ({ - run: async (_ctx, net, input) => - service.poolSearch(net, { + run: async (ctx, net, input) => + service.poolSearch(ctx.deadline, net, { keyword: input.keyword, limit: input.limit, offset: input.offset, diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/position-info.test.ts b/ts/src/adapters/inbound/cli/commands/sunswap/position-info.test.ts index 760ab37b1..81e324a03 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/position-info.test.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/position-info.test.ts @@ -1,9 +1,3 @@ -/** - * What the schema refuses, checked where it is decided. - * - * These refusals reach nothing remote: they fire before a node is asked anything, which is the - * point of putting them in the schema rather than in the use case. - */ import { describe, expect, it } from "vitest"; import { sunswapPositionInfoSpec } from "./position-info.js"; @@ -18,10 +12,6 @@ describe("sunswap position-info — the schema", () => { } }); - /** - * V2, V1, V1_5 and CURVE are not "unsupported" — their positions are not NFTs and have no id at - * all, so there is nothing for `--position-id` to name. Refused here rather than at a contract. - */ it.each(["V2", "V1", "V1_5", "CURVE", "ALL", ""])("refuses --protocol %s", (protocol) => { expect(schema.safeParse({ protocol, positionId: "88" }).success).toBe(false); }); @@ -34,7 +24,6 @@ describe("sunswap position-info — the schema", () => { const huge = "115792089237316195423570985008687907853269984665640564039457584007913129639935"; const parsed = schema.safeParse({ protocol: "V4", positionId: huge }); expect(parsed.success).toBe(true); - // still a string: coercing it to a number would name a different position expect((parsed.data as { positionId: string }).positionId).toBe(huge); }); @@ -52,13 +41,6 @@ describe("sunswap position-info — the spec", () => { expect(sunswapPositionInfoSpec.rejectsAccount).toBeTruthy(); }); - /** - * The gate is the CONTRACTS, not the market API. - * - * The data API is mainnet-only; this implementation reads the chain instead, so it is gated the - * way the other contract-reading commands are and works on Nile too. - * Using `sunswap.market` here would switch it off on every network but mainnet. - */ it("is gated on the liquidity contracts rather than on the market API", () => { expect(sunswapPositionInfoSpec.capability).toBe("sunswap.liquidity"); }); diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/position-info.ts b/ts/src/adapters/inbound/cli/commands/sunswap/position-info.ts index 085577107..8517fbd6e 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/position-info.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/position-info.ts @@ -1,18 +1,8 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunSwapPositionInfoService } from "../../../../../application/use-cases/tron/sunswap/position-info-service.js"; +import type { SunSwapPositionInfoService } from "../../../../../application/use-cases/sunswap/position-info-service.js"; import { TextFormatters } from "../../render/index.js"; -/** - * V3 or V4, and nothing else — refused before anything is asked of a node. - * - * V2, V1, V1_5 and CURVE positions are not NFTs and have no id, so there is no "unsupported - * protocol" here to fall through to at runtime: naming one is a mistake about what a position id - * IS, and the message says so rather than listing four more values that would never work. - * - * Case is normalised because `v4` is what a person types and the value is an enum, not an - * identifier — but the accepted set is still exactly two. - */ const protocolField = z .string() .transform((value) => value.trim().toUpperCase()) @@ -24,13 +14,7 @@ const protocolField = z ) .describe("protocol version: V3 or V4"); -/** - * The NFT id, as a STRING. - * - * Not `z.coerce.number()`: an id is an unbounded uint256, and a number would round one past fifteen - * digits into a different position. What is checked is the shape — digits only, no sign, no point — - * so a malformed id is a usage error at parse time rather than a revert a caller pays for. - */ +// Keep uint256 IDs as strings to avoid rounding to another position. const positionIdField = z .string() .refine((value) => /^\d+$/.test(value.trim()), { @@ -48,14 +32,7 @@ export const sunswapPositionInfoSpec: ChainSpec = { network: "optional", wallet: "none", auth: "none", - /** - * The CONTRACTS, not the market API. - * - * The data API is mainnet-only. This implementation reads the position manager and the pool - * instead, and those exist on every network the liquidity commands - * already work on — so the gate is the same one they use, and the USD fields are absent where - * there is no price source rather than the whole command being refused. - */ + // Gate on contracts: position reads do not require the mainnet market API. capability: "sunswap.liquidity", rejectsAccount: "position-info looks up a position by its id and reports whoever holds it, so it is not about any account of yours", @@ -81,7 +58,7 @@ export const sunswapPositionInfoTronBinding = ( service: SunSwapPositionInfoService, ): FamilyBinding => ({ run: async (ctx, net, input) => - service.positionInfo(ctx, net, { + service.positionInfo(ctx.deadline, ctx, net, { protocol: input.protocol, positionId: input.positionId, }), diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/position-list.ts b/ts/src/adapters/inbound/cli/commands/sunswap/position-list.ts index 62d10ea01..7a67451f7 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/position-list.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/position-list.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/tron/sunswap/market-query-service.js"; +import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/sunswap/market-query-service.js"; import { TextFormatters } from "../../render/index.js"; import { limitField, offsetField, protocolFilterField } from "./shared.js"; @@ -19,8 +19,6 @@ const fields = z.object({ export const sunswapPositionListSpec: ChainSpec = { path: ["sunswap", "position-list"], network: "optional", - // Read-only and address-scoped, like `account balance`: the active account by default, and - // `--account` takes a label, an account id, or any TRON address. wallet: "optional", auth: "none", capability: "sunswap.market", @@ -48,7 +46,7 @@ export const sunswapPositionListTronBinding = ( service: SunSwapMarketQueryService, ): FamilyBinding => ({ run: async (ctx, net, input) => - service.positionList(net, { + service.positionList(ctx.deadline, net, { owner: ctx.resolveAddress("tron"), limit: input.limit, offset: input.offset, diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/price.ts b/ts/src/adapters/inbound/cli/commands/sunswap/price.ts index d32fd0885..be8b8de08 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/price.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/price.ts @@ -1,15 +1,9 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/tron/sunswap/market-query-service.js"; +import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/sunswap/market-query-service.js"; import { TextFormatters } from "../../render/index.js"; -/** - * One schema drives arity, validation, help and `--json-schema`. - * - * The two inputs are deliberately not merged into one "token or address" field. Which one the - * caller meant decides whether a local lookup happens at all, and giving both is an error rather - * than a silent preference. - */ +// Symbol inputs use the token book; explicit addresses bypass it. const fields = z.object({ token: z .string() @@ -50,22 +44,15 @@ export const sunswapPriceSpec: ChainSpec = { formatText: TextFormatters.sunswapPrice, }; -/** - * The binding decides what the envelope publishes, and it is not the whole service result. - * - * `warnings` goes through `ctx.warn`, which is the only route to `meta.warnings` — the place a - * caller reads. Returning it inside the payload instead would put - * the notice somewhere nobody looks, which is how the degraded symbol lookup went silent. - */ +// Route warnings through ctx.warn so they reach envelope metadata. export const sunswapPriceTronBinding = (service: SunSwapMarketQueryService): FamilyBinding => ({ run: async (ctx, net, input) => { - const view = await service.prices(net, { + const view = await service.prices(ctx.deadline, net, { ...(input.token === undefined ? {} : { token: input.token }), ...(input.address === undefined ? {} : { addresses: input.address.split(",") }), }); for (const warning of view.warnings) ctx.warn(warning); - // `view` is stripped before the JSON envelope is written: the symbol stays out of json - // because a symbol is self-reported and an agent must key on the address instead. + // Symbols are display-only; JSON identifies tokens by address. return { prices: view.prices, view: { symbols: Object.fromEntries(view.symbols) } }; }, }); diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/remove-liquidity.test.ts b/ts/src/adapters/inbound/cli/commands/sunswap/remove-liquidity.test.ts index 1054bb244..b9d544320 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/remove-liquidity.test.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/remove-liquidity.test.ts @@ -1,16 +1,10 @@ -/** - * The flag × scenario matrix, checked where it is decided. - * - * These refusals depend on nothing remote, so they belong to the schema: they fire deterministically, - * before a wallet is opened or a node is asked anything. Exercised through the spec's own fields and - * refine, which is exactly what the shell composes. - */ import { describe, expect, it } from "vitest"; import type { z } from "zod"; import type { ZodObject, ZodRawShape } from "zod"; import { CliError } from "../../../../../domain/errors/index.js"; import { composeRefines, parseInputSchema } from "../../shell/index.js"; import { sunswapRemoveLiquiditySpec } from "./remove-liquidity.js"; +import { exitCodeOf } from "../../output/errors.js"; const schema = ( sunswapRemoveLiquiditySpec.baseFields as unknown as { @@ -44,19 +38,11 @@ describe("sunswap remove-liquidity — V4's flag matrix", () => { expect(schema.safeParse(V4).success).toBe(true); }); - // V4 is the one scenario that needs both, because the pair is a cross-check rather than a - // selector. A missing side is a missing option, not something to infer from the position. it.each(["token0", "token1", "positionId"])("requires --%s", (flag) => { const rest = Object.fromEntries(Object.entries(V4).filter(([key]) => key !== flag)); expect(refusals(rest)).toContainEqual({ path: flag, code: "missing_option" }); }); - /** - * `--recipient` is refused rather than ignored. - * - * A V4 withdrawal settles to the signing account and nowhere else, so a flag that was accepted and - * dropped would tell a caller their money was going somewhere it was not. - */ it("refuses --recipient", () => { expect(refusals({ ...V4, recipient: "TNmoJ3Be59WFEq5dsW6eCkZjveiL3G8HVB" })).toContainEqual({ path: "recipient", @@ -71,11 +57,6 @@ describe("sunswap remove-liquidity — V4's flag matrix", () => { expect(schema.safeParse({ ...V4, slippage: "0.005" }).success).toBe(true); }); - /** - * They COMBINE: --slippage is a tolerance that lowers --min0/--min1 further. Refusing the pair - * would refuse the one shape a careful caller wants — a floor they chose, with a little room - * under it. - */ it("accepts --slippage together with an explicit minimum", () => { expect(refusals({ ...V4, slippage: "0.005", min0: "1" })).toEqual([]); }); @@ -102,18 +83,10 @@ describe("sunswap remove-liquidity — V4's flags elsewhere", () => { path: "protocol", code: "invalid_value", }); - // Lower case too: the binding upper-cases it before the service ever sees it. expect(schema.safeParse({ ...V4, protocol: "v4" }).success).toBe(true); }); }); -/** - * A malformed `--recipient` is the caller's typo, not our crash. - * - * Unchecked, it travelled to the ABI encoder and came back as `internal_error` ("Invalid checksum") - * at exit 1 — after the position had already been read. Refused here, it is `invalid_address` at - * exit 2 before anything remote is asked. An EVM address is malformed on TRON for the same reason. - */ describe("sunswap remove-liquidity — a malformed --recipient", () => { it.each(["TNotAnAddress", "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"])( "refuses %s as invalid_address before any network call", @@ -125,7 +98,7 @@ describe("sunswap remove-liquidity — a malformed --recipient", () => { recipient, }); expect(error).toMatchObject({ code: "invalid_address" }); - expect(error.exitCode()).toBe(2); + expect(exitCodeOf(error)).toBe(2); expect(error.message).toMatch(/^invalid --recipient: /); }, ); diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/remove-liquidity.ts b/ts/src/adapters/inbound/cli/commands/sunswap/remove-liquidity.ts index 276a8186b..2cc73c711 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/remove-liquidity.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/remove-liquidity.ts @@ -1,28 +1,17 @@ import { z, type RefinementCtx } from "zod"; import { addressFieldsFor, allRefines, Schemas } from "../../schemas/index.js"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunSwapRemoveLiquidityService } from "../../../../../application/use-cases/tron/sunswap/remove-liquidity-service.js"; +import type { SunSwapRemoveLiquidityService } from "../../../../../application/use-cases/sunswap/remove-liquidity-service.js"; import { TextFormatters } from "../../render/index.js"; -import { LEDGER_TRON_SETTINGS_NOTE } from "../shared.js"; +import { LEDGER_TRON_SETTINGS_NOTE, unsignedTxModeExclusive } from "../shared.js"; +import { sunAmount } from "../../../../../domain/amounts/index.js"; +import { DEFAULT_FEE_LIMIT_SUN } from "../../../../../domain/fees/tron.js"; const PROTOCOLS = ["V2", "V3", "V4"] as const; -/** - * Flags that belong only to V4. - * - * `--fee` is a cross-check on V4 rather than a selector, and `--slippage` is a tolerance on the - * FLOOR. Neither means anything on V2 or V3, where the tier comes from the pair or the position and - * the floor is set directly with `--min0` / `--min1`. - */ const V4_ONLY = ["fee", "slippage"] as const; -/** - * Flags V4 does not have. - * - * `--recipient` is absent by design: a V4 withdrawal settles to the signing account and - * nowhere else. Accepting the flag and ignoring it would send someone's money to the wrong place - * while telling them otherwise. - */ +// V4 settles withdrawals to the signing account only. const NOT_ON_V4 = ["recipient"] as const; const fields = z.object({ @@ -35,8 +24,6 @@ const fields = z.object({ .default(false) .describe("emit the unsigned transactions in execution order without signing them"), protocol: z.string().describe("protocol version: V2, V3 or V4"), - // One flag, two meanings, and the difference is invisible in the value itself — so it is - // spelled out here rather than left to a reader to infer from the protocol they chose. liquidity: z .string() .describe( @@ -84,22 +71,12 @@ const fields = z.object({ .optional() .describe("Unix seconds the transaction stops being valid; default 30 minutes from now"), feeLimit: Schemas.positiveIntString() - .default("100000000") + .default(DEFAULT_FEE_LIMIT_SUN) .describe( "maximum energy fee to burn, in SUN; the dry run's estimate is a lower bound, so a limit set from it can fail", ), }); -/** - * The flag × scenario matrix. - * - * A V2 withdrawal names the pair; a V3 one names the position, which already knows its pair, so - * accepting both would let a caller believe the two could disagree. V4 is the exception and - * deliberately so: it requires BOTH, because there the pair is not a selector but a cross-check the - * use case makes against what the position reports. - * - * Every refusal here depends on nothing remote, so it fires deterministically and without a wallet. - */ function refuseFlagsOutsideScenario(value: Record, ctx: RefinementCtx): void { const protocol = String(value.protocol).toUpperCase(); if (!PROTOCOLS.includes(protocol as (typeof PROTOCOLS)[number])) { @@ -111,8 +88,7 @@ function refuseFlagsOutsideScenario(value: Record, ctx: Refinem }); return; } - // V4's own flags on a V2 or V3 withdrawal, said before anything else: a caller who passed - // --slippage to V3 has the wrong protocol rather than a stray flag. + // Report protocol mismatches before other scenario errors. if (protocol !== "V4") { for (const flag of V4_ONLY) { if (value[flag] !== undefined) { @@ -170,14 +146,7 @@ function refuseFlagsOutsideScenario(value: Record, ctx: Refinem } } -/** - * V4's own matrix. - * - * Both the position id AND the pair are required, which is the one place this command asks for more - * than V3 does. The pair does not choose the pool — the position does — so it is checked against - * what the position reports and a disagreement is refused. That check needs the chain and therefore - * lives in the use case; what lives here is that both were given at all. - */ +// Require the pair for an on-chain cross-check against the selected position. function refuseV4Flags(value: Record, ctx: RefinementCtx): void { for (const flag of NOT_ON_V4) { if (value[flag] !== undefined) { @@ -242,9 +211,9 @@ export const sunswapRemoveLiquiditySpec: ChainSpec = { "add-liquidity receipt reports.\n\n" + "--dry-run validates everything — the position, the balance, the amounts — without a\n" + "password, and works for a watch-only account.", + exclusive: [unsignedTxModeExclusive], baseFields: fields, - // The scenario matrix first, so a flag refused outright is reported as such; then a malformed - // `--recipient` is `invalid_address` at exit 2 rather than an encoder crash at exit 1. + // Reject unsupported flags before validating recipient addresses. baseRefine: allRefines(refuseFlagsOutsideScenario, addressFieldsFor("tron", "recipient")), examples: [ { @@ -274,11 +243,9 @@ export const sunswapRemoveLiquidityTronBinding = ( ...(input.min1 === undefined ? {} : { min1: input.min1 }), ...(input.recipient === undefined ? {} : { recipient: input.recipient }), ...(input.deadline === undefined ? {} : { deadline: input.deadline }), - // V4's own. Forwarded explicitly like the rest rather than by spreading `input`, so a flag - // that is declared but never passed on is a visible omission instead of a silent one. ...(input.fee === undefined ? {} : { fee: input.fee }), ...(input.slippage === undefined ? {} : { slippage: input.slippage }), - ...(input.feeLimit === undefined ? {} : { feeLimit: input.feeLimit }), + ...(input.feeLimit === undefined ? {} : { feeLimit: sunAmount(input.feeLimit) }), ...(input.dryRun === undefined ? {} : { dryRun: input.dryRun }), ...(input.buildOnly === undefined ? {} : { buildOnly: input.buildOnly }), }), diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/shared.ts b/ts/src/adapters/inbound/cli/commands/sunswap/shared.ts index 11eac3cc4..9ce713db5 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/shared.ts @@ -1,15 +1,3 @@ -/** - * Zod fragments shared by the SunSwap listing commands. - * - * Kept in one place so the paging and scope flags cannot drift apart between commands: an agent - * that learned `--limit` on one listing should not find a different default on the next. - * - * Note what is deliberately ABSENT. There is no `--include-blacklisted`: pool and token records - * carry no blacklist marker, so listing them "without marking" would put impersonations into the - * table unlabelled, and the service's filter is always left on. And `token-list` has no `--sort`, - * because the token endpoints expose a sort field but no direction — offering a flag the service - * ignores would be a promise the output does not keep. - */ import { z } from "zod"; import { SUNSWAP_PROTOCOL_FILTERS, @@ -18,10 +6,7 @@ import { const SCOPE_LIST = SUNSWAP_SCOPES.join(", "); -/** - * The scope is always sent, never left to the service's default: omitting it makes the - * service mix statistic scopes and return the same token more than once. - */ +// Always send the scope; omitting it can mix scopes and duplicate tokens. export const protocolField = z.string().default("ALL").describe(`protocol scope: ${SCOPE_LIST}`); export const limitField = z.coerce @@ -31,14 +16,7 @@ export const limitField = z.coerce export const offsetField = z.coerce.number().default(0).describe("number of rows to skip"); -/** - * `--protocol` where it FILTERS rows rather than choosing a statistic scope. - * - * `ALL` is not offered: "all of them" is spelled by omitting the flag, and there is no such - * protocol for a pool or a position to belong to. The list is generated from the same constant - * the validator checks against, so help, `--json-schema` and the runtime cannot drift apart — - * which is exactly how `ALL` came to be accepted here while the schema said it was not a value. - */ +// Omit the filter for all protocols; ALL is only a statistic scope. export const protocolFilterField = z .string() .optional() diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/swap.test.ts b/ts/src/adapters/inbound/cli/commands/sunswap/swap.test.ts index 682484b3a..1f3f2313a 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/swap.test.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/swap.test.ts @@ -1,12 +1,39 @@ import { describe, expect, it } from "vitest"; import { sunswapSwapSpec } from "./swap.js"; +const schema = sunswapSwapSpec.baseFields.superRefine(sunswapSwapSpec.baseRefine!); +const POSITIONALS = { tokenIn: "TRX", tokenOut: "USDT", amountIn: "1" }; + +function issues(input: Record) { + const result = schema.safeParse({ ...POSITIONALS, ...input }); + return result.success + ? [] + : result.error.issues.map((issue) => ({ + path: issue.path.join("."), + errorCode: (issue as { params?: { errorCode?: string } }).params?.errorCode, + })); +} + describe("sunswap swap --quote", () => { - // A quote submits nothing, so there is no confirmation to wait for — the same rule - // `tx send` applies to `--dry-run --wait`. `--wait` and `--wait-timeout` are global, so the - // shell refuses them from the spec before the binding runs; positional-contract.test.ts drives - // that refusal end to end for every command that declares it. it("declares --wait and --wait-timeout refused under --quote", () => { expect(sunswapSwapSpec.rejectsWaitWith).toBe("quote"); }); + + it.each([{ slippage: "0.005" }, { minOut: "1" }])("refuses a floor: %o", (flags) => { + expect(issues({ quote: true, ...flags })).toEqual([ + { path: Object.keys(flags)[0], errorCode: "invalid_option" }, + ]); + }); +}); + +describe("sunswap swap --min-out", () => { + it("refuses --slippage alongside it", () => { + expect(issues({ slippage: "0.005", minOut: "1" })).toEqual([ + { path: "minOut", errorCode: "invalid_option" }, + ]); + }); + + it("is accepted alone outside a quote", () => { + expect(issues({ minOut: "1" })).toEqual([]); + }); }); diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/swap.ts b/ts/src/adapters/inbound/cli/commands/sunswap/swap.ts index 8fc9642ba..2d845fbe1 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/swap.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/swap.ts @@ -1,9 +1,12 @@ import { z, type RefinementCtx } from "zod"; import { allRefines, Schemas, slippageField } from "../../schemas/index.js"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunSwapSwapService } from "../../../../../application/use-cases/tron/sunswap/swap-service.js"; +import type { SunSwapSwapService } from "../../../../../application/use-cases/sunswap/swap-service.js"; import { TextFormatters } from "../../render/index.js"; -import { LEDGER_TRON_SETTINGS_NOTE } from "../shared.js"; +import { LEDGER_TRON_SETTINGS_NOTE, unsignedTxModeExclusive } from "../shared.js"; +import { refuseQuoteWithSendingFlags } from "../sunpump/shared.js"; +import { sunAmount } from "../../../../../domain/amounts/index.js"; +import { DEFAULT_FEE_LIMIT_SUN } from "../../../../../domain/fees/tron.js"; const fields = z.object({ tokenIn: z.string().describe("token to spend, symbol or contract address"), @@ -28,44 +31,21 @@ const fields = z.object({ slippage: z .string() .optional() - .describe("tolerance as a decimal, e.g. 0.005 for 0.5% [default 0.005]"), + .describe("tolerance as a decimal, e.g. 0.005 for 0.5%; excludes --min-out [default 0.005]"), + minOut: z + .string() + .optional() + .describe("least to accept, in tokenOut's smallest unit; excludes --slippage"), feeLimit: Schemas.positiveIntString() - .default("100000000") + .default(DEFAULT_FEE_LIMIT_SUN) .describe( "maximum energy fee to burn, in SUN; the dry run's estimate is a lower bound, so a limit set from it can fail", ), }); -/** - * `--quote` sends no transaction, so it excludes everything that describes one — including - * `--slippage`, since a tolerance only means something when a floor will be enforced. `--all` - * lists candidate routes, which only a quote has. - */ function refuseFlagsOutsideMode(value: Record, ctx: RefinementCtx): void { - const sending = ["dryRun", "buildOnly"] as const; - if (value.quote === true) { - for (const flag of sending) { - if (value[flag] === true) { - ctx.addIssue({ - code: "custom", - path: [flag], - message: "cannot be given with --quote, which sends no transaction", - params: { errorCode: "invalid_option" }, - }); - } - } - if (value.slippage !== undefined) { - ctx.addIssue({ - code: "custom", - path: ["slippage"], - message: - "cannot be given with --quote: a quote enforces no floor, so a tolerance would have nothing to apply to", - params: { errorCode: "invalid_option" }, - }); - } - return; - } - if (value.all === true) { + refuseQuoteWithSendingFlags(value, ctx); + if (value.quote !== true && value.all === true) { ctx.addIssue({ code: "custom", path: ["all"], @@ -78,16 +58,12 @@ function refuseFlagsOutsideMode(value: Record, ctx: RefinementC export const sunswapSwapSpec: ChainSpec = { path: ["sunswap", "swap"], network: "optional", - // `none` because `--quote` must work with no account at all; the use case resolves - // the account itself on the paths that sign. An account is needed for every other mode. + // Quotes need no account; other modes resolve it in the use case. wallet: "none", auth: "conditional", - // A quote sends nothing, so there is no confirmation for --wait or --wait-timeout to wait on. rejectsWaitWith: "quote", broadcasts: true, - // Its own key, not sunpump's: this command needs a launchpad address OR a route service, and - // `swap` must not be switched on merely because SunPump is configured — nor hidden on a network - // that has a router but no curve. + // Swap availability depends on a curve or router, not SunPump configuration alone. capability: "sunswap.swap", summary: "Swap tokens through SunSwap or a SunPump curve", description: @@ -109,9 +85,12 @@ export const sunswapSwapSpec: ChainSpec = { "--build-only is refused for a token swap: the transaction embeds that signature and cannot\n" + "be built before it exists. --dry-run prices the approval and shows the grant it would ask\n" + "for.\n\n" + + "--min-out sets the floor directly, in tokenOut's smallest unit, instead of --slippage; a\n" + + "quote already below it is refused before anything is approved or signed.\n\n" + "Default slippage is 0.5% — note that `sunpump buy` and `sell` reach the same curve with a 5%\n" + "default, because a command named after a meme-token market budgets for its volatility.\n\n" + "An account is needed for every mode except --quote, which reads only.", + exclusive: [unsignedTxModeExclusive], baseFields: fields, baseRefine: allRefines(refuseFlagsOutsideMode, slippageField()), positionals: [{ field: "tokenIn" }, { field: "tokenOut" }, { field: "amountIn" }], @@ -133,11 +112,12 @@ export const sunswapSwapTronBinding = (service: SunSwapSwapService): FamilyBindi tokenOut: input.tokenOut, amountIn: input.amountIn, ...(input.slippage === undefined ? {} : { slippage: input.slippage }), + ...(input.minOut === undefined ? {} : { minOut: input.minOut }), ...(input.quote === undefined ? {} : { quote: input.quote }), ...(input.all === undefined ? {} : { all: input.all }), ...(input.dryRun === undefined ? {} : { dryRun: input.dryRun }), ...(input.buildOnly === undefined ? {} : { buildOnly: input.buildOnly }), - ...(input.feeLimit === undefined ? {} : { feeLimit: input.feeLimit }), + ...(input.feeLimit === undefined ? {} : { feeLimit: sunAmount(input.feeLimit) }), }); }, }); diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/token-list.ts b/ts/src/adapters/inbound/cli/commands/sunswap/token-list.ts index e0a8accaa..1f19bbbe0 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/token-list.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/token-list.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/tron/sunswap/market-query-service.js"; +import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/sunswap/market-query-service.js"; import { TextFormatters } from "../../render/index.js"; import { limitField, offsetField, protocolField } from "./shared.js"; @@ -36,8 +36,8 @@ export const sunswapTokenListSpec: ChainSpec = { }; export const sunswapTokenListTronBinding = (service: SunSwapMarketQueryService): FamilyBinding => ({ - run: async (_ctx, net, input) => - service.tokenList(net, { + run: async (ctx, net, input) => + service.tokenList(ctx.deadline, net, { protocol: input.protocol, orderBy: input.orderBy, limit: input.limit, diff --git a/ts/src/adapters/inbound/cli/commands/sunswap/token-search.ts b/ts/src/adapters/inbound/cli/commands/sunswap/token-search.ts index 1a4795f07..110cf48eb 100644 --- a/ts/src/adapters/inbound/cli/commands/sunswap/token-search.ts +++ b/ts/src/adapters/inbound/cli/commands/sunswap/token-search.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; -import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/tron/sunswap/market-query-service.js"; +import type { SunSwapMarketQueryService } from "../../../../../application/use-cases/sunswap/market-query-service.js"; import { TextFormatters } from "../../render/index.js"; import { limitField, offsetField, protocolField } from "./shared.js"; @@ -39,8 +39,8 @@ export const sunswapTokenSearchSpec: ChainSpec = { export const sunswapTokenSearchTronBinding = ( service: SunSwapMarketQueryService, ): FamilyBinding => ({ - run: async (_ctx, net, input) => - service.tokenSearch(net, { + run: async (ctx, net, input) => + service.tokenSearch(ctx.deadline, net, { keyword: input.keyword, protocol: input.protocol, limit: input.limit, diff --git a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts index 7471a1c2e..d2645b75a 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -9,6 +9,7 @@ import { } from "../../../../bootstrap/families/tron.js"; import { commandId } from "../command-id.js"; import { TextFormatters } from "../render/index.js"; +import { sunAmount } from "../../../../domain/amounts/index.js"; import { introspectFields } from "../arity/index.js"; import { isChainCommand } from "../contracts/index.js"; import type { TextRenderContext } from "../contracts/index.js"; @@ -17,9 +18,7 @@ import { registerContactCommands } from "./contact.js"; import { registerAddressCommands } from "./address.js"; import { registerEncodingCommands } from "./encoding.js"; -// A chain command always has a resolved network by the time its formatter runs, so the default -// carries one. renderFamily() now refuses to guess (it used to silently default to tron, which -// would render wei as TRX), and a fixture without `net` would not represent any real invocation. +// Chain-command formatters require a resolved network to interpret units. const ctx = (over: Partial = {}): TextRenderContext => ({ command: "x", net: { id: "tron:3448148188", family: "tron", nativeSymbol: "TRX" } as never, @@ -48,9 +47,6 @@ describe("text formatters", () => { expect(missing).toEqual([]); }); - // Help renders a field's description for both flags and positionals (help/index.ts Args + Flags). - // A field without one prints a bare name and leaves the reader — often an agent — guessing what - // to pass. Registry-wide so a new command cannot quietly ship undocumented inputs. it("every registered command field carries a help description", () => { const registry = new CommandRegistry(); registerWalletCommands(registry, {} as Parameters[1]); @@ -118,8 +114,6 @@ describe("permissionShow formatter", () => { ], } as any; - // operationsHex stays in json — it is a machine value, and the human column already - // carries the decoded operation labels next to it. it("does not print the operations bitmap in the text card", () => { const out = TextFormatters.permissionShow(view, ctx()) as string; expect(out).not.toContain("Operations Hex"); @@ -141,8 +135,6 @@ describe("accountBalance formatter", () => { { address: "TXaddress", balance: "1983993000", decimals: 6, symbol: "TRX" }, ctx(), ); - // Grouped, like every other amount the CLI prints — this now goes through the shared - // formatter rather than a bare unit conversion. expect(out).toContain("1,983.993 TRX"); expect(out).not.toContain("sun"); }); @@ -162,9 +154,6 @@ describe("accountBalance formatter", () => { expect(out).not.toContain("TXaddress"); }); - // An 18-decimal balance printed in full is 18 fractional digits of noise around the two or - // three that carry meaning. json keeps every one of them; text is for a person reading a - // terminal, so it obeys the same display rule as every other amount the CLI prints. it("caps an 18-decimal balance at six fractional digits", () => { const out = TextFormatters.accountBalance( { address: "0xabc", balance: "12345678901234567890", decimals: 18, symbol: "ETH" }, @@ -224,9 +213,40 @@ describe("stake/chain TRX amount formatting", () => { }); }); +describe("stakeDelegated formatter", () => { + it("shows only the cap the result carries", () => { + const out = TextFormatters.stakeDelegated( + { direction: "out", canDelegateMaxSun: { energy: "1000000" }, delegations: [] }, + ctx(), + ); + expect(out).toMatch(/Energy\s+1 TRX/); + expect(out).not.toContain("Bandwidth"); + }); +}); + +describe("chainParams formatter", () => { + it("shows the unit the result carries and none for a unitless parameter", () => { + const out = TextFormatters.chainParams({ + params: [ + { key: "getEnergyFee", value: 210, unit: "sun" }, + { key: "getProposalExpireTime", value: 259200000, unit: "ms" }, + { key: "getAllowMultiSign", value: 1, unit: "" }, + ], + }); + expect(out).toMatch(/getEnergyFee\s*\|\s*210 sun\s*\|/); + expect(out).toMatch(/getProposalExpireTime\s*\|\s*259,200,000 ms\s*\|/); + expect(out).toMatch(/getAllowMultiSign\s*\|\s*1\s*\|/); + }); +}); + describe("stakeInfo unfreezing list", () => { const data = { - staked: { energySun: "1000000000", bandwidthSun: "500000000" }, + staked: { + energySun: "1000000000", + bandwidthSun: "500000000", + tronPowerSun: "0", + totalSun: "1500000000", + }, votingPower: { total: 1500, used: 1000, available: 500 }, resource: { energy: { used: 12000, limit: 65000 }, bandwidth: { used: 600, limit: 1500 } }, unfreezing: [ @@ -244,10 +264,25 @@ describe("stakeInfo unfreezing list", () => { expect(branch).toHaveLength(2); expect(branch[0]).toContain("├─ 500 TRX withdrawable"); expect(branch[1]).toContain("└─ 300 TRX withdrawable"); - // no legacy " 1) "/" 2) " line-leading numbering survives expect(out).not.toMatch(/^\s*\d+\)\s/m); }); + it("shows the result's stake total and names TRON Power stake", () => { + const out = TextFormatters.stakeInfo( + { + ...data, + staked: { + energySun: "1000000", + bandwidthSun: "2000000", + tronPowerSun: "4000000", + totalSun: "7000000", + }, + }, + ctx({ accountLabel: "main" }), + ); + expect(out).toContain("7 TRX (for energy 1 TRX + for bandwidth 2 TRX + for TRON Power 4 TRX)"); + }); + it("aligns the branch under the value column", () => { const out = TextFormatters.stakeInfo(data, ctx({ accountLabel: "main" })); const lines = out.split("\n"); @@ -359,7 +394,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" rawAmount: "1000000", to: "Tdest", blockNumber: 66000000, - feeSun: "268000", + feeSun: 268000, }, ctx(), ); @@ -445,7 +480,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(out).toContain("Dry run"); expect(out).not.toContain("[object Object]"); expect(out).toContain("29,650 energy"); - expect(out).toContain("covered by staked energy"); // availableEnergy >= energy + expect(out).toContain("covered by staked energy"); }); it("dry-run energy estimate with insufficient available energy: no 'covered' note", () => { const out = TextFormatters.txReceipt( @@ -463,9 +498,6 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(out).toContain("29,650 energy"); expect(out).not.toContain("covered by staked energy"); }); - // `account activate` estimates its fee from two chain parameters, so its fee object carries - // neither `feeSun` nor `energy`. Every such shape must still render as TRX — and no fee shape, - // known or not, may ever reach the user as a stringified object. const activateFee = { feeModel: "tron-resource", createAccountFeeSun: "100000", @@ -489,15 +521,13 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" it("account activate dry-run: renders the total creation fee, not [object Object]", () => { const out = dryRun(activateFee); expect(out).not.toContain("[object Object]"); - // The confirmed receipt shows `Fee 1.1 TRX`; dry-run must be comparable expect(out).toContain("1.1 TRX"); }); it.each([ ["createAccountFeeSun alone", { minimumFeeSun: "100000" }, "0.1 TRX"], ["a zero fee", { minimumFeeSun: "0" }, "0 TRX"], - // fees use fromBaseUnits (exact decimal, no thousands separators) like every other Fee row - ["a large fee", { minimumFeeSun: "9000000000" }, "9,000 TRX"], // grouped + ["a large fee", { minimumFeeSun: "9000000000" }, "9,000 TRX"], ])("account activate dry-run: %s", (_name, fee, expected) => { expect(dryRun(fee)).toContain(expected); }); @@ -506,8 +536,6 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(dryRun({ feeSun: "2000000", minimumFeeSun: "1100000" })).toContain("2 TRX"); }); - // The regression net: every fee shape the renderer already understood must keep working, so - // adding a branch cannot silently reorder or shadow an existing one. it.each([ ["feeSun", { feeSun: "268000" }, "0.268 TRX"], ["bandwidth burn", { bandwidthBurnSunIfNoFreeze: "1000000" }, "1 TRX"], @@ -517,9 +545,6 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(dryRun(fee)).toContain(expected); }); - // The root cause of the activate bug: an unrecognized fee object fell through to a fallback that - // stringified it. Failing honestly is required; leaking "[object Object]" is not acceptable for - // any shape, including ones added later. it.each([ ["an unrecognized fee object", { someFutureFeeModel: "42" }], ["an empty fee object", {}], @@ -530,10 +555,6 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(out).toContain("unknown"); }); - // `tx broadcast` carries the full approval view in json, but text projected none of it: the - // dry-run receipt was a bare header plus a fee line, and the fee was printed twice whenever the - // multi-sign fee was non-zero (receiptRows pushed one row, the dry-run branch another). A sample - // fee of 0 hides the duplicate because 0 is falsy. const broadcastApproval = { txId: "abc123", contractType: "TransferContract", @@ -624,7 +645,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" kind: "stake-freeze", stage: "submitted", txId: "abc", - amountSun: "2000000", + amountSun: sunAmount("2000000"), resource: "energy", }, ctx(), @@ -661,9 +682,6 @@ describe("local multisig formatters", () => { expect(out).toContain("Tsigner"); }); - // The default `tx sign` receipt is the action block plus the same transaction and - // signature-progress block `tx approvals` prints — permission group name and threshold, a - // Progress line, and the per-signer weight table. The offline receipt cannot carry any of it. it("prints the documented progress block on the default (checked) sign receipt", () => { const out = TextFormatters.txSign({ kind: "tx-sign", @@ -682,16 +700,14 @@ describe("local multisig formatters", () => { approval, }) as string; expect(out).toContain("Signature added"); - expect(out).toContain("Tsigner (weight 1)"); // signer weight on the action block + expect(out).toContain("Tsigner (weight 1)"); expect(out).toContain('Permission active "operations" (id 2) threshold 2'); expect(out).toContain("Progress 1 / 2"); - expect(out).toContain("Approved signer"); // weight table header + expect(out).toContain("Approved signer"); expect(out).not.toContain("local inspection"); expect(out).not.toContain("was not checked online"); }); - // Text gets the human operation name; the machine-readable contractType enum stays in - // json. Printing both put a machine value in a human column. it("prints the human operation name without the contract-type enum", () => { const out = TextFormatters.txApprovals({ ...approval, operation: "Transfer TRX" }) as string; expect(out).toContain("Transfer TRX"); @@ -865,24 +881,47 @@ describe("txInfo formatter (per-family, narrowed on ctx.net.family)", () => { }); describe("accountInfo staking summary", () => { - const accountInfo = (amount: unknown) => + const accountInfo = (staked: Record) => TextFormatters.accountInfo( { address: "Towner", - account: { balance: 0, frozenV2: [{ type: "ENERGY", amount }] }, + account: { balance: 0, frozenV2: [{ type: "ENERGY", amount: "1" }] }, + balanceSun: "0", + staked, resources: {}, }, ctx(), ); - it("preserves staking amounts above Number.MAX_SAFE_INTEGER when supplied as strings", () => { - // grouped; the point of this test is that the fraction survives intact past - // Number.MAX_SAFE_INTEGER, which it still does. - expect(accountInfo("9007199254740993")).toContain("9,007,199,254.740993 TRX"); + it("formats the result's stake totals, not the raw frozenV2 entries", () => { + const out = accountInfo({ + energySun: "9007199254740993", + bandwidthSun: "2000000", + tronPowerSun: "0", + totalSun: "9007199256740993", + }); + expect(out).toContain("9,007,199,256.740993 TRX (energy 9,007,199,254.740993 + bandwidth 2)"); + expect(out).not.toContain("TRON Power"); + }); + + it("names TRON Power stake separately instead of counting it as bandwidth", () => { + const out = accountInfo({ + energySun: "0", + bandwidthSun: "1000000", + tronPowerSun: "3000000", + totalSun: "4000000", + }); + expect(out).toContain("4 TRX (energy 0 + bandwidth 1 + TRON Power 3)"); }); - it("omits the staking summary for an already-unsafe numeric amount", () => { - expect(accountInfo(9007199254740992)).not.toContain("Staked"); + it("omits the staking row when nothing is staked", () => { + const out = accountInfo({ + energySun: "0", + bandwidthSun: "0", + tronPowerSun: "0", + totalSun: "0", + }); + expect(out).not.toContain("Staked"); }); }); @@ -898,13 +937,13 @@ describe("contractInfo formatter", () => { expect(out).toContain("Methods"); expect(out).toContain("2 (a / b)"); }); - it("falls back to raw contract/info ABI shape", () => { + it("does not count functions from the raw contract payload", () => { const out = TextFormatters.contractInfo({ address: "TR7c", - contract: { name: "Bar", abi: { entrys: [{ type: "Function", name: "x" }] } }, + contract: { name: "Raw", abi: { entrys: [{ type: "Function", name: "x" }] } }, }); - expect(out).toContain("Bar"); - expect(out).toContain("1 (x)"); + expect(out).not.toContain("Raw"); + expect(out).not.toContain("(x)"); }); }); @@ -929,6 +968,47 @@ describe("accountHistory formatter", () => { expect(out).toContain("Transfer"); expect(out).toContain("Tother"); }); + + it("shows an unknown status as unknown, not as success", () => { + const row = (status: string) => + TextFormatters.accountHistory( + { address: "TXaddr", records: [{ time: 1700000000000, type: "Transfer", status }] }, + ctx(), + ).split("\n")[3]!; + expect(row("unknown")).toContain("❓"); + expect(row("unknown")).not.toContain("✅"); + expect(row("ok")).toContain("✅"); + expect(row("failed")).toContain("❌"); + }); + + it("names a TRC10 transfer by its asset, in base units when its precision is unknown", () => { + const out = TextFormatters.accountHistory( + { + address: "TXaddr", + records: [ + { + type: "TransferAsset", + amount: "1", + rawAmount: "1000000", + assetId: "1007445", + symbol: "TEN", + status: "ok", + }, + { + type: "TransferAsset", + amount: "", + rawAmount: "1000000", + assetId: "1007445", + status: "ok", + }, + ], + }, + ctx(), + ).split("\n"); + expect(out[3]).toContain("1 TEN"); + expect(out[4]).toContain("1000000 base units of TRC10 1007445"); + expect(out.join("\n")).not.toContain("TRX"); + }); }); describe("sign-only receipt", () => { @@ -943,13 +1023,10 @@ describe("sign-only receipt", () => { net: { family: "tron", nativeSymbol: "TRX", id: "nile" }, } as never; - // The signature is the product of a signing command and has to be copied somewhere, so it must - // never be shortened. Before this it showed a truncated txID — redundant with the TxID row and - // useless as output. it("prints the signature in full", () => { const sig = "16a2ec10".repeat(16) + "1C"; const out = TextFormatters.txReceipt( - { ...base, signed: { txID: "abc123", signature: [sig] } }, + { ...base, signed: { txID: "abc123", signature: [sig] } as never }, ctx, ) as string; expect(out).toContain(sig); @@ -959,26 +1036,25 @@ describe("sign-only receipt", () => { it("numbers the signatures when a multi-sig transaction carries several", () => { const out = TextFormatters.txReceipt( - { ...base, signed: { txID: "abc123", signature: ["aa".repeat(65), "bb".repeat(65)] } }, + { + ...base, + signed: { txID: "abc123", signature: ["aa".repeat(65), "bb".repeat(65)] } as never, + }, ctx, ) as string; expect(out).toContain("Signature 1"); expect(out).toContain("Signature 2"); }); - // tx sign estimates nothing, so there is no fee to report and the row is dropped entirely - // rather than rendered as "unknown". it("omits the fee row when nothing was estimated", () => { const out = TextFormatters.txReceipt( - { ...base, signed: { signature: ["aa".repeat(65)] } }, + { ...base, signed: { signature: ["aa".repeat(65)] } as never }, ctx, ) as string; expect(out).not.toContain("Fee"); }); }); -// `config networks` used to be a list of ids (an array, which rendered fine). It is now a map of -// id -> endpoint, and `aliases` is a map too — both printed as "[object Object]" until this. describe("config renders map-valued keys", () => { it("renders a single-key read as a titled block", () => { const out = TextFormatters.config({ @@ -986,17 +1062,12 @@ describe("config renders map-valued keys", () => { value: { nile: "tron:3448148188", sepolia: "eip155:11155111" }, }); - // `titled` is the house shape: bare title line, then indented fields (no colon) — see - // asset.ts / exchange.ts / governance.ts for the same form. expect(out.split("\n")[0]).toBe("aliases"); expect(out).toMatch(/^ {2}nile\s+tron:3448148188$/m); expect(out).toMatch(/^ {2}sepolia\s+eip155:11155111$/m); expect(out).not.toContain("[object Object]"); }); - // The whole-config view used to SUMMARISE a map by listing its keys ("networks tron:3448148188, - // eip155:1"), which said a network existed but never what it was configured with. The revised shape: - // config renders every configurable value, nested — the file's own shape, indented. it("expands map-valued keys in the whole-config view", () => { const out = TextFormatters.config({ defaultOutput: "text", @@ -1006,8 +1077,6 @@ describe("config renders map-valued keys", () => { }, }); - // No trailing colon: a network id already contains one, so `tron:3448148188:` would hide where the - // id ends — and the id is what a reader copies into `--network` / `config networks.`. expect(out).toMatch(/^networks$/m); expect(out).toMatch(/^ {2}tron:3448148188$/m); expect(out).toMatch(/^ {4}httpEndpoint {2}nile\.trongrid\.io$/m); @@ -1015,7 +1084,6 @@ describe("config renders map-valued keys", () => { expect(out).not.toContain("[object Object]"); }); - // Two levels deep, under a named read: the block below a network is its fields, indented once. it("renders a single network read as a nested block", () => { const out = TextFormatters.config({ key: "networks.tron:3448148188", @@ -1031,7 +1099,6 @@ describe("config renders map-valued keys", () => { expect(out).toMatch(/^ {2}apiKey {8}\*{8}$/m); }); - // A scalar leaf keeps its one-line form; nesting must not swallow the simple case. it("keeps a scalar read on one line", () => { expect(TextFormatters.config({ key: "timeoutMs", value: 60_000 })).toMatch( /^timeoutMs {2}60000$/, @@ -1039,8 +1106,6 @@ describe("config renders map-valued keys", () => { }); }); -// A distinction the renderer previously did not draw: a VALUATION gets 2 decimals, a UNIT -// PRICE gets 4. This column had no coverage at all, so the two were silently the same. describe("portfolio price vs valuation precision", () => { const portfolio = (priceUsd: string, valueUsd: string) => TextFormatters.accountPortfolio( @@ -1064,8 +1129,6 @@ describe("portfolio price vs valuation precision", () => { expect(portfolio("0.0001", "0.10")).toContain("$0.0001"); }); - // The Balance column reads the base-unit integer and the row's own decimals, so it obeys the - // same six-digit rule as `account balance`. It used to print the pre-scaled string verbatim. it("caps the balance column at six fractional digits", () => { const out = TextFormatters.accountPortfolio( { @@ -1089,8 +1152,6 @@ describe("portfolio price vs valuation precision", () => { }); }); -// The address already says which chain it is (T… / 0x…), so a Family column repeats it in -// vocabulary the user never needs otherwise. Externally the book is a flat name↔address map. describe("contact list is a flat name-to-address map", () => { const listed = () => TextFormatters.contactList({ @@ -1111,8 +1172,6 @@ describe("contact list is a flat name-to-address map", () => { }); }); -// The address column follows the SELECTED NETWORK's family. text never puts both families -// side by side — the table doubles in width and the user only cares about the chain in use. describe("list shows one family's addresses at a time", () => { const accounts = [ { @@ -1159,8 +1218,6 @@ describe("list shows one family's addresses at a time", () => { expect(out).not.toContain("TSRmq8kP9dEf"); }); - // A single-family account has nothing to show on the other family's network, and an empty row - // is worse than no row. it("hides single-family accounts that do not belong to the selected network", () => { expect(listed("tron")).not.toContain("ledger-evm"); expect(listed("evm")).not.toContain("team-vault"); @@ -1172,9 +1229,6 @@ describe("list shows one family's addresses at a time", () => { }); }); -// `--keystore` picks ONE of a seed account's two keys, and with --network omitted that choice -// comes from config.defaultNetwork. The receipt has to say which key was written, or the same -// command on two machines silently produces different secrets with nothing to tell them apart. describe("keystore receipt names the exported family", () => { const receipt = (extra: Record) => TextFormatters.walletBackup({ @@ -1191,8 +1245,6 @@ describe("keystore receipt names the exported family", () => { expect(receipt({ family: "evm" })).toMatch(/^\s*Family\s+evm$/m); }); - // A mnemonic covers every family, so there is nothing to disambiguate and a row would imply - // a choice that was never made. it("omits the row for a native backup", () => { const out = TextFormatters.walletBackup({ accountId: "wlt_a.0", diff --git a/ts/src/adapters/inbound/cli/commands/token-selector.ts b/ts/src/adapters/inbound/cli/commands/token-selector.ts deleted file mode 100644 index 45522a826..000000000 --- a/ts/src/adapters/inbound/cli/commands/token-selector.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { z } from "zod"; - -/** TRC20 contract XOR TRC10 asset id; exactly one selector is required. */ -export function tokenSelector( - value: { contract?: string; assetId?: string }, - context: z.RefinementCtx, -): void { - const count = [value.contract, value.assetId].filter( - (candidate) => candidate !== undefined, - ).length; - if (count !== 1) { - context.addIssue({ - code: "custom", - path: ["contract"], - message: "exactly one of --contract (TRC20) or --asset-id (TRC10) is required", - params: { errorCode: "invalid_option" }, - }); - } -} diff --git a/ts/src/adapters/inbound/cli/commands/token.ts b/ts/src/adapters/inbound/cli/commands/token.ts index c75c669ec..6d67d1acd 100644 --- a/ts/src/adapters/inbound/cli/commands/token.ts +++ b/ts/src/adapters/inbound/cli/commands/token.ts @@ -1,44 +1,38 @@ import { z } from "zod"; -import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { ChainSpec, ExclusiveGroup, FamilyBinding } from "../contracts/index.js"; import type { TronTokenService } from "../../../../application/use-cases/tron/token-service.js"; -import { Schemas, addressFieldsFor, allRefines } from "../schemas/index.js"; +import { Schemas, addressFieldsFor } from "../schemas/index.js"; import { TextFormatters } from "../render/index.js"; -import { tokenSelector } from "./token-selector.js"; import type { TokenBookService } from "../../../../application/use-cases/token-book-service.js"; import type { EvmTokenService } from "../../../../application/use-cases/evm/token-service.js"; -/** Shared across families: every family has a token contract. TRC10 does not exist outside TRON, - * so `--asset-id` — and the "exactly one selector" rule it is half of — belong to the TRON - * binding below, not here. */ +// TRC10 selectors belong to the TRON binding; only contract addresses are shared. const selectorFields = z.object({ contract: Schemas.address().optional().describe("token contract address"), }); -/** the EVM half: no TRC10 equivalent exists, so `--contract` is simply required, and the shared - * neutral field is validated as an EVM address here. */ const evmSelector = { - refine: allRefines( - (v: { contract?: string }, ctx: z.RefinementCtx) => { - if (v.contract === undefined) { - ctx.addIssue({ code: "custom", path: ["contract"], message: "--contract is required" }); - } - }, - addressFieldsFor("evm", "contract"), - ), + fields: z.object({ + contract: Schemas.address().describe("token contract address"), + }), + refine: addressFieldsFor("evm", "contract"), }; -/** the TRON half of the selector: TRC10 asset id + the XOR rule + TRON address format. */ const tronSelector = { fields: z.object({ - assetId: z - .string() - .regex(/^\d+$/) - .optional() - .describe("TRC10 numeric asset id; provide exactly one of --asset-id or --contract"), + assetId: z.string().regex(/^\d+$/).optional().describe("TRC10 numeric asset id"), }), - refine: allRefines(tokenSelector, addressFieldsFor("tron", "contract")), + refine: addressFieldsFor("tron", "contract"), }; +// --asset-id is a TRON field, so on EVM the group reduces to --contract alone. +const selectorGroup: ExclusiveGroup[] = [ + { + label: "the token: a contract address, or a TRC10 id on TRON", + flags: ["contract", "asset-id"], + }, +]; + export const tokenBalanceSpec: ChainSpec = { path: ["token", "balance"], network: "optional", @@ -47,6 +41,7 @@ export const tokenBalanceSpec: ChainSpec = { capability: "account.balance.token", summary: "Show a single token balance", baseFields: selectorFields, + exclusive: selectorGroup, examples: [ { cmd: "wallet-cli token balance --contract TR7... --network nile" }, { cmd: "wallet-cli token balance --contract 0xA0b8... --network sepolia" }, @@ -61,7 +56,7 @@ export const tokenBalanceEvmBinding = (svc: EvmTokenService): FamilyBinding => ( export const tokenInfoEvmBinding = (svc: EvmTokenService): FamilyBinding => ({ ...evmSelector, - run: async (_ctx, net, input) => svc.info(net, input), + run: async (ctx, net, input) => svc.info(ctx.deadline, net, input), }); export const tokenAddEvmBinding = (svc: EvmTokenService): FamilyBinding => ({ @@ -87,6 +82,7 @@ export const tokenInfoSpec: ChainSpec = { capability: "account.balance.token", summary: "Show token metadata", baseFields: selectorFields, + exclusive: selectorGroup, examples: [ { cmd: "wallet-cli token info --contract TR7... --network nile" }, { cmd: "wallet-cli token info --contract 0xA0b8... --network sepolia" }, @@ -96,7 +92,7 @@ export const tokenInfoSpec: ChainSpec = { export const tokenInfoTronBinding = (svc: TronTokenService): FamilyBinding => ({ ...tronSelector, - run: async (_ctx, net, input) => svc.info(net, input), + run: async (ctx, net, input) => svc.info(ctx.deadline, net, input), }); export const tokenAddSpec: ChainSpec = { @@ -106,12 +102,11 @@ export const tokenAddSpec: ChainSpec = { auth: "none", capability: "token.tokenbook", summary: "Add a token to the address book", - // The book is PER NETWORK, and the metadata comes from the contract — the one moment - // decimals are checked against the chain (see the token service). description: "Add a token to the address book of the selected network, fetching its name,\n" + "symbol and decimals from the contract", baseFields: selectorFields, + exclusive: selectorGroup, examples: [ { cmd: "wallet-cli token add --contract TR7... --network nile" }, { cmd: "wallet-cli token add --contract 0xA0b8... --network sepolia" }, @@ -131,7 +126,6 @@ export const tokenListSpec: ChainSpec = { auth: "none", capability: "token.tokenbook", summary: "List the address book", - // Which book depends on the network, and it holds two layers. description: "List the address book of the selected network (official + user entries)", baseFields: z.object({}), examples: [ @@ -141,7 +135,6 @@ export const tokenListSpec: ChainSpec = { formatText: TextFormatters.tokenBookList, }; -/** Shared by every family: listing merges the book's two layers and touches no chain. */ export const tokenListBinding = (svc: TokenBookService): FamilyBinding => ({ run: async (ctx, net) => svc.list(ctx, net), }); @@ -153,10 +146,10 @@ export const tokenRemoveSpec: ChainSpec = { auth: "none", capability: "token.tokenbook", summary: "Remove a user-added token", - // The refusal on an official entry is a rule worth stating before it is hit. description: "Remove a user-added token from the address book. Official entries cannot be\n" + "removed.", baseFields: selectorFields, + exclusive: selectorGroup, examples: [ { cmd: "wallet-cli token remove --contract TR7... --network nile" }, { cmd: "wallet-cli token remove --contract 0xA0b8... --network sepolia" }, diff --git a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts index d4b2f68a7..0fcf07aa0 100644 --- a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts +++ b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts @@ -23,8 +23,6 @@ import { voteCastSpec } from "./vote.js"; import type { ChainSpec } from "../contracts/index.js"; import { governanceTxModeFields, tronTxModeFields, txModeFields } from "./shared.js"; -// --permission-id and --expiration are TRON multi-signature concepts and live on the TRON -// binding's field set; these assertions are about their wording, which did not move. const txModeAndTron = { ...txModeFields, ...tronTxModeFields }; describe("transaction option argv coercion", () => { @@ -52,9 +50,6 @@ describe("transaction option argv coercion", () => { }); }); -// These two flags exist only for multi-sign, and their help used to state neither what the values -// mean nor what the limits are. A co-signer reading `--help` could not tell which permission group -// to pass, nor that the collection window they were extending is capped at 24h. describe("shared --permission-id / --expiration document their semantics", () => { const describeOf = (name: keyof typeof txModeAndTron): string => (txModeAndTron[name] as { description?: string }).description ?? ""; @@ -70,7 +65,6 @@ describe("shared --permission-id / --expiration document their semantics", () => const schema = z.object(txModeAndTron); expect(schema.safeParse({ buildOnly: true, expiration: 86_400_000 }).success).toBe(true); expect(schema.safeParse({ buildOnly: true, expiration: 86_400_001 }).success).toBe(false); - // the description must quote that same bound — a stale number here is worse than none expect(describeOf("expiration")).toContain("86400000"); expect(describeOf("expiration")).toContain("24h"); }); @@ -79,17 +73,12 @@ describe("shared --permission-id / --expiration document their semantics", () => expect(describeOf("expiration")).toMatch(/node default.*60s/); }); - // --build-only produces an artifact nobody can use without knowing what consumes it. Both - // multi-sig routes start here — the hex relay through `tx sign` and the TronLink queue through - // `tx multisig` (docs/commands/tx/index.md) — so naming only one of them would be worse than - // naming neither: it reads as "this flag is for the service path". it("says what the unsigned hex is for, and does not narrow it to one route", () => { const text = describeOf("buildOnly"); expect(text).toMatch(/tx sign/); expect(text).toMatch(/tx multisig/); }); - // permission update carries its own copies of these fields; two copies of a description drift. it("permission update shows the same text, not a stale copy", () => { const shape = permissionUpdateSpec.baseFields.shape as Record; expect(shape.permissionId?.description).toBe(describeOf("permissionId")); @@ -98,10 +87,6 @@ describe("shared --permission-id / --expiration document their semantics", () => }); }); -// `txModeFields` is shared, so adding a flag to it silently widens 14 commands at once while their -// individual reference pages stay behind — which is exactly how --build-only / --permission-id / -// --expiration ended up documented on only 3 of the 14. A page that documents the transaction modes -// at all must document all of them. describe("reference pages keep up with the shared transaction options", () => { const DOCS = fileURLToPath(new URL("../../../../../docs/commands/", import.meta.url)); const REQUIRED = ["--build-only", "--permission-id", "--expiration"]; @@ -116,11 +101,7 @@ describe("reference pages keep up with the shared transaction options", () => { ); } - // Presence is not enough. These two rows carried the pre-A-2/A-3 wording — a permission id with - // no key to its values, and a range with no readable bound — so a reader who went to the - // reference page instead of `--help` still could not tell which group to pass. it("every --permission-id row carries the same value key the flag's help does", () => { - // taken from the schema, not restated here: one wording, two surfaces const key = /\((0=owner[^)]*)\)/.exec( (tronTxModeFields.permissionId as { description?: string }).description ?? "", )?.[1]; @@ -158,8 +139,7 @@ describe("reference pages keep up with the shared transaction options", () => { it("every page offering --sign-only also documents the rest of the shared modes", () => { const behind = pages(DOCS) .map((path) => ({ path, text: readFileSync(path, "utf8") })) - // An Options-table ROW for --sign-only means the command itself carries the shared object; - // prose mentions (tx/index, tx/sign) merely refer to other commands and must not be flagged. + // Match option rows; prose may refer to flags on other commands. .filter(({ text }) => /^\| `--sign-only`/m.test(text)) .filter(({ text }) => REQUIRED.some((flag) => !text.includes(flag))) .map(({ path }) => relative(DOCS, path)); @@ -168,21 +148,11 @@ describe("reference pages keep up with the shared transaction options", () => { }); }); -/** - * The governance group re-declared `--permission-id` with an int32 ceiling, so `--help` advertised a - * range the application layer then refused: `transactionMode()` accepts 0..9 and rejects anything - * above it with `invalid_option`, after the command has already started. TRON has at most eight - * active permissions (ids 2..9) plus owner (0), so 0..9 is the real bound and the override was - * simply wrong — it also downgraded the failure from a schema `invalid_value` to a runtime - * `invalid_option`, classifying the same mistake differently from every non-governance command. - */ describe("governance --permission-id shares the protocol bound with every other command", () => { const field = (fields: Record) => z.object(fields as never); it("rejects a permission id above the protocol maximum, as the TRON field set does", () => { expect(field(governanceTxModeFields).safeParse({ permissionId: "10" }).success).toBe(false); - // The bound lives with the flag, which is TRON-only: a permission group is a TRON concept, - // so the shared set no longer declares it at all. expect(field(tronTxModeFields).safeParse({ permissionId: "10" }).success).toBe(false); expect(Object.keys(txModeFields)).not.toContain("permissionId"); }); @@ -194,12 +164,6 @@ describe("governance --permission-id shares the protocol bound with every other }); }); -/** - * Extracting `tronTxModeFields` out of the shared set silently dropped --permission-id and - * --expiration from every TRON-only write that did not re-add them: multi-signature use (signing - * under a non-owner active group) and extending the collection window both became impossible on - * those commands. They are single-family, so the fields belong in `baseFields` directly. - */ describe("TRON-only writes still carry the multi-signature transaction options", () => { const specs: Array<[string, ChainSpec]> = [ ["account activate", accountActivateSpec], @@ -214,7 +178,6 @@ describe("TRON-only writes still carry the multi-signature transaction options", ["exchange withdraw", exchangeWithdrawSpec], ["reward withdraw", rewardWithdrawSpec], ["vote cast", voteCastSpec], - // stake's read commands (info, delegated) are built separately and take no mode flags ...stakeDefinitions({} as never) .filter(({ spec }) => spec.broadcasts) .map(({ spec }) => [spec.path.join(" "), spec] as [string, ChainSpec]), @@ -225,15 +188,12 @@ describe("TRON-only writes still carry the multi-signature transaction options", expect(Object.keys(shape)).toEqual(expect.arrayContaining(["permissionId", "expiration"])); }); - // A flag whose help text drifts from the shared one is a second source of truth; these commands - // reuse the exported object precisely so there is only ever one. it.each(specs)("%s reuses the shared field definitions verbatim", (_name, spec) => { const shape = spec.baseFields.shape as Record; expect(shape.permissionId).toBe(tronTxModeFields.permissionId); expect(shape.expiration).toBe(tronTxModeFields.expiration); }); - // --build-only keeps the shared wording: these are not governance writes, which override it. it.each(specs)("%s keeps the shared --build-only description", (_name, spec) => { const shape = spec.baseFields.shape as Record; expect(shape.buildOnly?.description).toBe( diff --git a/ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts b/ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts index f3cdc9ac2..f645cfdbb 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import type { TronMultisigCollaborationService } from "../../../../application/use-cases/tron/multisig-collaboration-service.js"; -import { txTronLinkMultisigBinding } from "./tx.js"; +import { txTronLinkMultisigBinding, txTronLinkMultisigSpec } from "./tx.js"; +import { chainInputSchema, parseInputSchema } from "../shell/index.js"; +import { CommandDeadline } from "../../../../application/services/command-deadline.js"; + +const NO_DEADLINE = new CommandDeadline(0); const A = "TLZz5XKerAAebbRdScB3jmSPr5DHSpGJJP"; const NETWORK = { family: "tron", nativeSymbol: "TRX", id: "tron:3448148188" } as never; @@ -14,6 +18,7 @@ function harness() { watch: vi.fn(async () => ({ action: "watch" })), } as unknown as TronMultisigCollaborationService; const ctx = { + deadline: NO_DEADLINE, resolveAddress: () => A, streams: { event: vi.fn() }, } as never; @@ -21,8 +26,6 @@ function harness() { } describe("tx multisig binding", () => { - // --create signs, so it must receive the execution scope. Handing it the bare address instead - // would still typecheck at the call site and fail only at runtime, in the signing step. it("routes --create through the signing scope, not the resolved address", async () => { const { service, ctx, run } = harness(); await run(ctx, NETWORK, { create: true, hex: "aabb" } as never); @@ -44,8 +47,35 @@ describe("tx multisig binding", () => { const { service, ctx, run } = harness(); await run(ctx, NETWORK, {} as never); - expect(service.list).toHaveBeenCalledWith(NETWORK, A); + expect(service.list).toHaveBeenCalledWith(NO_DEADLINE, NETWORK, A); expect(service.create).not.toHaveBeenCalled(); expect(service.sign).not.toHaveBeenCalled(); }); }); + +describe("tx multisig input groups", () => { + const schema = chainInputSchema( + txTronLinkMultisigSpec, + txTronLinkMultisigBinding({} as never), + ).input; + + it.each([ + { create: true, hex: "aabb", file: "tx.hex" }, + { create: true }, + { hex: "aabb" }, + { sign: TX_ID, file: "tx.hex" }, + { create: true, sign: TX_ID, hex: "aabb" }, + { create: true, watch: true, hex: "aabb" }, + ])("reports %j as invalid_option", (input) => { + expect(() => parseInputSchema(schema, input)).toThrowError( + expect.objectContaining({ code: "invalid_option" }), + ); + }); + + it("accepts --create with one transaction source", () => { + expect(parseInputSchema(schema, { create: true, file: "tx.hex" })).toMatchObject({ + create: true, + file: "tx.hex", + }); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts index 6f3cb077f..791b7ca5e 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts @@ -26,8 +26,6 @@ describe("tx sign spec", () => { expect(txSignSpec.baseFields.safeParse({ hex: "abcd", offline: true }).success).toBe(true); }); - // The online permission/approval check is the default; --offline opts out of it. - // --check is gone: it was the inverted spelling and never shipped in a release. it("exposes --offline and no longer exposes --check", () => { expect(Object.keys(txSignSpec.baseFields.shape)).toContain("offline"); expect(Object.keys(txSignSpec.baseFields.shape)).not.toContain("check"); @@ -38,31 +36,30 @@ describe("tx sign spec", () => { expect(parsed.offline).toBe(false); }); - // the payload is not a secret, so argv is the only channel — no --tx-stdin on this command. it("declares no stdin channel", () => { expect(txSignSpec.stdin).toBeUndefined(); }); }); describe("tx sign binding", () => { - it("parses the JSON payload and hands it to the service", async () => { + it("hands the parsed JSON payload to the checked offline signing service", async () => { let received: unknown; const svc = { - sign: async (_c: unknown, _n: unknown, tx: unknown) => { - received = tx; + signJson: async (_c: unknown, _n: unknown, tx: unknown, options: unknown) => { + received = { tx, options }; return { kind: "sign" }; }, }; - await txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run(ctx, net, { + await txSignTronBinding(svc as never, {} as never).run(ctx, net, { transaction: '{"txID":"abc"}', }); - expect(received).toEqual({ txID: "abc" }); + expect(received).toEqual({ tx: { txID: "abc" }, options: { origin: "offline" } }); }); it("rejects malformed JSON with invalid_value", async () => { - const svc = { sign: async () => ({}) }; + const svc = { signJson: async () => ({}) }; await expect( - txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run(ctx, net, { + txSignTronBinding(svc as never, {} as never).run(ctx, net, { transaction: "not json", }), ).rejects.toMatchObject({ code: "invalid_value" }); @@ -99,68 +96,56 @@ describe("tx sign binding", () => { }, }; - // Default: verify signer permission and resulting weight online. A co-signer who is - // not in the permission group, or who already signed, must fail before a signature is produced — - // not silently emit a hex that only `tx broadcast` will reject, after it has been passed on. it("routes hex signing through the multisig authorization service by default", async () => { await expect( - txSignTronBinding( - {} as never, - rejectOffline as never, - checkedSigner() as never, - {} as never, - ).run(ctx, net, { hex: "abcd", offline: false }), + txSignTronBinding(rejectOffline as never, checkedSigner() as never).run(ctx, net, { + hex: "abcd", + offline: false, + }), ).resolves.toMatchObject({ checked: true, signerWeight: 1 }); }); it("treats an absent --offline exactly like --offline false", async () => { await expect( - txSignTronBinding( - {} as never, - rejectOffline as never, - checkedSigner() as never, - {} as never, - ).run(ctx, net, { hex: "abcd" }), + txSignTronBinding(rejectOffline as never, checkedSigner() as never).run(ctx, net, { + hex: "abcd", + }), ).resolves.toMatchObject({ checked: true }); }); it("routes --offline through the local signing service, never touching the node", async () => { await expect( - txSignTronBinding( - {} as never, - offlineSigner() as never, - rejectChecked as never, - {} as never, - ).run(ctx, net, { hex: "abcd", offline: true }), + txSignTronBinding(offlineSigner() as never, rejectChecked as never).run(ctx, net, { + hex: "abcd", + offline: true, + }), ).resolves.toMatchObject({ checked: false }); }); - it("writes --out on both routes", async () => { - for (const input of [ - { hex: "abcd", out: "signed.hex" }, - { hex: "abcd", out: "signed.hex", offline: true }, - ]) { - let written: unknown; - const writer = { - write: (path: string, hex: string) => { - written = { path, hex }; - }, - }; - const result = await txSignTronBinding( - {} as never, - offlineSigner() as never, - checkedSigner() as never, - writer as never, - ).run(ctx, net, input); - expect(written).toEqual({ path: "signed.hex", hex: "beef" }); - expect(result).toMatchObject({ out: "signed.hex", hex: "beef" }); - } + it("passes --out to the signing use case on both routes", async () => { + const outs: unknown[] = []; + const offline = { + sign: async (_c: unknown, _n: unknown, _h: unknown, _o: unknown, out: unknown) => { + outs.push(out); + return {}; + }, + }; + const checked = { + signChecked: async (_c: unknown, _n: unknown, _h: unknown, out: unknown) => { + outs.push(out); + return {}; + }, + }; + const binding = txSignTronBinding(offline as never, checked as never); + await binding.run(ctx, net, { hex: "abcd", out: "signed.hex" }); + await binding.run(ctx, net, { hex: "abcd", out: "offline.hex", offline: true }); + expect(outs).toEqual(["signed.hex", "offline.hex"]); }); it("rejects --offline with the JSON payload route, which has no online check to skip", async () => { - const svc = { sign: async () => ({ kind: "sign" }) }; + const svc = { signJson: async () => ({ kind: "sign" }) }; await expect( - txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run(ctx, net, { + txSignTronBinding(svc as never, {} as never).run(ctx, net, { transaction: "{}", offline: true, }), @@ -169,31 +154,23 @@ describe("tx sign binding", () => { }); describe("EVM tx sign binding", () => { - it("writes the signed raw transaction when --out is provided", async () => { - const signed = { raw: "0x02signed", hash: "0xabc" }; - const svc = { sign: async () => ({ kind: "sign", signed }) }; - let written: unknown; - const writer = { - write: (path: string, hex: string) => { - written = { path, hex }; + it("passes --out to the signing use case", async () => { + let received: unknown; + const svc = { + sign: async (_c: unknown, _n: unknown, hex: unknown, out: unknown) => { + received = { hex, out }; + return { kind: "sign" }; }, }; - const result = await txSignEvmBinding(svc as never, writer as never).run(ctx, net, { - hex: "0x02unsigned", - out: "signed.hex", - }); + await txSignEvmBinding(svc as never).run(ctx, net, { hex: "0x02unsigned", out: "signed.hex" }); - expect(written).toEqual({ path: "signed.hex", hex: signed.raw }); - expect(result).toMatchObject({ out: "signed.hex", signed }); + expect(received).toEqual({ hex: "0x02unsigned", out: "signed.hex" }); }); }); -// `tx sign --transaction ` was once the only form, and that contract must survive the -// multisig work: the JSON route still returns the transaction service's result verbatim, and the -// newer `kind:"tx-sign"` shape appears only for --hex/--file. describe("tx sign legacy JSON compatibility", () => { - it("returns the transaction service result unwrapped and unannotated", async () => { + it("returns the signing service result unwrapped and unannotated", async () => { const legacy = { kind: "sign", mode: "sign-only", @@ -201,12 +178,10 @@ describe("tx sign legacy JSON compatibility", () => { address: "T1", txId: "abc", }; - const svc = { sign: async () => legacy }; - const result = await txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run( - ctx, - net, - { transaction: '{"txID":"abc"}' }, - ); + const svc = { signJson: async () => legacy }; + const result = await txSignTronBinding(svc as never, {} as never).run(ctx, net, { + transaction: '{"txID":"abc"}', + }); expect(result).toEqual(legacy); expect(result).not.toHaveProperty("checked"); expect(result).not.toHaveProperty("approval"); @@ -217,8 +192,6 @@ describe("tx sign legacy JSON compatibility", () => { }); }); -// tx send carries both flavours, and conflating them would be a new lie in the help: -// the amount pair is jointly required, the asset trio is not (omit all three → native TRX). describe("tx send exclusive groups", () => { const groups = () => Object.fromEntries((txSendSpec.exclusive ?? []).map((g) => [g.flags[0], g])); @@ -231,11 +204,9 @@ describe("tx send exclusive groups", () => { }); it("marks the asset selector optional, since omitting it sends the native coin", () => { - // `--asset-id` is TRON-only and is declared on that binding. An exclusive group is - // spec-level and therefore shared by every family, so it may only name flags they all have. expect(groups().token).toEqual({ label: "which asset to send; omit for the network's native coin", - flags: ["token", "contract"], + flags: ["token", "contract", "asset-id"], select: "at-most-one", }); expect(txSendSpec.baseFields.safeParse({ to: "T...", amount: "1" }).success).toBe(true); @@ -250,9 +221,8 @@ describe("tx send exclusive groups", () => { }); describe("tx broadcast binding", () => { - const broadcastContext = (stdin?: string, wait = false) => + const broadcastContext = (stdin?: string) => ({ - wait, secrets: { has: (kind: string) => kind === "tx" && stdin !== undefined, pick: (inline: string | undefined) => inline ?? stdin, @@ -265,9 +235,6 @@ describe("tx broadcast binding", () => { expect(txBroadcastSpec.baseFields.safeParse({ file: "tx.hex" }).success).toBe(true); }); - // The exclusive group is the single source of truth for "pick exactly one of these four", - // on both the human help and the --json-schema catalog. Restating it inside a field - // description is a second copy that drifts the moment an input is added or renamed. it("declares every input in one exclusive group and nowhere else", () => { expect(txBroadcastSpec.exclusive).toEqual([ { @@ -316,7 +283,7 @@ describe("tx broadcast binding", () => { expect(received).toEqual({ txID: "abc" }); }); - it("rejects ambiguous input and --wait with --dry-run", async () => { + it("rejects ambiguous input", async () => { const service = { broadcastHex: async () => ({}), broadcastJson: async () => ({}) }; await expect( txBroadcastTronBinding(service as never).run(broadcastContext(), net, { @@ -325,12 +292,6 @@ describe("tx broadcast binding", () => { dryRun: false, }), ).rejects.toMatchObject({ code: "invalid_option" }); - await expect( - txBroadcastTronBinding(service as never).run(broadcastContext(undefined, true), net, { - hex: "aabb", - dryRun: true, - }), - ).rejects.toMatchObject({ code: "invalid_option" }); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index d60474217..e1086bc86 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -1,21 +1,21 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import { UsageError } from "../../../../domain/errors/index.js"; +import type { TronProtocolValue } from "../../../../domain/types/index.js"; import type { TronTransactionService } from "../../../../application/use-cases/tron/transaction-service.js"; import type { EvmTransactionService } from "../../../../application/use-cases/evm/transaction-service.js"; -import { gweiToWei } from "../../../../domain/fees/evm-gas.js"; +import { gweiToWei } from "../../../../domain/evm/evm-gas.js"; import type { TronSigService } from "../../../../application/use-cases/tron/sig-service.js"; import type { TronMultisigService } from "../../../../application/use-cases/tron/multisig-service.js"; import type { TronMultisigCollaborationService } from "../../../../application/use-cases/tron/multisig-collaboration-service.js"; -import type { TransactionArtifactWriter } from "../../../../application/ports/transaction-artifact-writer.js"; -import { Schemas, addressFieldsFor, allRefines } from "../schemas/index.js"; -import { amountSelector, tronTxModeFields, txModeFields, unifiedAmountFields } from "./shared.js"; +import { Schemas, addressFieldsFor } from "../schemas/index.js"; +import { tronTxModeFields, txModeFields, unifiedAmountFields, txModeExclusive } from "./shared.js"; import { TextFormatters } from "../render/index.js"; import { exactlyOne, readBoundedTextFile } from "./artifact.js"; +import { txHashInputPattern } from "../../../../domain/family/index.js"; +import { sunAmount } from "../../../../domain/amounts/index.js"; +import { DEFAULT_FEE_LIMIT_SUN } from "../../../../domain/fees/tron.js"; -// baseFields carry only what every family has: a recipient, an asset selector that is either a -// book symbol or a contract, and an amount. Everything priced or numbered per chain — TRON's -// fee limit and TRC10 asset id, EVM's gas flags and nonce — lives on that family's binding. const sendFields = z.object({ to: z .string() @@ -44,22 +44,17 @@ export const txSendSpec: ChainSpec = { summary: "Send native coins or tokens with human --amount", description: "Send the native coin, or a token selected with --token / --contract.\n" + - // A command whose Options show BOTH families' tags must say what the tags mean — - // help has to be readable on its own, without any other documentation. "Flags marked (TRON only) or (EVM only) are accepted only on networks of that family; using one on the other family is rejected.", baseFields: sendFields, exclusive: [ { label: "the amount to send", flags: ["amount", "raw-amount"], select: "exactly-one" }, - // omitting all three is the native-TRX path, so this set is optional as a whole. { - // `--asset-id` is TRON-only and is declared on that binding; this group is spec-level, so - // it may only name flags every family actually has. label: "which asset to send; omit for the network's native coin", - flags: ["token", "contract"], + flags: ["token", "contract", "asset-id"], select: "at-most-one", }, + txModeExclusive, ], - baseRefine: amountSelector, examples: [ { cmd: "wallet-cli tx send --to T... --amount 1 --network nile" }, { cmd: "wallet-cli tx send --to 0x742d... --amount 1 --network sepolia" }, @@ -77,13 +72,11 @@ const tronSendFields = z.object({ .optional() .describe("TRC10 numeric asset id; omit with --contract for native TRX"), feeLimit: Schemas.positiveIntString() - .default("100000000") + .default(DEFAULT_FEE_LIMIT_SUN) .describe("maximum TRX energy fee to burn for TRC20 transfers, in SUN"), ...tronTxModeFields, }); -/** EVM pricing: gwei for the per-gas fields, because that is the unit every wallet, explorer and - * human uses for gas — wei would be nine zeros longer and a real typo risk. */ const evmSendFields = z.object({ gasLimit: Schemas.positiveIntString() .optional() @@ -104,15 +97,11 @@ const evmSendFields = z.object({ .describe("transaction nonce; defaults to the account's pending nonce"), }); -/** EVM has no multi-signature relay, so the artifact both ends exchange is raw hex: an unsigned - * serialisation in, a signed one out. TRON's `--transaction` JSON has no EVM meaning. */ function evmHexOnly( input: { transaction?: string; hex?: string; file?: string }, hasStdin = false, ): string { - // `--transaction` no longer reaches here — it is declared by the TRON binding, so the flag check - // refuses it first. `--tx-stdin` is not a flag but a channel, so it still needs saying: a piped - // payload that is silently ignored is worse than one that is refused. + // Reject the TRON JSON stdin channel instead of ignoring its payload. if (hasStdin) { throw new UsageError( "invalid_option", @@ -122,25 +111,12 @@ function evmHexOnly( return hexInput(input); } -export const txSignEvmBinding = ( - svc: EvmTransactionService, - writer: TransactionArtifactWriter, -): FamilyBinding => ({ - run: async (ctx, net, input) => { - const result = await svc.sign(ctx, net, evmHexOnly(input)); - if (!input.out) return result; - // The two families name this differently: TRON's `tx sign` writes `result.hex`, EVM's writes - // `result.signed.raw` — not a shared field, so nothing here can be written generically. - writer.write(input.out, result.signed.raw); - return { ...result, out: input.out }; - }, +export const txSignEvmBinding = (svc: EvmTransactionService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.sign(ctx, net, evmHexOnly(input), input.out), }); export const txBroadcastEvmBinding = (svc: EvmTransactionService): FamilyBinding => ({ run: async (ctx, net, input) => { - if (input.dryRun && ctx.wait) { - throw new UsageError("invalid_option", "--wait cannot be used with --dry-run"); - } return svc.broadcast(ctx, net, evmHexOnly(input, ctx.secrets.has("tx")), input.dryRun === true); }, }); @@ -151,7 +127,7 @@ export const txSendEvmBinding = (svc: EvmTransactionService): FamilyBinding => ( run: async (ctx, net, input) => svc.send(ctx, net, { ...input, - // gwei on the flag, wei everywhere below it. + // CLI fees are in gwei; service fees are in wei. ...(input.maxFee === undefined ? {} : { maxFee: gweiToWei(input.maxFee) }), ...(input.priorityFee === undefined ? {} : { priorityFee: gweiToWei(input.priorityFee) }), }), @@ -159,13 +135,11 @@ export const txSendEvmBinding = (svc: EvmTransactionService): FamilyBinding => ( export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => ({ fields: tronSendFields, - refine: allRefines(tokenOptional, addressFieldsFor("tron", "contract")), - run: async (ctx, net, input) => svc.send(ctx, net, input), + refine: addressFieldsFor("tron", "contract"), + run: async (ctx, net, input) => + svc.send(ctx, net, { ...input, feeLimit: sunAmount(input.feeLimit) }), }); -/** TRON's JSON form of a signed transaction. Declared by the TRON binding alone, which is what - * makes help tag it `(TRON only)` and every other family refuse it — the same treatment `--asset-id` - * gets. EVM has no JSON transaction: it exchanges RLP hex. */ const tronBroadcastFields = z.object({ transaction: z.string().optional().describe("signed transaction JSON"), }); @@ -188,9 +162,6 @@ const broadcastFields = z.object({ export const txBroadcastSpec: ChainSpec = { path: ["tx", "broadcast"], stdin: "tx", - // The channel carries TRON's transaction JSON, and only the TRON binding reads it. Declaring - // that is what tags it `(TRON only)` in help and lets any other family refuse it outright, instead - // of ignoring a payload the caller piped in. stdinFamily: "tron", network: "optional", wallet: "none", @@ -200,7 +171,8 @@ export const txBroadcastSpec: ChainSpec = { summary: "Broadcast a presigned transaction", description: "Broadcast an already-signed transaction. It must have been built for the network you\n" + - "select — one built for another chain is rejected before it is sent.", + "select — one built for another chain is rejected before it is sent. Waits for\n" + + "confirmation by default; --no-wait returns the submitted receipt.", baseFields: broadcastFields, exclusive: [ { @@ -208,15 +180,6 @@ export const txBroadcastSpec: ChainSpec = { flags: ["transaction", "tx-stdin", "hex", "file"], }, ], - baseRefine: (input, context) => { - if ([input.hex, input.file].filter((entry) => entry !== undefined).length > 1) { - context.addIssue({ - code: "custom", - path: ["hex"], - message: "--hex and --file are mutually exclusive", - }); - } - }, examples: [ { cmd: "wallet-cli tx broadcast --file signed.hex --network nile" }, { cmd: "wallet-cli tx broadcast --file signed.hex --network sepolia" }, @@ -227,21 +190,7 @@ export const txBroadcastSpec: ChainSpec = { export const txBroadcastTronBinding = (service: TronMultisigService): FamilyBinding => ({ fields: tronBroadcastFields, - refine: (input, context) => { - if ( - [input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length > 1 - ) { - context.addIssue({ - code: "custom", - path: ["transaction"], - message: "--transaction, --hex, and --file are mutually exclusive", - }); - } - }, run: async (ctx, net, input) => { - if (input.dryRun && ctx.wait) { - throw new UsageError("invalid_option", "--wait cannot be used with --dry-run"); - } const stdin = ctx.secrets.has("tx"); exactlyOne( [input.transaction, stdin ? true : undefined, input.hex, input.file], @@ -279,27 +228,23 @@ export const txApprovalsSpec: ChainSpec = { capability: "tx.multisig.local", summary: "Show collected signatures on a multi-sig transaction", description: - "Inspect the transaction, selected permission group, approved signers, accumulated weight, missing weight, and expiration without signing.", + "Inspect the transaction, selected permission group, approved signers, accumulated weight, missing weight, and expiration without signing. On TRON, the selected network's node must hold the transaction's reference block.", baseFields: approvalsFields, exclusive: [{ label: "the transaction to inspect", flags: ["hex", "file"] }], - baseRefine: hexOrFileRefine, examples: [{ cmd: "wallet-cli tx approvals --file partially-signed.hex" }], formatText: TextFormatters.txApprovals, }; export const txApprovalsTronBinding = (service: TronMultisigService): FamilyBinding => ({ - run: async (_ctx, network, input) => service.approvals(network, hexInput(input)), + run: async (ctx, network, input) => service.approvals(ctx.deadline, network, hexInput(input)), }); -/** The TRON compatibility path, declared by the TRON binding so help tags it `(TRON only)`; see - * tronBroadcastFields. Its two companion rules (`--out` / `--offline` are hex-only) travel with - * it, because they are only meaningful where `--transaction` exists. */ const tronSignFields = z.object({ transaction: z .string() .min(1) .optional() - .describe("unsigned transaction JSON; TRON compatibility path, never checked online"), + .describe("unsigned TRON transaction JSON; signed without contacting a node"), }); const signFields = z.object({ @@ -308,13 +253,13 @@ const signFields = z.object({ .boolean() .default(false) .describe( - "sign locally without contacting a node; skips the signer-permission and approval-weight checks", + "sign locally without contacting a node; skips the reference-block, signer-permission and approval-weight checks", ), out: z .string() .min(1) .optional() - .describe("atomically write co-signed transaction hex to this file"), + .describe("write co-signed transaction hex to this new file; an existing path is refused"), }); export const txSignSpec: ChainSpec = { @@ -330,24 +275,11 @@ export const txSignSpec: ChainSpec = { "later with `tx broadcast`. A transaction built for another network is rejected before it\n" + "is signed.\n" + "On TRON, --hex/--file append one signature to those already collected and report the\n" + - "resulting approval weight; --offline skips the online permission checks. An EVM\n" + - "transaction carries exactly one signature, so an already-signed one is refused.", + "resulting approval weight; --offline and --transaction contact no node and skip the\n" + + "online checks. An EVM transaction must be unsigned and carry the selected network's\n" + + "chain id.", baseFields: signFields, - // --hex/--file first: --transaction is the compatibility path, not the co-signing one. exclusive: [{ label: "the transaction to co-sign", flags: ["hex", "file", "transaction"] }], - baseRefine: (input, context) => { - // On a family without `--transaction` this is the whole rule: one of --hex / --file. - if ( - input.transaction === undefined && - [input.hex, input.file].filter((entry) => entry !== undefined).length !== 1 - ) { - context.addIssue({ - code: "custom", - path: ["hex"], - message: "provide exactly one of --hex or --file", - }); - } - }, examples: [ { cmd: `wallet-cli tx sign --transaction '{"txID":"...","raw_data":{...},"raw_data_hex":"..."}'`, @@ -364,22 +296,11 @@ export const txSignSpec: ChainSpec = { }; export const txSignTronBinding = ( - transactionService: TronTransactionService, signingService: TronSigService, multisigService: TronMultisigService, - writer: TransactionArtifactWriter, ): FamilyBinding => ({ fields: tronSignFields, refine: (input, context) => { - if ( - [input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length !== 1 - ) { - context.addIssue({ - code: "custom", - path: ["transaction"], - message: "provide exactly one of --transaction, --hex, or --file", - }); - } if (input.out && input.transaction) { context.addIssue({ code: "custom", @@ -396,30 +317,23 @@ export const txSignTronBinding = ( } }, run: async (ctx, net, input) => { - exactlyOne( - [input.transaction, input.hex, input.file], - "provide exactly one of --transaction, --hex, or --file", - ); if (!input.transaction) { const hex = hexInput(input); - const result = input.offline - ? await signingService.sign(ctx, net, hex) - : await multisigService.signChecked(ctx, net, hex); - if (!input.out) return result; - writer.write(input.out, result.hex); - return { ...result, out: input.out }; + return input.offline + ? signingService.sign(ctx, net, hex, { origin: "offline" }, input.out) + : multisigService.signChecked(ctx, net, hex, input.out); } if (input.out) throw new UsageError("invalid_option", "--out is only valid with --hex or --file"); if (input.offline) throw new UsageError("invalid_option", "--offline is only valid with --hex or --file"); - let tx: unknown; + let tx: TronProtocolValue; try { - tx = JSON.parse(input.transaction); + tx = JSON.parse(input.transaction) as TronProtocolValue; } catch { throw new UsageError("invalid_value", "TRON transaction must be JSON"); } - return transactionService.sign(ctx, net, tx); + return signingService.signJson(ctx, net, tx, { origin: "offline" }); }, }); @@ -440,14 +354,14 @@ const tronLinkMultisigFields = z.object({ .describe("file containing unsigned transaction hex used with --create"), sign: z .string() - .regex(/^(?:0x)?[0-9a-fA-F]{64}$/) + .regex(txHashInputPattern("tron")) .optional() .describe("fetch and co-sign one pending TronLink transaction by txId"), watch: z .boolean() .default(false) .describe( - "keep a WebSocket open and report only the count of transactions awaiting this account", + "keep a WebSocket open and report only the count of transactions awaiting this account; ends at the command deadline (--timeout 0 = until stopped)", ), }); @@ -456,13 +370,15 @@ export const txTronLinkMultisigSpec: ChainSpec = { network: "optional", wallet: "optional", auth: "conditional", + rejectsWait: "the collection is managed by TronLink and follows its own workflow", capability: "tx.multisig.tronlink", summary: "Create / co-sign a multi-sig transaction", description: "With no mode flag, list service-managed transactions for the selected account. --create signs\n" + "an UNSIGNED transaction locally and submits it, which opens the collection at the first\n" + "signature; --sign fetches the accumulated transaction, signs locally, and submits it;\n" + - "--watch opens the official WebSocket count-only notification channel.", + "--watch opens the official WebSocket count-only notification channel. --create and --sign\n" + + "check the transaction's reference block on the selected network.", requires: [ "TronLink service credentials — config tronlinkSecretId / tronlinkSecretKey / tronlinkChannel", ], @@ -491,7 +407,7 @@ export const txTronLinkMultisigBinding = ( const address = ctx.resolveAddress("tron"); if (input.create) return service.create(ctx, network, hexInput(input)); if (input.sign) return service.sign(ctx, network, input.sign); - if (!input.watch) return service.list(network, address); + if (!input.watch) return service.list(ctx.deadline, network, address); const controller = new AbortController(); const stop = () => controller.abort(); @@ -499,7 +415,7 @@ export const txTronLinkMultisigBinding = ( process.once("SIGTERM", stop); ctx.streams.event(`Watching TronLink multi-sig service for ${network.id} … (Ctrl-C to stop)`); try { - return await service.watch(network, address, controller.signal, (count) => { + return await service.watch(ctx.deadline, network, address, controller.signal, (count) => { ctx.streams.event( `🔔 You have ${count} transaction(s) to sign — view them with: wallet-cli tx multisig`, ); @@ -528,7 +444,7 @@ export const txStatusSpec: ChainSpec = { }; export const txStatusTronBinding = (svc: TronTransactionService): FamilyBinding => ({ - run: async (_ctx, net, input) => svc.status(net, input.txid), + run: async (ctx, net, input) => svc.status(ctx.deadline, net, input.txid), }); export const txStatusEvmBinding = (svc: EvmTransactionService): FamilyBinding => ({ @@ -552,39 +468,13 @@ export const txInfoSpec: ChainSpec = { }; export const txInfoTronBinding = (svc: TronTransactionService): FamilyBinding => ({ - run: async (_ctx, net, input) => svc.info(net, input.txid), + run: async (ctx, net, input) => svc.info(ctx.deadline, net, input.txid), }); export const txInfoEvmBinding = (svc: EvmTransactionService): FamilyBinding => ({ run: async (ctx, net, input) => svc.info(ctx, net, input.txid), }); -function tokenOptional( - value: { token?: string; contract?: string; assetId?: string }, - context: z.RefinementCtx, -): void { - const count = [value.token, value.contract, value.assetId].filter( - (candidate) => candidate !== undefined, - ).length; - if (count > 1) { - context.addIssue({ - code: "custom", - path: ["token"], - message: "choose at most one of --token, --contract or --asset-id", - }); - } -} - -function hexOrFileRefine(value: { hex?: string; file?: string }, context: z.RefinementCtx): void { - if ([value.hex, value.file].filter((entry) => entry !== undefined).length !== 1) { - context.addIssue({ - code: "custom", - path: ["hex"], - message: "provide exactly one of --hex or --file", - }); - } -} - function hexInput(input: { hex?: string; file?: string }): string { exactlyOne([input.hex, input.file], "provide exactly one of --hex or --file"); return input.hex ?? readBoundedTextFile(input.file!, 1024 * 1024 + 4096, "transaction hex file"); @@ -594,20 +484,13 @@ function tronLinkMultisigRefine( value: z.infer, context: z.RefinementCtx, ): void { - const modes = [value.create, value.sign !== undefined, value.watch].filter(Boolean).length; - if (modes > 1) { - context.addIssue({ - code: "custom", - path: ["create"], - message: "--create, --sign, and --watch are mutually exclusive", - }); - } const artifacts = [value.hex, value.file].filter((entry) => entry !== undefined).length; if (value.create && artifacts !== 1) { context.addIssue({ code: "custom", path: ["hex"], message: "--create requires exactly one of --hex or --file", + params: { errorCode: "invalid_option" }, }); } if (!value.create && artifacts !== 0) { @@ -615,6 +498,7 @@ function tronLinkMultisigRefine( code: "custom", path: ["hex"], message: "--hex/--file are only valid with --create", + params: { errorCode: "invalid_option" }, }); } } diff --git a/ts/src/adapters/inbound/cli/commands/typed-data.test.ts b/ts/src/adapters/inbound/cli/commands/typed-data.test.ts index ab19d6dc3..a125d8799 100644 --- a/ts/src/adapters/inbound/cli/commands/typed-data.test.ts +++ b/ts/src/adapters/inbound/cli/commands/typed-data.test.ts @@ -22,7 +22,6 @@ describe("typed-data sign spec", () => { expect(typedDataSignSpec.auth).toBe("required"); }); - // the payload is not a secret, so argv is the only channel — no --typed-data-stdin exists. it("declares no stdin channel", () => { expect(typedDataSignSpec.stdin).toBeUndefined(); }); @@ -55,8 +54,6 @@ describe("typed-data sign binding", () => { ).rejects.toMatchObject({ code: "invalid_value" }); }); - // BUG-V413-020: missing/malformed top-level fields (domain/types/message) don't decode as what - // --typed-data says they are, so they're invalid_payload (exit 2), not invalid_value. it("rejects a structurally invalid payload with invalid_payload", async () => { const svc = { sign: async () => stubResult }; await expect( diff --git a/ts/src/adapters/inbound/cli/commands/typed-data.ts b/ts/src/adapters/inbound/cli/commands/typed-data.ts index d5ab7a480..ed6b88dd0 100644 --- a/ts/src/adapters/inbound/cli/commands/typed-data.ts +++ b/ts/src/adapters/inbound/cli/commands/typed-data.ts @@ -9,7 +9,9 @@ const typedDataFields = z.object({ typedData: z .string() .min(1) - .describe(`EIP-712/TIP-712 JSON: {"domain":…,"types":…,"primaryType"?:…,"message":…}`), + .describe( + `EIP-712/TIP-712 JSON: {"domain":…,"types":…,"primaryType"?:…,"message":…}; a domain.chainId must match the selected network`, + ), }); export const typedDataSignSpec: ChainSpec = { @@ -23,7 +25,7 @@ export const typedDataSignSpec: ChainSpec = { description: "Sign an EIP-712 / TIP-712 typed-data payload.\n" + "`EIP712Domain` in `types` is ignored and `value` is accepted for `message`; address values\n" + - "are interpreted by the selected chain family's signing strategy.", + "are interpreted by the selected chain family's signing strategy. Nothing is broadcast.", baseFields: typedDataFields, examples: [ { @@ -45,6 +47,6 @@ export const typedDataSignBinding = (service: TypedDataService): FamilyBinding = throw new UsageError("invalid_value", "typed data must be JSON"); } const payload = normalizeTypedData(raw); - return service.sign(ctx, net.family, ctx.activeAccount, payload); + return service.sign(ctx, net, ctx.activeAccount, payload); }, }); diff --git a/ts/src/adapters/inbound/cli/commands/vote.ts b/ts/src/adapters/inbound/cli/commands/vote.ts index fce37c6b9..a4afaa8e8 100644 --- a/ts/src/adapters/inbound/cli/commands/vote.ts +++ b/ts/src/adapters/inbound/cli/commands/vote.ts @@ -1,17 +1,20 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronVoteService } from "../../../../application/use-cases/tron/vote-service.js"; -import { txModeFields, tronTxModeFields } from "./shared.js"; +import { txModeFields, tronTxModeFields, txModeExclusive } from "./shared.js"; import { TextFormatters } from "../render/index.js"; +import { + ELECTED_SR_COUNT, + MAX_REWARDED_RANK, + MAX_VOTE_ITEMS, +} from "../../../../domain/tron/governance/voting.js"; -// repeatable flag: the arity layer sets yargs `array: true`, so `--for` always arrives as a -// string[] (single or repeated) — no preprocess needed to normalize. const voteForField = z .array(z.string().min(1)) .min(1) - .max(30) + .max(MAX_VOTE_ITEMS) .describe( - "witness address = vote count (positive integer); repeatable; the set replaces all prior votes (at least 1, at most 30 entries)", + `witness address = vote count (positive integer); repeatable; the set replaces all prior votes (at least 1, at most ${MAX_VOTE_ITEMS} entries)`, ); export const voteCastSpec: ChainSpec = { @@ -26,6 +29,7 @@ export const voteCastSpec: ChainSpec = { "Cast or replace your FULL vote allocation. The set you pass becomes\n" + "your complete voting distribution; any previous SR not listed is set to zero.\n" + "Total votes must be at least 1. 1 vote = 1 Tron Power (TP) = 1 staked TRX.", + exclusive: [txModeExclusive], baseFields: z.object({ for: voteForField, ...txModeFields, @@ -52,9 +56,9 @@ export const voteListSpec: ChainSpec = { .number() .int() .positive() - .max(127) - .default(27) - .describe("number of ranks to return; max 127"), + .max(MAX_REWARDED_RANK) + .default(ELECTED_SR_COUNT) + .describe(`number of ranks to return; max ${MAX_REWARDED_RANK}`), candidates: z.boolean().default(false).describe("include non-elected candidates"), }), examples: [ @@ -65,7 +69,7 @@ export const voteListSpec: ChainSpec = { }; export const voteListTronBinding = (svc: TronVoteService): FamilyBinding => ({ - run: async (_ctx, net, input) => svc.list(net, input), + run: async (ctx, net, input) => svc.list(ctx.deadline, net, input), }); export const voteStatusSpec: ChainSpec = { diff --git a/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts index de913cfd3..5711bd2ca 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts @@ -1,8 +1,4 @@ -/** - * `backup` password gating, exercised through real dispatch — the ordering only exists there. - * A Ledger-only keystore has no master password sentinel, so verifying one can never succeed: - * the account-type check must therefore win over the password gate. - */ +// Ledger-only keystores may have no password; dispatch must check exportability first. import { describe, it, expect, vi } from "vitest"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -21,8 +17,9 @@ import { Prompter } from "../input/prompt/index.js"; import { WalletService } from "../../../../application/use-cases/wallet-service.js"; import { registerWalletCommands } from "./wallet.js"; import type { SessionRef } from "../contracts/index.js"; +import { CommandDeadline } from "../../../../application/services/command-deadline.js"; -// Cheap KDF for keystore encryption in this suite — see cheap-scrypt.ts. Production untouched. +// Use a cheap KDF for test fixtures. vi.mock( "@noble/hashes/scrypt.js", async () => import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), @@ -62,13 +59,22 @@ function fixture(opts: { tty: boolean }) { { write: () => ({ out: "unused", fileMode: "0600", bytes: 0 }) }, { append: () => {}, list: () => [] }, ); - registerWalletCommands(registry, { walletService, ledger: {} as any } as any); + registerWalletCommands(registry, { walletService }); const session: SessionRef = {}; const shellOpts: ShellOptions = { registry, globals: { output: "text", verbose: false }, - deps: { config, networkRegistry, streams, secrets, keystore, prompter, formatter }, + deps: { + config, + deadline: new CommandDeadline(0), + networkRegistry, + streams, + secrets, + accounts: walletService, + prompter, + formatter, + }, targetResolver: new TargetResolver({ networkRegistry, keystore }), caps: new CapabilityRegistry(), streams, @@ -98,7 +104,7 @@ describe("backup password gating", () => { await secrets.primePassword({ mode: "set" }); const { accountId } = keystore.import({ secret: VALID_MNEMONIC, type: "seed" }); secrets.clearPrimed(); - spyPrime.mockClear(); // ignore the prime used to seed the wallet above + spyPrime.mockClear(); await buildCli(shellOpts).parseAsync(["backup", accountId]); @@ -107,11 +113,6 @@ describe("backup password gating", () => { }); }); -/** - * The log filters only mean anything in --records mode; accepting one on an export would silently - * ignore what the caller asked for. --offset is the one that has to be asserted deliberately: it - * reads as a plain pagination flag, so a default value would hide it from the guard entirely. - */ describe("backup --records flag gating", () => { for (const args of [ ["--from", "2026-08-01"], @@ -129,8 +130,6 @@ describe("backup --records flag gating", () => { }); } - // BUG-V413-033: --keystore/--out describe an export, so combining either with --records is a - // flag conflict, not a bad value — machine-interface.md reserves invalid_option for that. for (const args of [["--keystore"], ["--out", "./out.json"]]) { it(`rejects ${args[0]} with --records as invalid_option`, async () => { const { shellOpts, spyPrime } = fixture({ tty: false }); @@ -151,7 +150,6 @@ describe("backup --records flag gating", () => { expect(spy).toHaveBeenCalledWith(expect.objectContaining({ offset: 1, limit: 5 })); }); - // offset is optional now; the 0 has to come from the service, or the emitted pagination changes. it("still reports offset 0 when --offset is omitted", async () => { const { shellOpts, walletService } = fixture({ tty: false }); const spy = vi.spyOn(walletService, "backupRecords"); @@ -163,13 +161,8 @@ describe("backup --records flag gating", () => { }); }); -// A V3 keystore holds ONE private key, and a seed account has a different one per family (derivation puts -// TRON at coin 195, EVM at coin 60). The selected network picks which — `family` is -// never exposed as a flag, because it is an internal concept and --network already selects it -// everywhere else in the CLI. describe("backup --keystore exports the selected network's key", () => { - // --network reaches the shell through parseGlobals, not through argv, so it is supplied the - // way the real runner supplies it. + // Supply globals separately, as the runner does. async function exportWith(network?: string) { const f = fixture({ tty: true }); await f.secrets.primePassword({ mode: "set" }); @@ -193,9 +186,7 @@ describe("backup --keystore exports the selected network's key", () => { expect(await exportWith(network)).toBe(family); }); - // No --family flag to forget, and no error either: the default network decides. The receipt - // names the family so the choice is never silent. it("falls back to the configured default network", async () => { - expect(await exportWith()).toBe("tron"); // built-in default is tron:728126428 + expect(await exportWith()).toBe("tron"); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts index 9062a8696..f5f508653 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts @@ -30,15 +30,12 @@ function command( const registry = new CommandRegistry(); registerWalletCommands(registry, { walletService: walletService as never, - ledger: {} as never, qr, }); const current = registry.resolveNeutral(["current"]); if (!current || isChainCommand(current)) { throw new Error("current command missing"); } - // ExecutionContext always carries these; --qr now reads the selected network to choose which - // family's address to encode, so a fixture without them represents no real invocation. const tronNet = { id: "tron:728126428", family: "tron", @@ -64,15 +61,29 @@ describe("current --qr", () => { }); expect(fixture.walletService.current).toHaveBeenCalledWith("wlt_selected"); - expect(fixture.qr.encode).toHaveBeenCalledWith(ADDRESS); + expect(fixture.qr.encode).toHaveBeenCalledWith(ADDRESS, expect.anything()); expect(result).toMatchObject({ receiveQr: "QR", receiveAddress: ADDRESS, }); }); - // json gets the answer --qr was asked for, and no terminal art: the QR is the only text-shaped - // part of this command, so it is the only part the output format decides. + it("draws for the terminal stdout writes to", async () => { + const fixture = command(); + const stdout = process.stdout as { isTTY?: boolean; columns?: number }; + const saved = { isTTY: stdout.isTTY, columns: stdout.columns }; + stdout.isTTY = true; + stdout.columns = 97; + try { + await fixture.current.run(fixture.context as never, fixture.tronNet as never, { qr: true }); + } finally { + stdout.isTTY = saved.isTTY; + stdout.columns = saved.columns; + } + + expect(fixture.qr.encode).toHaveBeenCalledWith(ADDRESS, { isTTY: true, columns: 97 }); + }); + it("gives JSON the receive address and never builds terminal art", async () => { const fixture = command({ output: "json" }); const result = await fixture.current.run(fixture.context as never, fixture.tronNet as never, { @@ -97,7 +108,6 @@ describe("current --qr", () => { const EVM_ADDRESS = "0xe2E1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; -/** the same fixture, but with the network selector the QR now reads. */ function withNetwork( addresses: Record, selected: string | undefined, @@ -108,7 +118,6 @@ function withNetwork( const registry = new CommandRegistry(); registerWalletCommands(registry, { walletService: walletService as never, - ledger: {} as never, qr, }); const current = registry.resolveNeutral(["current"]); @@ -120,18 +129,12 @@ function withNetwork( output: "text" as "text" | "json", warn: vi.fn(), }; - // the shell resolves --network (else config.defaultNetwork) and hands it to run(). - // A namespace is not a family — `eip155` addresses the EVM family — so this maps the two - // builtin namespaces by hand. Production never parses an id for this: it reads the - // descriptor's own `family` field. + // Fixture mapping: eip155 is the EVM namespace; runtime uses the network descriptor. const familyOf = (id: string) => (id.startsWith("eip155:") ? "evm" : "tron"); const network = net(selected ? familyOf(selected) : defaultFamily); return { current, context, network, qr }; } -// --qr encodes the address for the SELECTED NETWORK's family. Handing someone a receive -// code for a different chain is a fund-loss shape, so this never falls back to whatever the -// account happens to have. describe("current --qr picks the address by network family", () => { const both = { tron: ADDRESS, evm: EVM_ADDRESS }; @@ -142,7 +145,7 @@ describe("current --qr picks the address by network family", () => { }; expect(result.receiveAddress).toBe(EVM_ADDRESS); - expect(f.qr.encode).toHaveBeenCalledWith(EVM_ADDRESS); + expect(f.qr.encode).toHaveBeenCalledWith(EVM_ADDRESS, expect.anything()); }); it("encodes the TRON address when a TRON network is selected", async () => { @@ -177,9 +180,6 @@ describe("current --qr picks the address by network family", () => { expect(f.qr.encode).not.toHaveBeenCalled(); }); - // The regression this replaces: the family check sat behind `output === "text"`, so the same - // command exited 2 with family_mismatch for a human and 0 with success for an agent — and the - // agent is the one that cannot see the QR it was supposedly refusing to draw. it("refuses under -o json too, not just in text", async () => { const f = withNetwork({ evm: EVM_ADDRESS }, "tron:3448148188"); f.context.output = "json"; @@ -190,8 +190,6 @@ describe("current --qr picks the address by network family", () => { expect(f.qr.encode).not.toHaveBeenCalled(); }); - // The error is scoped to --qr. Looking at an account is local and must not depend on which - // network happens to be selected. it("still shows a mismatched single-family account when --qr is absent", async () => { const f = withNetwork({ evm: EVM_ADDRESS }, "tron:3448148188"); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.import-dedup.e2e.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.import-dedup.e2e.test.ts index cb5bae7ed..fadc69f70 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.import-dedup.e2e.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.import-dedup.e2e.test.ts @@ -1,13 +1,3 @@ -/** - * End-to-end coverage for scoping import dedup to one source kind, exercised through real - * CLI dispatch rather than calling Keystore.import directly: import private-key, then import the - * mnemonic that derives the SAME EVM address at account #0, and confirm both accounts survive - * (list shows two) and a wallet-management command on that shared address refuses to guess - * (ambiguous_account — list/backup/delete/rename/derive never accept a family to narrow - * with). `import private-key` / `import mnemonic` are TTY-only, so this drives them through the - * same fake-TTY prompter pattern wallet.test.ts and wallet.backup.test.ts already use instead of a - * real PTY. - */ import { describe, it, expect, vi } from "vitest"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -28,8 +18,9 @@ import { Derivation } from "../../../../domain/derivation/index.js"; import { WalletService } from "../../../../application/use-cases/wallet-service.js"; import { registerWalletCommands } from "./wallet.js"; import type { SessionRef } from "../contracts/index.js"; +import { CommandDeadline } from "../../../../application/services/command-deadline.js"; -// Cheap KDF for keystore encryption in this suite — see cheap-scrypt.ts. Production untouched. +// Use a cheap KDF for test fixtures. vi.mock( "@noble/hashes/scrypt.js", async () => import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), @@ -37,17 +28,11 @@ vi.mock( const MNEMONIC = "test test test test test test test test test test test junk"; const VALID_PASSWORD = "Abcdef1!"; -// the raw private key MNEMONIC derives at m/44'/60'/0'/0/0 — importing this key and later -// importing MNEMONIC as a seed land on the same EVM address via two DIFFERENT source kinds. const EVM_KEY_OF_MNEMONIC_ACCOUNT_0 = bytesToHex( Derivation.derive(Derivation.mnemonicToSeed(MNEMONIC), Derivation.path("evm", 0)).privateKey, ); -/** - * Fresh CLI plumbing (StreamManager allows exactly one `result` write, so it — and everything - * built from it — must be rebuilt per command) sharing one keystore/root and one hidden-answer - * script consumed across calls, the way a single interactive session would. - */ +// Rebuild streams per command: each StreamManager allows only one result write. function fixture(root: string, hiddenAnswers: string[], hiddenIdx: { i: number }) { const store = new AtomicFileStore(); const streams = new StreamManager("text", false); @@ -75,13 +60,22 @@ function fixture(root: string, hiddenAnswers: string[], hiddenIdx: { i: number } { write: () => ({ out: "unused", fileMode: "0600", bytes: 0 }) }, { append: () => {}, list: () => [] }, ); - registerWalletCommands(registry, { walletService, ledger: {} as any } as any); + registerWalletCommands(registry, { walletService }); const session: SessionRef = {}; const shellOpts: ShellOptions = { registry, globals: { output: "text", verbose: false }, - deps: { config, networkRegistry, streams, secrets, keystore, prompter, formatter }, + deps: { + config, + deadline: new CommandDeadline(0), + networkRegistry, + streams, + secrets, + accounts: walletService, + prompter, + formatter, + }, targetResolver: new TargetResolver({ networkRegistry, keystore }), caps: new CapabilityRegistry(), streams, @@ -93,8 +87,6 @@ function fixture(root: string, hiddenAnswers: string[], hiddenIdx: { i: number } describe("import private-key then import mnemonic on the same address (real CLI dispatch)", () => { it("keeps both accounts and makes wallet-management commands on the shared address ambiguous", async () => { - // Fresh $WALLET_CLI_HOME asks Set + Confirm master password before the first secret; the - // second import only asks to verify it (already established), then the mnemonic. const root = mkdtempSync(join(tmpdir(), "wallet-import-dedup-e2e-")); const hiddenIdx = { i: 0 }; const hiddenAnswers = [ diff --git a/ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts index 79d465fab..5317e047f 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { walletImportLedgerInput } from "./wallet.js"; +import { walletImportLedgerExclusive, walletImportLedgerFields } from "./wallet.js"; +import { exclusiveRefine } from "../schemas/exclusive.js"; + +const walletImportLedgerInput = walletImportLedgerFields.superRefine( + exclusiveRefine(walletImportLedgerExclusive, walletImportLedgerFields)!, +); const ok = (v: unknown) => walletImportLedgerInput.safeParse(v).success; @@ -28,8 +33,6 @@ describe("wallet import-ledger contract", () => { expect(r.success && (r.data as { index?: number }).index).toBe(2); }); - // Previously "rejects a hidden-family app (EVM is not currently exposed)". The app list is - // derived from FAMILIES[f].ledger, so wiring hw-app-eth exposes `--app ethereum` by itself. it("accepts the ethereum app now that the EVM family is ledger-wired", () => { expect(ok({ app: "ethereum", index: 0 })).toBe(true); }); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts index 069900050..103f4103b 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts @@ -1,8 +1,3 @@ -/** - * The `backup` mode switch and `import keystore`, exercised through real dispatch — the parts that - * only exist there: which envelope `command` a mode reports, whether a password is demanded, whether - * the TTY is asked to pick an account, and the flag combinations each mode refuses. - */ import { describe, it, expect, vi } from "vitest"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -23,8 +18,9 @@ import type { BackupRecord } from "../../../../application/ports/backup-records. import { KeystoreV3 } from "../../../../domain/keystore/index.js"; import { registerWalletCommands } from "./wallet.js"; import type { SessionRef } from "../contracts/index.js"; +import { CommandDeadline } from "../../../../application/services/command-deadline.js"; -// Cheap KDF for keystore encryption in this suite — see cheap-scrypt.ts. Production untouched. +// Use a cheap KDF for test fixtures. vi.mock( "@noble/hashes/scrypt.js", async () => import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), @@ -36,7 +32,7 @@ const RAW_KEY = "4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1 const KEYSTORE_PW = "keystore-file-pw"; const record = (over: Partial = {}): BackupRecord => ({ - operation: "backup", + operation: "native-export", accountId: "wlt_seeded.0", account: "TSeeded", label: "seeded", @@ -55,7 +51,6 @@ function fixture(opts: { tty: boolean; records?: BackupRecord[] }) { isTTY: () => opts.tty, async question(prompt: string, _hidden: boolean) { asked.push(prompt); - // the keystore file's own password is a distinct prompt from the master password return /keystore/i.test(prompt) ? KEYSTORE_PW : VALID_PASSWORD; }, async readKey() { @@ -90,14 +85,22 @@ function fixture(opts: { tty: boolean; records?: BackupRecord[] }) { }, { append: () => {}, list: () => opts.records ?? [] }, ), - ledger: {} as any, } as any); const session: SessionRef = {}; const shellOpts: ShellOptions = { registry, globals: { output: "json", verbose: false }, - deps: { config, networkRegistry, streams, secrets, keystore, prompter, formatter }, + deps: { + config, + deadline: new CommandDeadline(0), + networkRegistry, + streams, + secrets, + accounts: new WalletService(keystore, {} as never, {} as never, {} as never), + prompter, + formatter, + }, targetResolver: new TargetResolver({ networkRegistry, keystore }), caps: new CapabilityRegistry(), streams, @@ -108,7 +111,6 @@ function fixture(opts: { tty: boolean; records?: BackupRecord[] }) { return { shellOpts, keystore, secrets, spyPrime, spySelect, root, asked, writes, envelope }; } -/** an account whose secret can be exported, with the master password already established. */ async function seedWallet( f: ReturnType, secret = VALID_MNEMONIC, @@ -215,8 +217,6 @@ describe("backup --records", () => { expect(f.envelope().command).toBe("backup.records"); }); - // The service returns `pagination` inside its view; the json formatter lifts it into envelope - // `meta` (and removes it from `data`) whenever it carries a full offset/limit/total triple. it("returns records, with pagination lifted into envelope meta", async () => { const f = fixture({ tty: false, @@ -235,8 +235,6 @@ describe("backup --records", () => { ["backup", "--records", "--keystore"], ["backup", "--records", "--out", "./x.json"], ]) { - // BUG-V413-033: --keystore/--out describe an export, so combining either with --records is - // a flag conflict (invalid_option), not a bad value. await expect(buildCli(f.shellOpts).parseAsync(argv)).rejects.toMatchObject({ code: "invalid_option", }); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.test.ts index 8fa7ed832..6038c84da 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.test.ts @@ -16,20 +16,18 @@ import { commandId } from "../command-id.js"; import { isChainCommand } from "../contracts/index.js"; import type { CommandDefinition, Globals } from "../contracts/index.js"; import { WalletService } from "../../../../application/use-cases/wallet-service.js"; +import { CommandDeadline } from "../../../../application/services/command-deadline.js"; -// Cheap KDF for keystore encryption in this suite — see cheap-scrypt.ts. Production untouched. +// Use a cheap KDF for test fixtures. vi.mock( "@noble/hashes/scrypt.js", async () => import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), ); -// ── test constants ───────────────────────────────────────────────────────────── const VALID_MNEMONIC = "test test test test test test test test test test test junk"; const VALID_PRIVATE_KEY = "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; const VALID_PASSWORD = "Abcdef1!"; -// ── helpers ──────────────────────────────────────────────────────────────────── - interface FakePromptOpts { tty?: boolean; hiddenAnswers?: string[]; @@ -47,7 +45,6 @@ function makeFakeBackend(opts: FakePromptOpts = {}): ConstructorParameters:"], + [ + "parameter assignment", + () => parseChainParameterAssignments(["x"], []), + "invalid --set 'x'; expected =", + ], + [ + "agent id", + () => resolveAgentId("tron:abc:1", TRON), + "Agent ID prefixes require a numeric chain ID (for example tron:3448148188:170); use --network for aliases", + ], + [ + "legacy fee overrides", + () => planEvmFee({ gasPriceWei: "1", gasLimit: "21000", overrides: { maxFeeWei: "2" } }), + "--max-fee and --priority-fee need an EIP-1559 chain; this network prices in gasPrice", + ], + [ + "amount", + () => assertPositiveAmount(0n, inputRef("amount")), + "--amount must be greater than zero", + ], + ])("renders the %s message as before", (_name, action, text) => { + expect(cliText(action)).toBe(text); + }); + + it("renders fee-plan warnings as the same text", () => { + const plan = planEvmFee({ + baseFeeWei: "1000000000", + gasPriceWei: "1100000000", + suggestedPriorityWei: "1000000", + gasLimit: "21000", + overrides: { maxFeeWei: "500000" }, + }); + expect(plan.warnings?.map((warning) => cliMessage(warning).message)).toEqual([ + "--max-fee 500000 wei is below the node's suggested tip of 1000000 wei, so the tip was reduced to match it; a node rejects a tip above the fee cap", + "--max-fee 500000 wei is below the current base fee of 1000000000 wei; this transaction cannot be included until the base fee falls", + ]); + }); +}); diff --git a/ts/src/adapters/inbound/cli/output/errors.ts b/ts/src/adapters/inbound/cli/output/errors.ts new file mode 100644 index 000000000..797ed68ee --- /dev/null +++ b/ts/src/adapters/inbound/cli/output/errors.ts @@ -0,0 +1,80 @@ +// CLI projection of errors: flag and command spelling, the envelope, and exit-code fallback. +import type { ExitCode } from "../../../../domain/types/primitives.js"; +import { ERROR_CODES, type ErrorCodeEntry } from "../../../../domain/errors/codes.js"; +import { + classifyError, + CliError, + renderMessageParts, + UsageError, + type FollowUp, + type FollowUpRef, + type InputRef, + type UserMessage, +} from "../../../../domain/errors/index.js"; +import { camelToKebab } from "../arity/index.js"; +import { GLOBAL_FLAG_SPECS } from "../globals/index.js"; + +const FOLLOW_UP_COMMANDS: Record string> = { + "account-list": () => "list", + "account-rename": (account) => `rename ${account ?? ""} --label `, + "asset-info": (name) => `asset info ${name ?? ""}`, + "bai-report-recharge": () => "bai report-recharge", + "gasfree-trace": (id) => (id === undefined ? "gasfree trace" : `gasfree trace ${id}`), + "keystore-backup": (account) => + `wallet-cli backup ${account ?? ""} --keystore --network tron:728126428 --password-stdin`, + "ledger-import": () => "import ledger", + "proposal-list": () => "proposal list", + "sunpump-token-search": (symbol) => + `sunpump token-search ${JSON.stringify(symbol ?? "")}`, + "sunswap-pool-list-v4": () => "sunswap pool-list --protocol V4", + "sunswap-position-list": () => "sunswap position-list", + "sunswap-swap": () => "sunswap swap", + "token-add": (contract) => + contract === undefined ? "wallet-cli token add" : `wallet-cli token add --contract ${contract}`, +}; + +export function flagFor(field: string): string { + const global = GLOBAL_FLAG_SPECS.find((spec) => spec.field === field); + return `--${global ? global.name : camelToKebab(field)}`; +} + +function spell(ref: InputRef | FollowUpRef): string { + return "input" in ref ? flagFor(ref.input) : FOLLOW_UP_COMMANDS[ref.followUp](ref.subject); +} + +// The message as the CLI shows it: JSON keeps raw values, text escapes them. +export function cliMessage(message: UserMessage): { message: string; textMessage: string } { + if (message.parts === undefined || message.parts.length === 0) { + return { message: message.message, textMessage: message.textMessage }; + } + return { + message: renderMessageParts(message.parts, spell, false), + textMessage: renderMessageParts(message.parts, spell, true), + }; +} + +export function errorEnvelope(err: CliError): { code: string; message: string; details?: object } { + const { message } = cliMessage(err.userMessage); + return err.details === undefined + ? { code: err.code, message } + : { code: err.code, message, details: err.details }; +} + +// The registry sets the exit code; unknown or "either" entries fall back to the class. +export function exitCodeOf(err: CliError): ExitCode { + const table = ERROR_CODES as Record; + const declared = table[err.code]?.exit; + if (declared === 1 || declared === 2) return declared; + return err.kind === "usage" ? 2 : 1; +} + +const YARGS_USAGE = + /Not enough non-option arguments|Missing required argument|Unknown argument|Invalid values|Did you mean|Not enough arguments|Too many non-option/i; + +// Parser failures are usage errors; everything else follows the domain classification. +export function normalizeCliError(e: unknown): CliError { + if (!(e instanceof CliError) && e instanceof Error && YARGS_USAGE.test(e.message)) { + return new UsageError("usage_error", e.message); + } + return classifyError(e); +} diff --git a/ts/src/adapters/inbound/cli/output/fatal.test.ts b/ts/src/adapters/inbound/cli/output/fatal.test.ts new file mode 100644 index 000000000..0710b8d39 --- /dev/null +++ b/ts/src/adapters/inbound/cli/output/fatal.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { reportFatal } from "./fatal.js"; + +function capture() { + const lines: string[] = []; + return { lines, streams: { errorLine: (line: string) => void lines.push(line) } }; +} + +describe("reportFatal", () => { + it("writes the error message as one fatal line and exits 1", () => { + const { lines, streams } = capture(); + expect(reportFatal(new Error("boom"), streams)).toBe(1); + expect(lines).toEqual(["fatal: boom"]); + }); + + it("falls back to the value's text when it has no message", () => { + const { lines, streams } = capture(); + reportFatal("plain", streams); + reportFatal(undefined, streams); + expect(lines).toEqual(["fatal: plain", "fatal: undefined"]); + }); +}); diff --git a/ts/src/adapters/inbound/cli/output/fatal.ts b/ts/src/adapters/inbound/cli/output/fatal.ts new file mode 100644 index 000000000..bcba91f43 --- /dev/null +++ b/ts/src/adapters/inbound/cli/output/fatal.ts @@ -0,0 +1,12 @@ +import type { ExitCode } from "../../../../domain/types/index.js"; +import { StreamManager } from "../stream/index.js"; + +// Last-resort report for a failure that escaped the runner's own error handling. +export function reportFatal( + error: unknown, + streams: Pick = new StreamManager("text", false), +): ExitCode { + const message = (error as { message?: unknown } | null | undefined)?.message; + streams.errorLine(`fatal: ${(message as string | undefined) ?? String(error)}`); + return 1; +} diff --git a/ts/src/adapters/inbound/cli/output/index.ts b/ts/src/adapters/inbound/cli/output/index.ts index ac5552336..721a13fd8 100644 --- a/ts/src/adapters/inbound/cli/output/index.ts +++ b/ts/src/adapters/inbound/cli/output/index.ts @@ -1,14 +1,4 @@ -/** - * OutputFormatter — turn outcomes into result/event frame strings without changing - * behavior. Instead of one class branching on `if (output === "json")`, this is an interface - * with two implementations chosen by createOutputFormatter (borrowed from ledger wallet-cli - * output.ts). The formatter only computes strings; StreamManager owns writing & stream choice. - * - success → single terminal frame (caller hands to streams.result) - * - error → terminal error (json envelope to stdout, or short stderr line) - * - event → intermediate progress frame (caller hands to streams.event); null = not shown - * JSON mode emits exactly one terminal envelope; empty data is {}; big numbers stay strings. - * Neutral commands omit `chain`. - */ +// Format results and progress; errors are written through the streams port. import type { NetworkDescriptor, OutputMode } from "../../../../domain/types/index.js"; import type { ProgressEvent } from "../../../../application/contracts/index.js"; import type { Pagination, QueryEcho, StreamManager, TextFormatter } from "../contracts/index.js"; @@ -16,9 +6,11 @@ import type { CliError } from "../../../../domain/errors/index.js"; import { OutputEnvelope, toJson } from "./envelope.js"; import { renderErrorDetails, renderGenericText } from "../render/index.js"; import { sanitizeText } from "../render/scalars.js"; +import { maskCredentials } from "./mask.js"; +import { cliMessage, errorEnvelope } from "./errors.js"; export interface OutputFormatter { - /** the single result frame for the caller to hand to streams.result. */ + // Return the result frame for streams.result. success( command: string, net: NetworkDescriptor | undefined, @@ -26,9 +18,9 @@ export interface OutputFormatter { formatText?: TextFormatter, accountLabel?: string, ): string; - /** terminal error output (JSON envelope to stdout, or short line to stderr). */ + // Write JSON errors to stdout or human errors to stderr. error(err: CliError, ctx?: { commandId?: string; net?: NetworkDescriptor }): void; - /** intermediate progress frame for streams.event; null = this mode does not show it. */ + // Return a progress frame for streams.event; null suppresses it. event(e: ProgressEvent): string | null; } @@ -36,6 +28,8 @@ abstract class BaseOutputFormatter { constructor( protected readonly streams: StreamManager, protected readonly startedAt: number, + // External error text can echo the credentials a request used. + protected readonly credentials: readonly string[], ) {} protected meta() { @@ -45,7 +39,6 @@ abstract class BaseOutputFormatter { class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter { success(command: string, net: NetworkDescriptor | undefined, data: unknown): string { - // JSON mode always uses the envelope; the account label is a text-mode display nicety. const paged = extractPagination(stripView(data)); const ordered = extractQuery(paged.data); return toJson( @@ -58,8 +51,13 @@ class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter } error(err: CliError, ctx?: { commandId?: string; net?: NetworkDescriptor }): void { - const env = OutputEnvelope.error(ctx?.commandId ?? "", ctx?.net, err.toEnvelope(), this.meta()); - this.streams.result(toJson(env)); + const env = OutputEnvelope.error( + ctx?.commandId ?? "", + ctx?.net, + errorEnvelope(err), + this.meta(), + ); + this.streams.result(maskCredentials(toJson(env), this.credentials)); } event(e: ProgressEvent): string { @@ -67,23 +65,7 @@ class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter } } -/** - * Pagination is envelope metadata in the public JSON contract — ONE location for every list command, - * so a caller pages any of them without knowing the payload's shape. Text renderers keep reading the - * same value from their view model to title `showing N of total`, which is why only this path moves it. - * - * What identifies a window is `offset` + `limit`; `total` is OPTIONAL and normalised to `null`, - * because for TRON's paginated endpoints no count exists at all (obtaining one would mean - * transferring every record). Requiring it here is what used to strand `asset list` / `exchange list` - * in `data` while `backup --records` / `proposal show` moved to `meta` — the same envelope carrying - * the same concept in two places, decided by whether a total happened to be knowable. - */ -/** - * Lift `query` out of `data` and into `meta`, the same move `pagination` makes and for the same - * reason: how a listing was ordered is metadata about the window, not part of the payload, and a - * caller should find it in one place whatever the command returns. Text renderers keep reading it - * from their view model, which is why only this path moves it. - */ +// Move query metadata only in JSON mode; text renderers retain the original view. function extractQuery(data: unknown): { data: unknown; query?: QueryEcho } { if (!data || typeof data !== "object" || Array.isArray(data)) return { data }; const source = data as Record; @@ -96,22 +78,7 @@ function extractQuery(data: unknown): { data: unknown; query?: QueryEcho } { return { data: rest, query: { orderBy, sort } }; } -/** - * Remove the reserved `view` key before anything is published. - * - * `view` carries text-mode scaffolding ONLY — a value a renderer needs to draw a column that the - * spec says JSON must not carry. It is dropped, not relocated: unlike `pagination`, which moves - * from data to meta and is still published, nothing takes its place in the envelope. - * - * That makes this the one place where text mode can show something JSON mode cannot reach, which - * cuts against machine-interface's premise that JSON is the complete output. So the bar is - * narrow and explicit: a field belongs in `view` ONLY when a specification states that JSON must - * not carry it. "We did not model it in the view type" is not a reason — model it and publish it. - * - * The one case today is `sunswap price`: the token symbol stays out of json because a symbol is - * self-reported and proves nothing, so an agent must key on the address. Publishing it - * would re-introduce exactly what that rule removes. - */ +// The reserved view key holds text-only data and must not appear in JSON. function stripView(data: unknown): unknown { if (!data || typeof data !== "object" || Array.isArray(data)) return data; if (!("view" in (data as Record))) return data; @@ -125,7 +92,7 @@ function extractPagination(data: unknown): { data: unknown; pagination?: Paginat const value = source.pagination; if (!value || typeof value !== "object" || Array.isArray(value)) return { data }; const pagination = value as Record; - // Not a window → leave it alone: `pagination` might be an unrelated field on some future payload. + // Leave unrelated pagination fields in the payload. if ( !Number.isInteger(pagination.offset) || !(pagination.limit === null || Number.isInteger(pagination.limit)) @@ -146,8 +113,7 @@ function extractPagination(data: unknown): { data: unknown; pagination?: Paginat } class HumanOutputFormatter extends BaseOutputFormatter implements OutputFormatter { - // Text mode: strip terminal control bytes from every frame so a hostile wallet label or remote - // token/RPC metadata value cannot inject ANSI/OSC sequences (CLI-OUT-001). JSON mode stays raw. + // Sanitize text frames against terminal injection; JSON retains raw values. success( command: string, net: NetworkDescriptor | undefined, @@ -161,11 +127,10 @@ class HumanOutputFormatter extends BaseOutputFormatter implements OutputFormatte } error(err: CliError): void { - // Errors that are a choice rather than a dead end append their candidate table (see - // renderErrorDetails); everything else stays the single line it has always been. - const line = `error [${err.code}]: ${err.textMessage}`; + const line = `error [${err.code}]: ${cliMessage(err.userMessage).textMessage}`; const candidates = renderErrorDetails(err.details); - this.streams.errorLine(sanitizeText(candidates ? `${line}\n${candidates}` : line)); + const text = candidates ? `${line}\n${candidates}` : line; + this.streams.errorLine(sanitizeText(maskCredentials(text, this.credentials))); } event(e: ProgressEvent): string { @@ -177,13 +142,13 @@ export function createOutputFormatter( output: OutputMode, streams: StreamManager, startedAt: number, + credentials: readonly string[] = [], ): OutputFormatter { return output === "json" - ? new JsonOutputFormatter(streams, startedAt) - : new HumanOutputFormatter(streams, startedAt); + ? new JsonOutputFormatter(streams, startedAt, credentials) + : new HumanOutputFormatter(streams, startedAt, credentials); } -// plain human progress line (no spinner / no TTY detection — Standard CLI / agent-first). function renderEvent(e: ProgressEvent): string { switch (e.type) { case "activity": diff --git a/ts/src/adapters/inbound/cli/output/mask.ts b/ts/src/adapters/inbound/cli/output/mask.ts new file mode 100644 index 000000000..e0adb2477 --- /dev/null +++ b/ts/src/adapters/inbound/cli/output/mask.ts @@ -0,0 +1,12 @@ +const MASK = "********"; + +// Replaces every configured credential in rendered error text, raw or JSON-escaped. +export function maskCredentials(text: string, credentials: readonly string[]): string { + const forms = new Set( + credentials.filter((c) => c !== "").flatMap((c) => [c, JSON.stringify(c).slice(1, -1)]), + ); + // Longer first, so a credential containing another is masked whole. + return [...forms] + .sort((a, b) => b.length - a.length) + .reduce((masked, form) => masked.split(form).join(MASK), text); +} diff --git a/ts/src/adapters/inbound/cli/output/output.test.ts b/ts/src/adapters/inbound/cli/output/output.test.ts index b93f3aad3..5f68a7886 100644 --- a/ts/src/adapters/inbound/cli/output/output.test.ts +++ b/ts/src/adapters/inbound/cli/output/output.test.ts @@ -71,9 +71,6 @@ describe("createOutputFormatter (json)", () => { expect(env.meta.pagination).toEqual({ offset: 10, limit: 5, total: 42 }); }); - // The commands whose endpoint reports no count (asset list / exchange list) must land in the SAME - // place as those that do — otherwise one envelope carries one concept in two locations, decided by - // whether a total happens to be knowable. it("moves pagination into metadata even when no total is knowable, as total: null", () => { const { sm } = capture("json"); const f = createOutputFormatter("json", sm, 0); @@ -115,7 +112,6 @@ describe("createOutputFormatter (json)", () => { expect(env.meta).not.toHaveProperty("pagination"); }); - // Text mode titles read the window from the view model, so it must NOT be stripped there. it("leaves pagination in the view model for text renderers", () => { const { sm } = capture("text"); const f = createOutputFormatter("text", sm, 0); @@ -156,7 +152,7 @@ describe("createOutputFormatter (text)", () => { const f = createOutputFormatter("text", sm, 0); const frame = f.event({ type: "awaiting_device", reason: "sign" }); expect(frame).not.toBeNull(); - expect(frame).not.toContain("{"); // human text, not NDJSON + expect(frame).not.toContain("{"); }); it("renders wallet create as a focused human receipt", () => { @@ -208,28 +204,28 @@ describe("createOutputFormatter (text)", () => { }, walletCmd.formatText, ); - // icon and label live in separate ANSI spans, so assert on the pieces (not a fused substring). + // Icon and label occupy separate ANSI spans. expect(text).toContain("⚠"); expect(text).toContain("Existing wallet"); - expect(text).not.toContain("✅"); // existing wallets must not show the success check + expect(text).not.toContain("✅"); }); it("strips terminal control sequences from rendered data values", () => { const { sm } = capture("text"); const f = createOutputFormatter("text", sm, 0); - // a hostile label / remote metadata value carrying ANSI CSI, OSC, and a bare C1 CSI byte. + // Include ANSI CSI, OSC, and a bare C1 CSI byte. const text = f.success(commandId(cmd), net, { balance: "1\x1b[31mHACKED\x1b]0;pwn\x07\x9bK" }); expect(text).not.toContain("\x1b"); expect(text).not.toContain("\x9b"); expect(text).not.toContain("\x07"); - expect(text).toContain("1"); // the real value survives, only control bytes are removed + expect(text).toContain("1"); }); it("preserves newlines while stripping control bytes", () => { const { sm } = capture("text"); const f = createOutputFormatter("text", sm, 0); const text = f.success(commandId(cmd), net, { balance: "1" }); - expect(text).toContain("\n"); // layout line breaks must remain intact + expect(text).toContain("\n"); }); it("sanitizes human error and event lines", () => { @@ -247,7 +243,7 @@ describe("createOutputFormatter (text)", () => { const { sm } = capture("json"); const f = createOutputFormatter("json", sm, 0); const env = JSON.parse(f.success(commandId(cmd), net, { balance: "1\x1b[31m" })); - expect(env.data.balance).toBe("1\x1b[31m"); // machine-parseable output stays byte-exact + expect(env.data.balance).toBe("1\x1b[31m"); }); it("renders backup metadata without secret material", () => { @@ -277,9 +273,6 @@ describe("createOutputFormatter (text)", () => { }); }); -// Chain-controlled strings (permission names, token metadata, TronLink signer labels) reach the -// security summaries a user reads before approving. Bidi controls are not control BYTES, so the -// C0/C1 strip lets them through — and U+202E reorders everything printed after it. describe("invisible formatting in untrusted display fields", () => { const RLO = String.fromCharCode(0x202e); const LRI = String.fromCharCode(0x2066); @@ -306,7 +299,6 @@ describe("invisible formatting in untrusted display fields", () => { it("leaves ordinary right-to-left text alone", () => { const { sm } = capture("text"); const f = createOutputFormatter("text", sm, 0); - // implicit bidi comes from the characters' own properties — nothing to neutralize const text = f.success(commandId(cmd), net, { label: "Acme \u0645\u0631\u062d\u0628\u0627" }); expect(text).toContain("Acme \u0645\u0631\u062d\u0628\u0627"); expect(text).not.toContain(" { const out = new Map>(); for (const cmd of this.#chainByPath.values()) { @@ -66,7 +55,15 @@ export class CommandRegistry { return new Map([...out].map(([f, s]) => [f, [...s]])); } - /** Resolve a neutral command by its full path. */ + // Family-neutral commands run on any network family. + neutralCapabilityKeys(): string[] { + return [ + ...new Set( + [...this.#byPath.values()].flatMap((cmd) => (cmd.capability ? [cmd.capability] : [])), + ), + ]; + } + resolveNeutral(path: string[]): CommandDefinition | null { return this.#byPath.get(path.join(".")) ?? null; } diff --git a/ts/src/adapters/inbound/cli/render/account.ts b/ts/src/adapters/inbound/cli/render/account.ts index 2aa81a324..369037250 100644 --- a/ts/src/adapters/inbound/cli/render/account.ts +++ b/ts/src/adapters/inbound/cli/render/account.ts @@ -8,20 +8,27 @@ import { num, quote, } from "./scalars.js"; -import { type Obj, type Pair, asObj, query, receipt, table, ok, fail, warn } from "./layout.js"; +import { + type Obj, + type Pair, + asObj, + query, + receipt, + table, + ok, + fail, + unknown, + warn, +} from "./layout.js"; import { FAMILY_RENDER, renderFamily, renderSymbol } from "./family.js"; -/** humanize a raw base-unit balance: scale by `decimals` when known, else show the raw integer. - * Scaling goes through `formatAmount`, so a balance obeys the same display rule here as - * everywhere else text prints an amount — without it an 18-decimal coin printed in full. */ function humanBalance(d: Obj): string { return d.decimals !== undefined ? formatAmount(d.balance ?? "0", num(d.decimals, 0)) : formatScalar(d.balance); } -/** a portfolio row's balance. `holding()` reports the base-unit integer alongside the scaled - * string, so the column can format from `rawBalance` rather than reprinting the pre-scaled one. */ +// rawBalance is in base units; balance is already scaled. function holdingBalance(h: Obj): string { return h.rawBalance !== undefined && h.rawBalance !== null && h.decimals !== undefined ? formatAmount(h.rawBalance, num(h.decimals, 0)) @@ -119,15 +126,6 @@ export const AccountFormatters = { }) satisfies TextFormatter, }; -/** - * `account info` — family-shaped. - * - * TRON returns the node's account object (permissions, resources, stakes); EVM has no equivalent - * RPC and returns a flat `{balance, nonce, type, codeSize?}`. These are not the same field set with - * different values, so the rows come from the family table rather than from one formatter reading - * whichever keys happen to be present — the TRON reader applied to an EVM payload found nothing - * and printed "Balance 0 TRX" for an account holding ETH. - */ function renderAccountInfo(d: Obj, ctx: TextRenderContext): string { const pairs: Pair[] = []; if (ctx.accountLabel) pairs.push(["Label", ctx.accountLabel]); @@ -135,32 +133,32 @@ function renderAccountInfo(d: Obj, ctx: TextRenderContext): string { return query(pairs); } +const HISTORY_STATUS: Record string> = { ok, failed: fail, unknown }; + function historyRow(r: Obj): string[] { - const ts = r.time ?? r.block_timestamp ?? r.timestamp; - const type = r.type ?? r.transfer_type ?? r.direction ?? ""; - const amount = r.amount ?? r.value ?? r.quant ?? ""; - const symbol = - r.symbol ?? - (r.token_info && typeof r.token_info === "object" ? asObj(r.token_info).symbol : undefined); - const counterparty = r.counterparty ?? r.to ?? r.from ?? ""; - const status = r.status === "failed" || r.confirmed === false ? "failed" : "ok"; return [ - formatTime(ts), - String(type), - `${formatScalar(amount)}${symbol ? ` ${String(symbol)}` : ""}`, - String(counterparty), - status === "ok" ? ok() : fail(), + formatTime(r.time), + String(r.type ?? ""), + historyAmount(r), + String(r.counterparty ?? ""), + (HISTORY_STATUS[String(r.status)] ?? unknown)(), ]; } -/** account display id for receipts: the centrally-injected --account label when present, - * else the full on-chain address. Callers add their own quoting where wanted. */ +function historyAmount(r: Obj): string { + const unit = r.symbol ? String(r.symbol) : r.assetId ? `TRC10 ${String(r.assetId)}` : ""; + const amount = r.amount + ? formatScalar(r.amount) + : r.rawAmount + ? `${formatScalar(r.rawAmount)} base units of` + : ""; + return [amount, unit].filter(Boolean).join(" "); +} + function acct(ctx: TextRenderContext, address: unknown): string { return ctx.accountLabel ?? String(address ?? ""); } -/** identity field pair: prefer the account label, else show the full address — the field - * name tracks the value's real meaning. */ function identity(ctx: TextRenderContext, address: unknown): Pair { return ctx.accountLabel ? ["Label", ctx.accountLabel] : ["Address", String(address ?? "")]; } diff --git a/ts/src/adapters/inbound/cli/render/approval.ts b/ts/src/adapters/inbound/cli/render/approval.ts index 41105ed08..4b22ee694 100644 --- a/ts/src/adapters/inbound/cli/render/approval.ts +++ b/ts/src/adapters/inbound/cli/render/approval.ts @@ -3,8 +3,6 @@ import { formatAtWithRelative, formatInt, formatSun } from "./scalars.js"; import { query, table } from "./layout.js"; function transactionType(value: TxApprovalView): string { - // human name only — the machine-readable contractType enum stays in json. The raw - // enum is the fallback, since an unmapped contract type has no human name to show. const label = value.operation ?? value.contractType; if (!value.rawAmount) return label; return value.contractType === "TransferContract" diff --git a/ts/src/adapters/inbound/cli/render/asset.ts b/ts/src/adapters/inbound/cli/render/asset.ts index d2ec3dc25..cc906d19a 100644 --- a/ts/src/adapters/inbound/cli/render/asset.ts +++ b/ts/src/adapters/inbound/cli/render/asset.ts @@ -3,7 +3,7 @@ import { fromBaseUnits } from "../../../../domain/amounts/index.js"; import { formatDecimal, formatInt, formatUtc, num } from "./scalars.js"; import { type Obj, type Pair, asObj, kv, table, titled } from "./layout.js"; -/** whole tokens from minimal units — TRC10 quantities are always stored scaled by precision. */ +// TRC10 quantities are scaled by precision. function whole(raw: unknown, precision: unknown): string { return raw === undefined || raw === null ? "" @@ -33,7 +33,6 @@ export const AssetFormatters = { ["Public free net", formatInt(d.publicFreeAssetNetLimit ?? 0)], ]; const body = titled(`Asset ${d.name ?? ""} (id ${d.assetId ?? ""})`, rows); - // An empty collection is omitted entirely rather than printed as "Frozen (0)". const tranches = Array.isArray(d.frozenSupply) ? (d.frozenSupply as Obj[]) : []; if (tranches.length === 0) return body; const frozen = kv( @@ -43,7 +42,6 @@ export const AssetFormatters = { return `${body}\n Frozen (${tranches.length})\n${frozen}`; }) satisfies TextFormatter, - // Reserves/supply are whole tokens here at no cost: an asset record carries its own precision. assetList: ((data) => { const d = asObj(data); const assets = Array.isArray(d.assets) ? (d.assets as Obj[]) : []; diff --git a/ts/src/adapters/inbound/cli/render/block-render.test.ts b/ts/src/adapters/inbound/cli/render/block-render.test.ts index 295eed4ad..5a66eb972 100644 --- a/ts/src/adapters/inbound/cli/render/block-render.test.ts +++ b/ts/src/adapters/inbound/cli/render/block-render.test.ts @@ -1,11 +1,3 @@ -/** - * `block` renders each family's RAW node object. - * - * The JSON contract for this command is deliberately "what the node said", so the two families - * arrive in different shapes: TRON nests the header under `block_header.raw_data` and reports - * milliseconds, while an EVM node returns a flat object of hex QUANTITY values and seconds. - * Normalizing for humans is therefore this renderer's job, and only this renderer's. - */ import { describe, it, expect } from "vitest"; import { TextFormatters } from "./index.js"; import type { NetworkDescriptor } from "../../../../domain/types/index.js"; @@ -15,83 +7,75 @@ const ctxFor = (family: "tron" | "evm") => ({ net: { family } as NetworkDescriptor, }); -const TRON_BLOCK = { - blockID: "0000000000abcdef", - block_header: { raw_data: { number: 1234567, timestamp: 1722925264000 } }, - transactions: [{}, {}], +const TRON_RESULT = { + block: { blockID: "0000000000abcdef", block_header: { raw_data: { number: 1 } } }, + number: 1234567, + timestampMs: 1722925264000, + transactionCount: 2, }; -const EVM_BLOCK = { - number: "0x12d687", - timestamp: "0x66b1c0d0", +// The EVM block result: normalized fields beside the node's verbatim block. +const EVM_RESULT = { + block: { number: "0x12d687", timestamp: "0x66b1c0d0" }, + number: 1234567, + timestamp: 1722925264000, hash: "0xabc", parentHash: "0xparent", - gasUsed: "0xc3ed1d", - gasLimit: "0x1c9c380", - baseFeePerGas: "0x448b9b800", - transactions: ["0xdead", "0xbeef"], + transactionCount: 2, + gasUsed: "12840221", + gasLimit: "30000000", + baseFeeWei: "18400000000", }; describe("block renderer", () => { - it("reads TRON's nested header and millisecond timestamp", () => { - const out = TextFormatters.block!({ block: TRON_BLOCK }, ctxFor("tron"))!; + it("formats TRON's summary fields, not the raw header", () => { + const out = TextFormatters.block!(TRON_RESULT, ctxFor("tron"))!; expect(out).toContain("1,234,567"); expect(out).toContain("2024-08-06 06:21:04 UTC"); expect(out).toContain("2"); }); - it("decodes EVM hex quantities instead of printing them raw", () => { - const out = TextFormatters.block!({ block: EVM_BLOCK }, ctxFor("evm"))!; + it("prints the normalized EVM number and millisecond timestamp", () => { + const out = TextFormatters.block!(EVM_RESULT, ctxFor("evm"))!; - expect(out).toContain("1,234,567"); + expect(out).toContain("#1,234,567"); + expect(out).toContain("2024-08-06 06:21:04 UTC"); expect(out).not.toContain("0x12d687"); }); - // Seconds read as milliseconds would date every EVM block to 1970. - it("treats the EVM timestamp as seconds", () => { - const out = TextFormatters.block!({ block: EVM_BLOCK }, ctxFor("evm"))!; + it("does not decode the node's raw EVM block", () => { + const out = TextFormatters.block!( + { block: { number: "0x12d687", timestamp: "0x66b1c0d0" } }, + ctxFor("evm"), + )!; - expect(out).toContain("2024-08-06 06:21:04 UTC"); - expect(out).not.toContain("1970"); + expect(out).not.toContain("1,234,567"); + expect(out).toContain("unknown"); }); it("counts EVM transactions", () => { - expect(TextFormatters.block!({ block: EVM_BLOCK }, ctxFor("evm"))!).toContain("2"); - }); - - it("says unknown rather than crashing when the chain has no such block", () => { - const out = TextFormatters.block!({ block: null }, ctxFor("evm"))!; - - expect(out).toContain("unknown"); + expect(TextFormatters.block!(EVM_RESULT, ctxFor("evm"))!).toMatch(/Transactions\s+2/); }); - /** - * The four rows that say whether a block is full and what it cost. Without them `block` on EVM - * answered "how many transactions" and nothing a reader could act on. - */ it("reports the EVM hash, parent, gas usage and base fee", () => { - const out = TextFormatters.block!({ block: EVM_BLOCK }, ctxFor("evm"))!; + const out = TextFormatters.block!(EVM_RESULT, ctxFor("evm"))!; expect(out).toContain("Hash"); expect(out).toContain("Parent hash"); - // decoded and grouped, never the hex the node sent expect(out).toContain("12,840,221 / 30,000,000"); expect(out).toContain("18.4 gwei"); }); - // Not "a field whose value we do not know" but a concept that does not exist there; an empty - // row would claim the former. it("omits the base fee row entirely on a pre-1559 chain", () => { - const { baseFeePerGas: _dropped, ...legacy } = EVM_BLOCK; - const out = TextFormatters.block!({ block: legacy }, ctxFor("evm"))!; + const { baseFeeWei: _dropped, ...legacy } = EVM_RESULT; + const out = TextFormatters.block!(legacy, ctxFor("evm"))!; expect(out).not.toContain("Base fee"); }); - // The EVM rows are additive; TRON's three stay exactly as they were. it("leaves the TRON block rows unchanged", () => { - const out = TextFormatters.block!({ block: TRON_BLOCK }, ctxFor("tron"))!; + const out = TextFormatters.block!(TRON_RESULT, ctxFor("tron"))!; expect(out).not.toContain("Base fee"); expect(out).not.toContain("Gas used"); diff --git a/ts/src/adapters/inbound/cli/render/chain.ts b/ts/src/adapters/inbound/cli/render/chain.ts index 1f887ff4d..8b418583c 100644 --- a/ts/src/adapters/inbound/cli/render/chain.ts +++ b/ts/src/adapters/inbound/cli/render/chain.ts @@ -1,52 +1,33 @@ import type { TextFormatter } from "../contracts/index.js"; import { formatInt } from "./scalars.js"; -import { asObj, query, table } from "./layout.js"; +import { type Obj, asObj, query, table } from "./layout.js"; import { FAMILY_RENDER, renderFamily, renderSymbol } from "./family.js"; -const KNOWN_UNITS: Record = { - getEnergyFee: "SUN", - getTransactionFee: "SUN", - getCreateAccountFee: "SUN", - getCreateNewAccountFeeInSystemContract: "SUN", - getWitnessPayPerBlock: "SUN", - getMemoFee: "SUN", - getMaintenanceTimeInterval: "ms", -}; - -function parameterValue(key: string, value: unknown): string { - const unit = KNOWN_UNITS[key]; - return unit ? `${formatInt(value)} ${unit}` : String(value ?? ""); +function parameterValue(param: Obj): string { + return param.unit ? `${formatInt(param.value)} ${String(param.unit)}` : String(param.value ?? ""); } export const ChainFormatters = { chainParams: ((data) => { const d = asObj(data); if ("key" in d) { - const key = String(d.key ?? ""); return query([ - ["Key", key], - ["Value", parameterValue(key, d.value)], + ["Key", String(d.key ?? "")], + ["Value", parameterValue(d)], ]); } const params = Array.isArray(d.params) ? d.params.map(asObj) : []; return table( ["Key", "Value"], - params.map((p) => [String(p.key ?? ""), parameterValue(String(p.key ?? ""), p.value)]), + params.map((p) => [String(p.key ?? ""), parameterValue(p)]), ); }) satisfies TextFormatter, - // Both families price transactions, but in disjoint terms — TRON in SUN per energy/bandwidth - // unit, EVM in gwei per gas under a fee model the chain reports. The rows come from the family - // table; reading one family's keys out of the other's payload printed three empty TRON labels - // on EVM and none of the fee data that was there. chainPrices: ((data, ctx) => { const d = asObj(data); return query(FAMILY_RENDER[renderFamily(ctx)].chainPricesRows(d, renderSymbol(ctx))); }) satisfies TextFormatter, - // Family-shaped, like `chain prices` and `account info`: TRON has a p2p network to report on, - // EVM has a chain id to check the endpoint against. Reading one family's keys out of the other's - // payload is what printed empty TRON labels on EVM before. chainNode: ((data, ctx) => query(FAMILY_RENDER[renderFamily(ctx)].chainNodeRows(asObj(data)))) satisfies TextFormatter, }; diff --git a/ts/src/adapters/inbound/cli/render/error-details.test.ts b/ts/src/adapters/inbound/cli/render/error-details.test.ts index 9eeee08f5..4cf67e34e 100644 --- a/ts/src/adapters/inbound/cli/render/error-details.test.ts +++ b/ts/src/adapters/inbound/cli/render/error-details.test.ts @@ -12,6 +12,28 @@ describe("renderErrorDetails", () => { { assetId: "1000488", issuerAddress: "TZx9kP2m", totalSupply: "5000000000", precision: 2 }, ]; + it("shows the submission ID and advises checking status before resending", () => { + expect(renderErrorDetails({ txId: "abc" })).toBe( + "Transaction ID: abc. Check with tx status before sending again.", + ); + }); + + it("keeps existing detail tables alongside the transaction ID", () => { + const out = renderErrorDetails({ txId: "abc", matches }); + expect(out).toContain("Transaction ID: abc"); + expect(out).toContain(renderErrorDetails({ matches })); + }); + + it("escapes untrusted transaction ID line separators", () => { + expect(renderErrorDetails({ txId: "abc\nforged\r\u0085\u2028\u2029" })).toContain( + "abc\\nforged\\r\\u0085\\u2028\\u2029", + ); + }); + + it.each([undefined, null, "", 42, {}])("does not render an unavailable ID %j", (txId) => { + expect(renderErrorDetails({ txId })).toBeNull(); + }); + it("tables the candidates so a human can pick one", () => { const out = renderErrorDetails({ name: "MyToken", assetIds: ["1000123", "1000488"], matches }); expect(out).toContain("ID"); @@ -20,12 +42,10 @@ describe("renderErrorDetails", () => { expect(out).toContain("1000488"); }); - // The row reports raw minimal units; text shows the same whole-token figure `asset info` does, - // so the two views of one token never disagree. it("scales each row's supply by its own precision", () => { const out = renderErrorDetails({ matches })!; - expect(out).toContain("1,000,000,000"); // 1e15 at precision 6 - expect(out).toContain("50,000,000"); // 5e9 at precision 2 + expect(out).toContain("1,000,000,000"); + expect(out).toContain("50,000,000"); expect(out).not.toContain("1000000000000000"); }); @@ -37,7 +57,6 @@ describe("renderErrorDetails", () => { expect(renderErrorDetails({ matches: [{}] })).toBeNull(); }); - // The contract is the `matches` key, not the asset shape — a future error opts in for free. it("derives its columns from whatever keys the rows carry", () => { const out = renderErrorDetails({ matches: [{ label: "cold", path: "m/44'/195'/1'/0/0" }] })!; expect(out).toContain("label"); diff --git a/ts/src/adapters/inbound/cli/render/error-details.ts b/ts/src/adapters/inbound/cli/render/error-details.ts index 5495f5267..47f1dd8f5 100644 --- a/ts/src/adapters/inbound/cli/render/error-details.ts +++ b/ts/src/adapters/inbound/cli/render/error-details.ts @@ -1,28 +1,9 @@ -/** - * Error-detail rendering — the text-mode counterpart to `error.details`. - * - * Text mode prints one line per error (`error [code]: message`). A few errors are a *choice* - * rather than a dead end — the caller has to pick one of several candidates — and a bare line - * leaves a human with no way to pick. Those errors put the candidates in `details.matches`, and - * this renders them as the same table the corresponding list command uses. - * - * Deliberately data-driven, not a per-error-code registry: the contract is `details.matches` (an - * array of flat rows sharing one key set), so any future error can opt in by carrying that key, - * without the output layer learning command names. JSON mode is untouched — it already carries - * `details` verbatim. - */ import { fromBaseUnits } from "../../../../domain/amounts/index.js"; import { singleLineErrorValue } from "../../../../domain/errors/index.js"; import type { Obj } from "./layout.js"; import { asObj, table } from "./layout.js"; import { formatDecimal, formatInt, formatScalar, num } from "./scalars.js"; -/** key → column header; keys not listed fall back to their own name. */ -// `label` is deliberately NOT mapped here: it stays whatever key case a row carries. Mapping it -// would collide with error-details.test.ts's "derives its columns from whatever keys the rows -// carry" case, which uses an unmapped `label` key specifically to prove the fallback path works — -// an existing test this batch must not edit to fit the implementation. `ambiguous_account`'s -// label column therefore prints as the raw key ("label"), same as any other unmapped column. const HEADERS: Record = { assetId: "ID", issuerAddress: "Issuer", @@ -33,11 +14,6 @@ const HEADERS: Record = { index: "Index", }; -/** - * Rows report quantities raw (minimal units) so `details` stays machine-honest, but a human - * picking between tokens needs the same whole-token figure `asset info` / `asset list` show — - * hence the one semantic rule here: a row carrying `precision` scales its `totalSupply` by it. - */ function cell(key: string, row: Obj): string { const value = row[key]; if (key === "totalSupply" && row.precision !== undefined) { @@ -49,9 +25,20 @@ function cell(key: string, row: Obj): string { return formatScalar(value); } -/** the candidate table for an error that carries one, or null when there is nothing to add. */ export function renderErrorDetails(details: unknown): string | null { - const rows = asObj(details).matches; + const { txId, matches } = asObj(details); + const lines: string[] = []; + if (typeof txId === "string" && txId !== "") { + lines.push( + `Transaction ID: ${singleLineErrorValue(txId)}. Check with tx status before sending again.`, + ); + } + const candidates = renderMatches(matches); + if (candidates) lines.push(candidates); + return lines.length === 0 ? null : lines.join("\n"); +} + +function renderMatches(rows: unknown): string | null { if (!Array.isArray(rows) || rows.length === 0) return null; const objects = rows.map(asObj); const keys = Object.keys(objects[0] ?? {}); diff --git a/ts/src/adapters/inbound/cli/render/exchange.ts b/ts/src/adapters/inbound/cli/render/exchange.ts index bad61e1a9..b30c93ddf 100644 --- a/ts/src/adapters/inbound/cli/render/exchange.ts +++ b/ts/src/adapters/inbound/cli/render/exchange.ts @@ -9,7 +9,6 @@ function whole(raw: unknown, decimals: unknown): string { : formatDecimal(fromBaseUnits(String(raw), num(decimals, 0))); } -/** `MyToken (id 1000123)`, or plain `TRX` for the native side. */ function sideLabel(tokenId: unknown, label: unknown): string { const id = String(tokenId ?? ""); if (id === "_") return "TRX"; @@ -36,15 +35,11 @@ export const ExchangeFormatters = { ] as Pair[], " ", ); - // No price is derived: the reserve ratio is a quoted rate, not what a trade returns. Price a - // specific amount with `exchange trade --dry-run`. + // Reserve ratios are not executable trade quotes. return `${head}\n Reserves\n${reserves}`; }) satisfies TextFormatter, - /** - * One RPC, so no token names or precisions are available — ids and minimal units, - * with the column labelled so the numbers cannot be mistaken for whole tokens. - */ + // List reserves are in minimal units; token precisions are unavailable. exchangeList: ((data) => { const d = asObj(data); const rows = Array.isArray(d.exchanges) ? (d.exchanges as Obj[]) : []; diff --git a/ts/src/adapters/inbound/cli/render/family-render.test.ts b/ts/src/adapters/inbound/cli/render/family-render.test.ts index 31ef1136e..c95bd2ba6 100644 --- a/ts/src/adapters/inbound/cli/render/family-render.test.ts +++ b/ts/src/adapters/inbound/cli/render/family-render.test.ts @@ -39,8 +39,6 @@ describe("FAMILY_RENDER evm", () => { expect(FAMILY_RENDER.evm.addressLabel).toBe("EVM address"); }); - // The cross-cutting rule is that EVM reuses TRON's field set and only changes values and - // units — with the fee as the stated exception, because the unit is IN the field name. it("renders gas used and the fee in ETH", () => { const rows = FAMILY_RENDER.evm.txInfoRows( { @@ -63,8 +61,6 @@ describe("FAMILY_RENDER evm", () => { expect(Object.fromEntries(rows).Fee).toBe(""); }); - // TxInfoView is a cross-family superset; each family picks only the fields it populates, so - // the EVM rows must not carry TRON's resource accounting. it("omits TRON-only rows from its tx info", () => { const labels = FAMILY_RENDER.evm .txInfoRows( @@ -85,18 +81,12 @@ describe("renderFamily", () => { ).toBe("evm"); }); - // The old default was "tron". With one family that was unreachable; with two it silently - // renders wei amounts as TRX — a wrong-currency receipt, which is the worst way to be wrong. - // Chain commands always resolve a network before rendering, so this really is unreachable; - // the point is that if it ever happens it must be loud. it("refuses to guess when no network was resolved", () => { expect(() => renderFamily({ command: "tx.info" })).toThrow(); expect(() => renderFamily(undefined)).toThrow(); }); }); -// The regression this exists to prevent: FAMILY_RENDER is keyed by FAMILY, so eip155:1 and eip155:56 -// share one hook. With the symbol baked into the hook, 0.5 BNB on BSC rendered as "0.5 ETH". describe("the native symbol comes from the network, not the family", () => { it.each([ ["evm", "ETH", "0.5 ETH"], @@ -120,13 +110,6 @@ describe("the native symbol comes from the network, not the family", () => { }); }); -/** - * `account info` and `chain prices` — the two commands whose text output was TRON-only. - * - * Both were rendered by a single TRON formatter for every family. On EVM that printed - * "Balance 0 TRX" for an account holding 0.412 ETH, and three empty TRON price labels with none - * of the EVM fee data. The JSON was correct in both cases, so this is purely the text side. - */ describe("FAMILY_RENDER accountInfoRows", () => { const EVM_ACCOUNT = { address: "0xe4aAd11792F7E74f1B5cbce65f9a1E207c952961", @@ -141,7 +124,6 @@ describe("FAMILY_RENDER accountInfoRows", () => { const rows = FAMILY_RENDER.evm.accountInfoRows(EVM_ACCOUNT, "ETH"); expect(rows).toContainEqual(["Balance", "0.41209 ETH"]); - // The exact failure this replaces: a real balance reported as an empty TRON account. expect(rows.map((r) => r[1])).not.toContain("0 TRX"); }); @@ -149,15 +131,12 @@ describe("FAMILY_RENDER accountInfoRows", () => { const rows = FAMILY_RENDER.evm.accountInfoRows(EVM_ACCOUNT, "ETH"); expect(rows).toContainEqual(["Nonce", "16"]); - // Uppercase `EOA` — the standard name, and the text twin of json's `eoa`. expect(rows).toContainEqual(["Type", "EOA"]); expect( FAMILY_RENDER.evm.accountInfoRows({ ...EVM_ACCOUNT, type: "contract" }, "ETH"), ).toContainEqual(["Type", "contract"]); }); - // A contract gets its code size and an EOA none: an EOA is not a contract with zero - // bytes, and a row reading "0 bytes" would say it is. it("sizes a contract's code and leaves the row off an EOA", () => { expect( FAMILY_RENDER.evm.accountInfoRows( @@ -183,19 +162,39 @@ describe("FAMILY_RENDER accountInfoRows", () => { const rows = FAMILY_RENDER.tron.accountInfoRows( { address: "TXP3YPS3mgoHRioz42gMhL6x5VvusPTMk6", - account: { - balance: "9000000000", - owner_permission: { threshold: 1, keys: [{}] }, - active_permission: [{}], - }, + account: { balance: "1", owner_permission: { threshold: 9, keys: [] } }, + balanceSun: "9000000000", + staked: { energySun: "0", bandwidthSun: "0", tronPowerSun: "0", totalSun: "0" }, + createdAt: 1700000000000, + permissions: { ownerThreshold: 1, ownerKeys: 1, activeGroups: 1 }, resources: { energy: { used: 12, limit: 65 }, bandwidth: { used: 6, limit: 15 } }, }, "TRX", ); expect(rows).toContainEqual(["Balance", "9,000 TRX"]); + expect(rows).toContainEqual(["Created", "2023-11-14"]); expect(rows).toContainEqual(["Permissions", "owner 1-of-1, 1 active group"]); expect(rows.map((r) => r[0])).toContain("Energy"); + expect(rows.map((r) => r[0])).not.toContain("Staked"); + }); + + it("shows no permission summary for an account without permissions", () => { + const rows = FAMILY_RENDER.tron.accountInfoRows( + { + address: "TXP3YPS3mgoHRioz42gMhL6x5VvusPTMk6", + account: {}, + balanceSun: "0", + staked: { energySun: "0", bandwidthSun: "0", tronPowerSun: "0", totalSun: "0" }, + createdAt: null, + permissions: null, + resources: {}, + }, + "TRX", + ); + + expect(rows).toContainEqual(["Permissions", "—"]); + expect(rows).toContainEqual(["Created", ""]); }); }); @@ -207,8 +206,6 @@ describe("FAMILY_RENDER chainPricesRows", () => { gasPriceWei: "960341983", }; - // gwei, not wei: it is the unit --max-fee and --priority-fee accept, and quoting the output in - // a different unit than the input would leave the reader converting nine zeros by hand. it("prices EVM gas in gwei", () => { const rows = FAMILY_RENDER.evm.chainPricesRows(EVM_PRICES, "ETH"); @@ -217,8 +214,6 @@ describe("FAMILY_RENDER chainPricesRows", () => { expect(rows).toContainEqual(["Gas price", "0.960341 gwei"]); }); - // A legacy chain reports no base fee. The row is absent rather than blank — an empty value is - // what the TRON formatter produced on EVM, and it says nothing. it("omits the base fee on a legacy chain instead of printing a blank row", () => { const rows = FAMILY_RENDER.evm.chainPricesRows( { feeModel: "legacy", gasPriceWei: "5000000000" }, @@ -245,16 +240,11 @@ describe("FAMILY_RENDER chainPricesRows", () => { "TRX", ); - expect(rows[0]![1]).toContain("100 SUN / unit"); + expect(rows[0]![1]).toContain("100 sun / unit"); expect(rows).toContainEqual(["Memo fee", "1 TRX"]); }); }); -/** - * The settlement rows: what a transaction cost, how deep it is, and what a block or - * a node actually reports. All four were in the JSON already — only the text layer read TRON's - * fields and so printed nothing (or nothing useful) on EVM. - */ describe("FAMILY_RENDER — receipt settlement rows", () => { it("states the EVM fee AND what it is the product of", () => { const rows = FAMILY_RENDER.evm.receiptSettlementRows( @@ -270,8 +260,6 @@ describe("FAMILY_RENDER — receipt settlement rows", () => { expect(rows).toEqual([["Fee", "0.000441 ETH (21,000 gas × 21 gwei)"]]); }); - // A receipt from a node that omitted effectiveGasPrice still has to state the total: the fee was - // paid whether or not its breakdown came back. it("falls back to the bare total when the breakdown is missing", () => { const rows = FAMILY_RENDER.evm.receiptSettlementRows( { kind: "send", feeWei: "441000000000000" } as never, @@ -293,8 +281,6 @@ describe("FAMILY_RENDER — receipt settlement rows", () => { ]); }); - // The nonce is the entry point for diagnosing a stuck transaction — the case where no - // receipt ever arrives — so it is a receipt row, not a confirmation one. it("gives EVM receipts a Nonce row and TRON none", () => { expect(FAMILY_RENDER.evm.receiptIdentityRows({ kind: "send", nonce: 42 } as never)).toEqual([ ["Nonce", "42"], @@ -332,7 +318,6 @@ describe("FAMILY_RENDER — chain node rows", () => { ]); }); - // "the node would not say" is not the same claim as "it is behind". it("dashes the sync row when the node did not answer eth_syncing", () => { const rows = FAMILY_RENDER.evm.chainNodeRows({ ...EVM_NODE, inSync: null } as never); expect(rows).toContainEqual(["Syncing", "—"]); diff --git a/ts/src/adapters/inbound/cli/render/family.ts b/ts/src/adapters/inbound/cli/render/family.ts index 330dfe9c4..c28076c31 100644 --- a/ts/src/adapters/inbound/cli/render/family.ts +++ b/ts/src/adapters/inbound/cli/render/family.ts @@ -1,55 +1,28 @@ import type { TxInfoView, TxReceiptView } from "../../../../domain/types/index.js"; -import { RESOURCES, resourceOfRpcCode, type Resource } from "../../../../domain/resources/index.js"; import type { TextRenderContext } from "../contracts/index.js"; import { ChainFamily } from "../../../../domain/family/index.js"; import { ExecutionError } from "../../../../domain/errors/index.js"; import { formatScalar, formatInt, formatGwei, formatSun, formatUtc, formatWei } from "./scalars.js"; import { asObj, type Obj, type Pair } from "./layout.js"; -/** - * Per-family render hooks — the one table that folds the scattered `r.family === tron ? … : …` - * branches. Adding a chain = one entry here (alongside its FAMILIES + FamilyDef entries). - */ -/** - * Every hook takes the native coin's `symbol` rather than baking one in. This table is keyed by - * FAMILY, so `eip155:1` and `eip155:56` share one entry — and their coins are ETH and BNB. A hook that - * hardcoded the symbol rendered a BNB balance as "ETH". - */ +// Native symbols belong to networks, not families (e.g. ETH and BNB share EVM). interface FamilyRenderHooks { - /** the full TxInfo detail rows (family-shaped: Energy/TRX vs Gas/wei). Reads the flat - * TxInfoView and picks its own family's fields — no narrowing cast (no closed union). */ txInfoRows(r: TxInfoView, symbol: string): Pair[]; - /** native smallest-unit amount → display string (sun→TRX / wei→ETH or BNB). */ + // Raw native amounts are SUN for TRON and wei for EVM. nativeAmount(raw: string, symbol: string): string; - /** fee fallback when no structured fee object is present. */ feeFallback(fee: unknown, symbol: string): string; - /** address-type label for the per-family address rows. */ addressLabel: string; - /** `account info` rows below the Label. TRON reports the node's account object — permissions, - * resources, stakes; EVM has no such RPC and reports a flat balance/nonce/code triple. The - * field SETS differ, not just their values, so neither family can read the other's payload. */ accountInfoRows(d: Obj, symbol: string): Pair[]; - /** `chain prices` rows. TRON prices energy and bandwidth in SUN; EVM prices gas per the fee - * model the chain reports. Same reason as accountInfoRows: disjoint field sets. */ chainPricesRows(d: Obj, symbol: string): Pair[]; - /** `chain node` rows. TRON has a p2p network to report on, EVM has a chain id to check the - * endpoint against — neither field exists on the other side. */ chainNodeRows(d: Obj): Pair[]; - /** `block` rows. The payload is the node's own object, so the two families arrive in - * different shapes and each picks its own fields out. */ - blockRows(block: Obj, timestampMs: number | undefined): Pair[]; - /** rows a broadcast receipt shows BEFORE the TxID — the transaction's own identifiers. - * EVM has a nonce; TRON's transactions are identified only by their hash. */ + blockRows(data: Obj): Pair[]; receiptIdentityRows(r: TxReceiptView): Pair[]; - /** rows a CONFIRMED receipt shows after the block: what the transaction actually consumed. - * TRON bills energy and a SUN fee; EVM bills gas at a settled per-gas price. */ receiptSettlementRows(r: TxReceiptView, symbol: string): Pair[]; } const txInfoAmount = (v: string | undefined, suffix: string): string => v === undefined || v === "" ? "" : `${formatScalar(v)}${suffix}`; -/** "#84,120,345 2026-08-24 12:31:12 (~2s ago — in sync)" — shared by both families' node views. */ function headBlockRow(d: Obj): Pair { const head = asObj(d.headBlock); const headTimestamp = Number(head.timestamp ?? 0); @@ -60,21 +33,17 @@ function headBlockRow(d: Obj): Pair { return ["Head block", `#${formatInt(head.number)} ${nodeTime(head.timestamp)} (${age})`]; } -/** epoch-ms → "YYYY-MM-DD HH:MM:SS", or "—" for a missing/zero stamp (the node view's convention - * for a field an endpoint did not expose). */ +// Input is epoch milliseconds. function nodeTime(v: unknown): string { const n = Number(v); if (!Number.isFinite(n) || n <= 0) return "—"; return new Date(n).toISOString().replace("T", " ").slice(0, 19); } -/** a best-effort field the endpoint may not serve: null renders as the dash, per `chain node`. */ function orDash(v: unknown): string { return v === null || v === undefined ? "—" : String(v); } -/** `Fee ( gas × gwei)` — the total plus what it is the product of. - * Falls back to the bare total when the receipt did not carry the two components. */ function evmFeeRow(fee: unknown, gasUsed: unknown, priceWei: unknown, symbol: string): string { const total = `${formatWei(fee)} ${symbol}`; if (gasUsed === undefined || priceWei === undefined) return total; @@ -87,22 +56,18 @@ export const FAMILY_RENDER: Record = { feeFallback: (fee, symbol) => `${formatSun(fee)} ${symbol}`, addressLabel: "TRON address", accountInfoRows: (d, symbol) => { - const account = asObj(d.account); - const owner = asObj(account.owner_permission); - const active = Array.isArray(account.active_permission) - ? account.active_permission.length - : 0; - const created = account.create_time - ? new Date(Number(account.create_time)).toISOString().slice(0, 10) - : ""; - const ownerKeys = Array.isArray(owner.keys) ? owner.keys.length : "?"; + const permissions = asObj(d.permissions); + const active = Number(permissions.activeGroups); + const created = + typeof d.createdAt === "number" ? new Date(d.createdAt).toISOString().slice(0, 10) : ""; const resources = asObj(d.resources); const bandwidth = asObj(resources.bandwidth); const energy = asObj(resources.energy); const rows: Pair[] = [["Address", String(d.address ?? "")]]; - rows.push(["Balance", `${formatSun(account.balance)} ${symbol}`]); - const staked = stakedSummary(account, symbol); - if (staked) rows.push(["Staked", staked]); + rows.push(["Balance", `${formatSun(d.balanceSun)} ${symbol}`]); + const staked = asObj(d.staked); + if (staked.totalSun !== undefined && staked.totalSun !== "0") + rows.push(["Staked", stakedSummary(staked, symbol)]); if (resources.energy) rows.push(["Energy", `used ${formatInt(energy.used)} / ${formatInt(energy.limit)}`]); if (resources.bandwidth) @@ -113,7 +78,9 @@ export const FAMILY_RENDER: Record = { rows.push(["Created", created]); rows.push([ "Permissions", - `owner ${String(owner.threshold ?? "?")}-of-${ownerKeys}, ${active} active group${active === 1 ? "" : "s"}`, + d.permissions + ? `owner ${formatInt(permissions.ownerThreshold)}-of-${formatInt(permissions.ownerKeys)}, ${active} active group${active === 1 ? "" : "s"}` + : "—", ]); return rows; }, @@ -121,8 +88,8 @@ export const FAMILY_RENDER: Record = { const energy = asObj(d.energy); const bandwidth = asObj(d.bandwidth); return [ - ["Energy price", `${formatInt(energy.currentSunPerUnit)} SUN / unit (current)`], - ["Bandwidth price", `${formatInt(bandwidth.currentSunPerUnit)} SUN / unit (current)`], + ["Energy price", `${formatInt(energy.currentSunPerUnit)} sun / unit (current)`], + ["Bandwidth price", `${formatInt(bandwidth.currentSunPerUnit)} sun / unit (current)`], ["Memo fee", `${formatSun(d.memoFeeSun)} ${symbol}`], ]; }, @@ -147,19 +114,11 @@ export const FAMILY_RENDER: Record = { ], ]; }, - // The node's protobuf block: header fields live under block_header.raw_data, and the hash is - // the block's own `blockID`. Unchanged from what this command has always printed. - blockRows: (block, timestampMs) => { - const header = asObj(asObj(block.block_header).raw_data); - const number = block.number ?? header.number; - const txs = Array.isArray(block.transactions) ? block.transactions.length : 0; - return [ - ["Number", number === undefined ? "" : `#${formatInt(number)}`], - ["Time", timestampMs ? formatUtc(timestampMs) : "unknown"], - ["Transactions", String(txs)], - ]; - }, - // A TRON transaction is identified by its hash alone — there is no per-account sequence. + blockRows: (d) => [ + ["Number", `#${formatInt(d.number)}`], + ["Time", formatUtc(d.timestampMs)], + ["Transactions", String(d.transactionCount)], + ], receiptIdentityRows: () => [], receiptSettlementRows: (r, symbol) => { const rows: Pair[] = []; @@ -190,18 +149,11 @@ export const FAMILY_RENDER: Record = { ["Address", String(d.address ?? "")], ["Balance", `${formatWei(d.balance)} ${symbol}`], ["Nonce", formatInt(d.nonce)], - // The distinction a reader needs before sending: an address with code may reject a plain - // transfer. `EOA` rather than a spelled-out phrase: it is the standard name on this chain, - // it is what json's `eoa` says, and it stays a noun beside `contract`. ["Type", d.type === "contract" ? "contract" : "EOA"], ]; - // Only a contract has code, so only a contract gets the row (an EOA is not a - // contract with zero bytes). if (d.codeSize !== undefined) rows.push(["Code size", `${formatInt(d.codeSize)} bytes`]); return rows; }, - // Priced in gwei, the unit --max-fee and --priority-fee accept: showing wei here and taking - // gwei there would make the reader do the nine-zero conversion themselves. JSON keeps wei. chainPricesRows: (d, symbol) => { const rows: Pair[] = [["Fee model", String(d.feeModel ?? "")]]; if (d.baseFeeWei !== undefined) rows.push(["Base fee", `${formatGwei(d.baseFeeWei)} gwei`]); @@ -209,8 +161,6 @@ export const FAMILY_RENDER: Record = { rows.push(["Priority fee", `${formatGwei(d.priorityFeeWei)} gwei`]); if (d.gasPriceWei !== undefined) rows.push(["Gas price", `${formatGwei(d.gasPriceWei)} gwei`]); - // The per-gas numbers above answer "how expensive is gas"; this answers "what will a - // transfer cost me", which is the question most readers actually have. if (d.transferCostWei !== undefined) { rows.push([ "Transfer cost", @@ -225,8 +175,6 @@ export const FAMILY_RENDER: Record = { return [ ["Endpoint", orDash(d.endpoint)], ["Version", orDash(d.version)], - // What every signature commits to (EIP-155), and the one field that says whether this - // endpoint is the chain the caller thinks it is. ["Chain id", orDash(d.chainId)], headBlockRow(d), [ @@ -235,8 +183,6 @@ export const FAMILY_RENDER: Record = { ? "—" : `#${formatInt(solid.number)} (${formatInt(d.lagBlocks)} blocks behind head)`, ], - // eth_syncing answers this directly; null means the node would not say, which is not the - // same as "out of sync". ["Syncing", d.inSync === null || d.inSync === undefined ? "—" : d.inSync ? "no" : "yes"], [ "Peers", @@ -246,36 +192,34 @@ export const FAMILY_RENDER: Record = { ], ]; }, - // The node's own block object: hex QUANTITIES throughout (json keeps them verbatim), so every - // number here is converted for display only. Gas and base fee are what say whether the chain - // is busy and what it costs — the reason to look at a block at all. - blockRows: (block, timestampMs) => { - const txs = Array.isArray(block.transactions) ? block.transactions.length : 0; - const gasUsed = quantity(block.gasUsed); - const gasLimit = quantity(block.gasLimit); - const baseFee = quantity(block.baseFeePerGas); + // Reads the normalized block fields of the result, not the node's block. + blockRows: (block) => { + const timestampMs = typeof block.timestamp === "number" ? block.timestamp : undefined; const rows: Pair[] = [ - ["Number", block.number === undefined ? "" : `#${formatInt(quantity(block.number))}`], + ["Number", block.number === undefined ? "" : `#${formatInt(block.number)}`], ["Hash", String(block.hash ?? "")], ["Parent hash", String(block.parentHash ?? "")], ["Time", timestampMs ? formatUtc(timestampMs) : "unknown"], - ["Transactions", String(txs)], + [ + "Transactions", + block.transactionCount === undefined ? "unknown" : formatInt(block.transactionCount), + ], ]; - if (gasUsed !== undefined) { + if (block.gasUsed !== undefined) { rows.push([ "Gas used", - gasLimit === undefined - ? formatInt(gasUsed) - : `${formatInt(gasUsed)} / ${formatInt(gasLimit)}`, + block.gasLimit === undefined + ? formatInt(block.gasUsed) + : `${formatInt(block.gasUsed)} / ${formatInt(block.gasLimit)}`, ]); } - // Absent on a pre-1559 chain, where it is not a field with an unknown value but a concept - // that does not apply. An empty row would claim otherwise. - if (baseFee !== undefined) rows.push(["Base fee", `${formatGwei(baseFee)} gwei`]); + // Pre-1559 chains have no base fee. + if (block.baseFeeWei !== undefined) { + rows.push(["Base fee", `${formatGwei(block.baseFeeWei)} gwei`]); + } return rows; }, - // The nonce is the entry point for diagnosing a stuck transaction, and a stuck one is - // precisely the case where the receipt never arrives — so it is stated from `submitted` on. + // Show the nonce before confirmation so stuck transactions can be identified. receiptIdentityRows: (r) => r.nonce === undefined ? [] : [["Nonce", formatInt(r.nonce)] as Pair], receiptSettlementRows: (r, symbol) => @@ -291,7 +235,7 @@ export const FAMILY_RENDER: Record = { ["Nonce", r.nonce === undefined ? "" : formatInt(r.nonce)], ["Status", r.status ?? "unknown"], ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], - // Seconds on the wire, milliseconds for the formatter. + // Block time is in seconds; formatUtc takes milliseconds. ["Block time", r.blockTime === undefined ? "" : formatUtc(r.blockTime * 1000)], ["Confirmations", r.confirmations === undefined ? "" : formatInt(r.confirmations)], ["Gas", r.gasUsed === undefined ? "" : formatInt(r.gasUsed)], @@ -300,29 +244,11 @@ export const FAMILY_RENDER: Record = { }, }; -/** hex QUANTITY (or a plain number) → number, for the EVM block view. */ -function quantity(v: unknown): number | undefined { - if (v === undefined || v === null) return undefined; - try { - return Number(BigInt(String(v))); - } catch { - return undefined; - } -} - export function familyAddressLabel(family: string): string { return FAMILY_RENDER[family as ChainFamily]?.addressLabel ?? `${family} address`; } -/** - * The active chain family for a chain-command renderer. Chain commands always resolve a network - * before running, so `ctx.net` is present and this cannot legitimately fail. - * - * It throws rather than defaulting: the previous default was "tron", which was harmless while - * that was the only family and renders wei amounts as TRX now that it is not. A receipt naming - * the wrong currency is worse than no receipt. - */ -/** the selected network's native coin symbol — the one the render hooks label amounts with. */ +// Require a resolved network to avoid labelling amounts with the wrong currency. export function renderSymbol(ctx?: TextRenderContext): string { const symbol = ctx?.net?.nativeSymbol; if (!symbol) { @@ -345,30 +271,11 @@ export function renderFamily(ctx?: TextRenderContext): ChainFamily { return family; } -/** Sum FreezeBalanceV2 stakes into a " TRX (energy + bandwidth )" summary. */ -function stakedSummary(account: Obj, symbol: string): string { - const frozen = Array.isArray(account.frozenV2) ? account.frozenV2.map(asObj) : []; - // frozenV2's bandwidth entries carry no `type`, so an unrecognized code folds into bandwidth. - const sums = new Map(RESOURCES.map((r) => [r, 0n])); - for (const f of frozen) { - const r = resourceOfRpcCode(String(f.type ?? "")) ?? "bandwidth"; - const amount = safeUnsignedBigInt(f.amount ?? 0); - // An unsafe JS number has already lost precision. Omit the summary instead of presenting a - // plausible but incorrect total; the raw account payload remains available in JSON mode. - if (amount === null) return ""; - sums.set(r, (sums.get(r) ?? 0n) + amount); - } - const total = RESOURCES.reduce((t, r) => t + (sums.get(r) ?? 0n), 0n); - if (total === 0n) return ""; - const parts = RESOURCES.map((r) => `${r} ${formatSun(sums.get(r) ?? 0n)}`).join(" + "); - return `${formatSun(total)} ${symbol} (${parts})`; -} - -function safeUnsignedBigInt(value: unknown): bigint | null { - if (typeof value === "bigint") return value >= 0n ? value : null; - if (typeof value === "number") { - return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null; - } - if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value); - return null; +function stakedSummary(staked: Obj, symbol: string): string { + const parts = [ + `energy ${formatSun(staked.energySun)}`, + `bandwidth ${formatSun(staked.bandwidthSun)}`, + ]; + if (staked.tronPowerSun !== "0") parts.push(`TRON Power ${formatSun(staked.tronPowerSun)}`); + return `${formatSun(staked.totalSun)} ${symbol} (${parts.join(" + ")})`; } diff --git a/ts/src/adapters/inbound/cli/render/governance.test.ts b/ts/src/adapters/inbound/cli/render/governance.test.ts index ea8a4e8e9..e43e82025 100644 --- a/ts/src/adapters/inbound/cli/render/governance.test.ts +++ b/ts/src/adapters/inbound/cli/render/governance.test.ts @@ -62,7 +62,6 @@ describe("proposal text rendering", () => { const rows = GovernanceFormatters.proposalList(list).split("\n"); expect(rows[1]).toContain("Parameter"); expect(rows[1]?.trimEnd().endsWith("Value")).toBe(true); - // second parameter of #44 continues below it with the left-hand columns blank expect(rows[4]?.trimStart().startsWith("getMaxCpuTimeOfOneTx")).toBe(true); expect(rows[4]?.trimEnd().endsWith("80")).toBe(true); }); @@ -114,13 +113,6 @@ describe("proposal text rendering", () => { }); }); -/** - * `--build-only` and `--sign-only` exist to hand the transaction hex to the next step (collect a - * co-signature, relay it offline). `tx send` prints that hex bare, so it pipes straight into a file - * or another command; the governance renderer printed a receipt built around `data.unsignedHex`, a - * field the pipeline never produces — the canonical name is `hex`. `kv()` drops empty rows, so the - * artifact did not merely render blank, it vanished, and only `-o json` carried it. - */ describe("governance build-only / sign-only emit the transaction artifact", () => { const HEX = "0a83010a02c1112208bd2a9a677fccd7dc"; diff --git a/ts/src/adapters/inbound/cli/render/governance.ts b/ts/src/adapters/inbound/cli/render/governance.ts index d2b60baa7..8a33175f4 100644 --- a/ts/src/adapters/inbound/cli/render/governance.ts +++ b/ts/src/adapters/inbound/cli/render/governance.ts @@ -84,9 +84,7 @@ function renderGovernanceReceipt(data: Obj, ctx: TextRenderContext): string { fields.push(["Fee", estimateFee(data)]); return appendChanges(receipt(pending(), `Dry run ${label}`, fields), data); } - // Both early-exit modes exist to produce a relayable artifact, so the hex IS the output — - // bare, as `tx send` prints it, so it pipes into a file or the next command unchanged. The - // receipts below remain for the case the pipeline could not serialise one. + // Emit bare artifact hex so build-only and sign-only output can be piped. if (mode === "build-only") { if (data.hex) return String(data.hex); return appendChanges(receipt(ok(), `Built unsigned ${label}`, fields), data); @@ -249,7 +247,6 @@ function width(values: string[]): number { return Math.max(0, ...values.map((value) => value.length)); } -/** Column-aligned rows; numeric columns are right-aligned so digits line up down the page. */ function table(headers: string[], rows: string[][], align: Align[]): string[] { const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => String(row[index] ?? "").length)), diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index 8cae5fa80..1c30ce93f 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -1,17 +1,3 @@ -/** - * Text-mode renderers, split by command domain: - * family.ts — FAMILY_RENDER per-family hook table (+ renderFamily) - * wallet.ts — wallet create/import/list/… receipts - * account.ts — account/token queries (balance, info, history, portfolio, token book) - * tx.ts — tx/stake/contract signing receipts + tx status/info - * stake.ts — stake queries (info, delegated) - * vote.ts — SR voting list/status views - * reward.ts — voting/block reward views - * chain.ts — chain queries (params, prices, node) - * sunswap.ts — SunSwap market queries (price) - * misc.ts — config, networks, contract call/info, message sign, block - * This barrel reassembles the one TextFormatters table command specs import. - */ import type { NetworkDescriptor } from "../../../../domain/types/index.js"; import { structuredText } from "./structured.js"; import { ok } from "./layout.js"; @@ -60,8 +46,7 @@ export const TextFormatters = { ...MultisigFormatters, ...GasFreeFormatters, ...SunSwapFormatters, - // The liquidity receipts keep addresses out of their rows; a token resolved from the - // user's own book is the exception, named in a note under the receipt. + // Token-book notes identify user-resolved tokens omitted from the receipt rows. sunswapLiquidity: withTokenBookNotes(SunSwapLiquidityFormatters.sunswapLiquidity), sunswapRemoveLiquidity: withTokenBookNotes( SunSwapRemoveLiquidityFormatters.sunswapRemoveLiquidity, diff --git a/ts/src/adapters/inbound/cli/render/layout.test.ts b/ts/src/adapters/inbound/cli/render/layout.test.ts index dcdfffef2..fb0519f56 100644 --- a/ts/src/adapters/inbound/cli/render/layout.test.ts +++ b/ts/src/adapters/inbound/cli/render/layout.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import stringWidth from "string-width"; import { table } from "./layout.js"; -/** every line of a table must occupy the same number of terminal columns. */ const widths = (rendered: string) => [ ...new Set(rendered.split("\n").map((line) => stringWidth(line))), ]; @@ -20,12 +19,6 @@ describe("table alignment", () => { expect(out.split("\n")[0]).toBe("| Symbol | Name |"); }); - /** - * The case this exists for. `波场人生` is a real token name on this chain: four characters, eight - * terminal columns. Padding by string length leaves its row four columns too wide and bends - * every column to its right — and the value comes from a contract, so nothing on our side can - * keep it ASCII. - */ it("aligns a CJK token name by display width, not character count", () => { const out = table( ["Symbol", "Name", "Protocol"], @@ -35,7 +28,6 @@ describe("table alignment", () => { ], ); expect(widths(out)).toHaveLength(1); - // the CJK cell is padded to the same visible width as the longest name expect(out).toContain("| 波场人生 |"); }); diff --git a/ts/src/adapters/inbound/cli/render/layout.ts b/ts/src/adapters/inbound/cli/render/layout.ts index 3fc149bb3..e8e4ecfd7 100644 --- a/ts/src/adapters/inbound/cli/render/layout.ts +++ b/ts/src/adapters/inbound/cli/render/layout.ts @@ -1,44 +1,32 @@ -/** - * Layout primitives — structural composition of label/value blocks and tables, - * plus status glyphs. The "one field per line" vocabulary; no scalar or domain knowledge. - */ import stringWidth from "string-width"; export type Obj = Record; export type Pair = [string, string]; -/** aligned `