From 8da4be6098d7bdb1d09558f05b7e09a896ad293d Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 8 Jul 2026 00:40:19 +0800 Subject: [PATCH 01/16] refactor(cli): family-keyed command contract with unified command id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One ChainCommandDefinition per logical chain command: a neutral, service-free ChainSpec (path, policy, baseFields, shared formatText) plus a family->FamilyBinding table (run + delta fields/refine). Registry assembles same-path bindings; shell dispatches by resolving the network first, then the binding via net.family (network_family_mismatch when absent), merging baseFields with the binding's delta and composing base->family refines. - command id = path.join(".") — drop the tron. prefix; the family travels in the envelope's chain.family - help/catalog render one entry per chain command with families: [...] and a merged input schema - registerTronChainCommands(registry, deps) replaces TronModule; commands/tron/* folded up into commands/* - remove dead legacy paths: resolveCandidates/resolveForFamily/tree(), CommandDefinition.family, and the Networked/Networkless union — CommandDefinition is now neutral-and-networkless by type User-facing surface is unchanged: --json-schema catalog is identical modulo the tron. prefix removal; all describe()/summary strings are byte-identical; golden suite 360 passed / 2 skipped; tsc clean. Co-Authored-By: Claude Opus 4.8 --- ...wallet-cli-architecture-source-of-truth.md | 20 +-- ts/src/adapters/inbound/cli/command-id.ts | 11 +- .../adapters/inbound/cli/commands/account.ts | 64 ++++++++ ts/src/adapters/inbound/cli/commands/block.ts | 19 +++ .../adapters/inbound/cli/commands/contract.ts | 153 ++++++++++++++++++ .../adapters/inbound/cli/commands/shared.ts | 51 +++--- .../inbound/cli/commands/{tron => }/stake.ts | 61 +++---- .../cli/commands/text-formatters.test.ts | 11 +- .../{tron/shared.ts => token-selector.ts} | 1 - ts/src/adapters/inbound/cli/commands/token.ts | 87 ++++++++++ .../inbound/cli/commands/tron/account.ts | 72 --------- .../inbound/cli/commands/tron/block.ts | 28 ---- .../inbound/cli/commands/tron/contract.ts | 153 ------------------ .../inbound/cli/commands/tron/index.ts | 48 ------ .../inbound/cli/commands/tron/message.ts | 11 -- .../inbound/cli/commands/tron/token.ts | 88 ---------- .../adapters/inbound/cli/commands/tron/tx.ts | 123 -------------- ts/src/adapters/inbound/cli/commands/tx.ts | 128 +++++++++++++++ .../inbound/cli/commands/wallet.test.ts | 8 +- .../adapters/inbound/cli/contracts/command.ts | 82 +++++++--- .../cli/contracts/command.types.test.ts | 15 ++ ts/src/adapters/inbound/cli/help/catalog.ts | 64 +++++--- ts/src/adapters/inbound/cli/help/help.test.ts | 43 +++-- ts/src/adapters/inbound/cli/help/index.ts | 124 +++++++------- .../inbound/cli/output/envelope.test.ts | 9 +- .../adapters/inbound/cli/output/envelope.ts | 11 +- ts/src/adapters/inbound/cli/output/index.ts | 14 +- .../inbound/cli/output/output.test.ts | 28 ++-- ts/src/adapters/inbound/cli/registry/index.ts | 101 ++++++------ .../cli/registry/registry.chain.test.ts | 28 ++++ .../inbound/cli/registry/registry.test.ts | 50 ++---- ts/src/adapters/inbound/cli/shell/index.ts | 113 +++++++++---- .../inbound/cli/shell/shell.chain.test.ts | 71 ++++++++ ts/src/bootstrap/composition.ts | 22 ++- ts/src/bootstrap/families/tron.ts | 113 +++++++++++-- ts/src/bootstrap/families/types.ts | 21 +-- ts/test/golden.test.ts | 14 +- 37 files changed, 1137 insertions(+), 923 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/account.ts create mode 100644 ts/src/adapters/inbound/cli/commands/block.ts create mode 100644 ts/src/adapters/inbound/cli/commands/contract.ts rename ts/src/adapters/inbound/cli/commands/{tron => }/stake.ts (74%) rename ts/src/adapters/inbound/cli/commands/{tron/shared.ts => token-selector.ts} (99%) create mode 100644 ts/src/adapters/inbound/cli/commands/token.ts delete mode 100644 ts/src/adapters/inbound/cli/commands/tron/account.ts delete mode 100644 ts/src/adapters/inbound/cli/commands/tron/block.ts delete mode 100644 ts/src/adapters/inbound/cli/commands/tron/contract.ts delete mode 100644 ts/src/adapters/inbound/cli/commands/tron/index.ts delete mode 100644 ts/src/adapters/inbound/cli/commands/tron/message.ts delete mode 100644 ts/src/adapters/inbound/cli/commands/tron/token.ts delete mode 100644 ts/src/adapters/inbound/cli/commands/tron/tx.ts create mode 100644 ts/src/adapters/inbound/cli/commands/tx.ts create mode 100644 ts/src/adapters/inbound/cli/contracts/command.types.test.ts create mode 100644 ts/src/adapters/inbound/cli/registry/registry.chain.test.ts create mode 100644 ts/src/adapters/inbound/cli/shell/shell.chain.test.ts diff --git a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md index 6c13d906..dae13d5c 100644 --- a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md +++ b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md @@ -262,7 +262,7 @@ interface FamilyPlugin { } ``` -`bootstrap/families/tron.ts` is TRON's concrete composition: it builds the `TronRpcClient`, the TronGrid history reader, the TRON use cases, and the `TronModule`. Application and adapters must not import the family registry in reverse. +`bootstrap/families/tron.ts` is TRON's concrete composition: it builds the `TronRpcClient`, the TronGrid history reader, the TRON use cases, and registers each command via `registerTronChainCommands`, which `addChain`s the neutral `ChainSpec` for each command together with its TRON `FamilyBinding`. Application and adapters must not import the family registry in reverse. --- @@ -288,23 +288,25 @@ interface FamilyPlugin { | `run` | Translates CLI input/context into a use-case call and returns structured data. | | `formatText` | Optional text renderer; JSON does not use it. | -The stable command id is derived from metadata: a neutral command is `path.join(".")`, e.g. `import.mnemonic`; a chain command is `family.path`, e.g. `tron.tx.send`. +The stable command id is derived from metadata as `path.join(".")` for every command — a neutral command is e.g. `import.mnemonic`, and a chain command is e.g. `tx.send` (no family prefix). The family is not encoded in the id; it travels in the envelope's `chain` view (`chain.family`), so the id matches what the user types and is redundancy-free. ### 4.2 The Two Command Classes and Routing +A chain command is one `ChainCommandDefinition` — a service-free `ChainSpec` plus a `families` table of per-family `FamilyBinding`s (`run` + optional option delta). The registry keys it by logical path; dispatch resolves the network, then selects the binding by `network.family`. + ```mermaid flowchart LR PATH[Parsed path] --> KIND{neutral exact match?} KIND -->|yes| NEUTRAL[resolveNeutral] - KIND -->|no| CAND[resolveCandidates] - CAND --> NET[resolve explicit/default network] - NET --> FAMILY[choose candidate by network.family] - NEUTRAL --> EXEC[common executeCommand] - FAMILY --> EXEC + KIND -->|no| CHAIN[resolveChain by logical path] + CHAIN --> NET[resolve explicit/default network] + NET --> BIND[select families network.family binding] + NEUTRAL --> EXEC[executeCommand] + BIND --> XEXEC[executeChainCommand: merge base+delta, run, spec.formatText] ``` - `tron` is not a public prefix for ordinary execution commands; `--network` decides the family. -- Help/JSON Schema may use the family prefix to address a concrete implementation precisely. +- Help/JSON Schema may accept a leading family token (e.g. `tron block --json-schema`) as an addressing convenience, but the emitted id stays unqualified and the catalog entry lists `families: [...]`. - An unknown top-level/subcommand/flag must return `unknown_command` or `invalid_option`; yargs must not silently succeed. ### 4.3 The Fixed Dispatch Order @@ -668,7 +670,7 @@ flowchart LR PLUGIN --> TEST[7 routing/output/contract tests] ``` -Adding a family must extend `ChainFamily`/`FAMILIES`, the discriminated network/address types, `ChainGatewayMap`, the sign strategy, the gateway, use cases, commands, the family plugin, and networks/render/tests. Only a genuinely identical intent and I/O shape may be factored into a shared port; the TRON resource model and the EVM gas/nonce must remain separate. +Adding a family must extend `ChainFamily`/`FAMILIES`, the discriminated network/address types, `ChainGatewayMap`, the sign strategy, the gateway, and use cases; add a `FamilyBinding` for that family to each shared command's `ChainSpec.families` table (with its option delta in `binding.fields` and family-shaped rows in `FAMILY_RENDER[family]`) rather than defining new command objects; and extend networks/render/tests. Only a genuinely identical intent and I/O shape may be factored into a shared port; the TRON resource model and the EVM gas/nonce must remain separate. ### 14.3 Adding a Wallet Source diff --git a/ts/src/adapters/inbound/cli/command-id.ts b/ts/src/adapters/inbound/cli/command-id.ts index 22d1a9aa..0e50bfe6 100644 --- a/ts/src/adapters/inbound/cli/command-id.ts +++ b/ts/src/adapters/inbound/cli/command-id.ts @@ -1,12 +1,7 @@ -/** - * Canonical command identifier — derived purely from `family` + `path`, never stored. - * This is the value surfaced as the `command` field in every result/error envelope, and the - * stable handle agents key on. Chain commands are family-qualified so the same logical path - * (e.g. tx send) yields a per-chain id (tron.tx.send); neutral commands - * are just their path (create, import.mnemonic, config.get, networks). - */ 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.family ? [cmd.family, ...cmd.path].join(".") : cmd.path.join("."); + return cmd.path.join("."); } diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts new file mode 100644 index 00000000..8d365956 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { TronAccountService } from "../../../../application/use-cases/tron/account-service.js"; +import { ciEnum } from "../arity/index.js"; +import { TextFormatters } from "../render/index.js"; + +export const accountBalanceSpec: ChainSpec = { + path: ["account", "balance"], + network: "optional", wallet: "optional", auth: "none", + capability: "account.balance.native", + summary: "Show native balance (TRX/SUN)", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli account balance" }], + formatText: TextFormatters.accountBalance, +}; + +export const accountBalanceTronBinding = (svc: TronAccountService): FamilyBinding => ({ + run: async (ctx, net) => svc.balance(ctx, net, "tron"), +}); + +export const accountInfoSpec: ChainSpec = { + path: ["account", "info"], + network: "optional", wallet: "optional", auth: "none", + summary: "Show raw account data (getAccount; TRON includes resources)", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli account info" }], + formatText: TextFormatters.accountInfo, +}; + +export const accountInfoTronBinding = (svc: TronAccountService): FamilyBinding => ({ + run: async (ctx, net) => svc.info(ctx, net), +}); + +export const accountHistorySpec: ChainSpec = { + path: ["account", "history"], + network: "optional", wallet: "optional", auth: "none", + summary: "Show transaction history (requires TronGrid)", + baseFields: z.object({ + limit: z.coerce.number().int().positive().max(200).default(20) + .describe("maximum records to return, in records; range: 1-200"), + only: ciEnum(["native", "token"]).optional() + .describe("filter history by transfer type; omit to show all transfer types"), + }), + examples: [{ cmd: "wallet-cli account history --limit 10" }], + formatText: TextFormatters.accountHistory, +}; + +export const accountHistoryTronBinding = (svc: TronAccountService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.historyFor(ctx, net, input), +}); + +export const accountPortfolioSpec: ChainSpec = { + path: ["account", "portfolio"], + network: "optional", wallet: "optional", auth: "none", + capability: "account.portfolio", + summary: "Show native + token balances with best-effort USD value", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli account portfolio" }], + formatText: TextFormatters.accountPortfolio, +}; + +export const accountPortfolioTronBinding = (svc: TronAccountService): FamilyBinding => ({ + run: async (ctx, net) => svc.portfolio(ctx, net), +}); diff --git a/ts/src/adapters/inbound/cli/commands/block.ts b/ts/src/adapters/inbound/cli/commands/block.ts new file mode 100644 index 00000000..fdc268a8 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/block.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { TronBlockService } from "../../../../application/use-cases/tron/block-service.js"; +import { Schemas } from "../schemas/index.js"; +import { TextFormatters } from "../render/index.js"; + +export const blockSpec: ChainSpec = { + path: ["block"], + network: "optional", wallet: "none", auth: "none", + positionals: [{ field: "number" }], + summary: "Get a block (latest if omitted)", + baseFields: z.object({ number: Schemas.uintString().optional().describe("block number to fetch, in block height; omit to fetch the latest block") }), + examples: [{ cmd: "wallet-cli block" }, { cmd: "wallet-cli block 12345" }], + formatText: TextFormatters.block, +}; + +export const blockTronBinding = (svc: TronBlockService): FamilyBinding => ({ + run: async (_ctx, net, input) => svc.get(net, input.number), +}); diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts new file mode 100644 index 00000000..2183acf8 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -0,0 +1,153 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import { UsageError } from "../../../../domain/errors/index.js"; +import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; +import type { TronContractParameter } from "../../../../application/ports/chain/tron-gateway.js"; +import { Schemas } from "../schemas/index.js"; +import { txModeFields } from "./shared.js"; +import { TextFormatters } from "../render/index.js"; + +function jsonArray(raw: string | undefined, flag = "--params"): unknown[] { + if (!raw) return []; + try { + const value = JSON.parse(raw); + if (Array.isArray(value)) return value; + } catch { + // Fall through to the stable usage error. + } + throw new UsageError("invalid_value", `${flag} must be a JSON array`); +} + +// call/send 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 encoder/RPC +// error deep in TronWeb. (deploy params are raw positional values — they use jsonArray, not this.) +const typedParam = z + .object({ type: z.string().min(1), value: z.unknown() }) + .refine((e) => e.value !== undefined, { message: "value is required" }); + +function typedParams(raw: string | undefined): TronContractParameter[] { + const arr = jsonArray(raw); + if (!z.array(typedParam).safeParse(arr).success) { + throw new UsageError( + "invalid_value", + '--params entries must be {"type","value"} objects with a non-empty ABI type', + ); + } + return arr as TronContractParameter[]; +} + +const callFields = z.object({ + contract: Schemas.addressFor("tron").describe("TRON contract address"), + method: z.string().min(1).describe("function signature, e.g. balanceOf(address)"), + params: z.string().optional() + .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), +}); + +export const contractCallSpec: ChainSpec = { + path: ["contract", "call"], + network: "optional", wallet: "none", auth: "none", + capability: "contract.call", + summary: "Read-only call (triggerConstantContract)", + baseFields: callFields, + examples: [{ + cmd: `wallet-cli contract call --contract TR7... --method "balanceOf(address)" --params '[{"type":"address","value":"T..."}]'`, + }], + formatText: TextFormatters.contractCall, +}; + +export const contractCallTronBinding = (svc: TronContractService): FamilyBinding => ({ + run: async (_ctx, net, input) => svc.call( + net, input.contract, input.method, typedParams(input.params), + ), +}); + +const sendFields = z.object({ + contract: Schemas.addressFor("tron").describe("TRON contract address"), + method: z.string().min(1).describe("function signature, e.g. transfer(address,uint256)"), + params: z.string().optional() + .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), + callValueSun: Schemas.uintString().default("0") + .describe("native TRX attached to the call, in SUN"), + feeLimit: Schemas.positiveIntString().default("100000000") + .describe("maximum energy fee to burn, in SUN"), + ...txModeFields, +}); + +export const contractSendSpec: ChainSpec = { + path: ["contract", "send"], + network: "required", wallet: "optional", auth: "required", + broadcasts: true, + capability: "contract.call", + summary: "State-changing call (triggerSmartContract)", + baseFields: sendFields, + examples: [{ + cmd: `wallet-cli contract send --contract TR7... --method "transfer(address,uint256)" --params '[...]'`, + }], + formatText: TextFormatters.txReceipt, +}; + +export const contractSendTronBinding = (svc: TronContractService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.send(ctx, net, { + ...input, + parameters: typedParams(input.params), + }), +}); + +const deployFields = z.object({ + abi: z.string().min(1).describe("contract ABI as a JSON array string"), + bytecode: z.string().min(1).describe("compiled contract bytecode as hex, 0x-prefixed or bare"), + feeLimit: Schemas.positiveIntString().describe("maximum energy fee to burn, in SUN"), + params: z.string().optional() + .describe("constructor args as a JSON array of raw positional values, e.g. [100, \"T...\"]; types are taken from the ABI constructor; omit to pass no constructor args"), + ...txModeFields, +}); + +export const contractDeploySpec: ChainSpec = { + path: ["contract", "deploy"], + network: "required", wallet: "optional", auth: "required", + broadcasts: true, + capability: "contract.deploy", + summary: "Deploy a smart contract", + // The Ledger TRON app firmware rejects CreateSmartContract (APDU 0x6a80), even with + // blind-signing enabled; software accounts sign and deploy it fine. + requires: ["a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type"], + baseFields: deployFields, + examples: [{ + cmd: "wallet-cli contract deploy --abi '[...]' --bytecode 60... --fee-limit 1000000000 --params '[100, \"T...\"]'", + }], + formatText: TextFormatters.txReceipt, +}; + +export const contractDeployTronBinding = (svc: TronContractService): FamilyBinding => ({ + run: async (ctx, net, input) => { + let abi: unknown; + try { + abi = JSON.parse(input.abi); + } catch { + throw new UsageError("invalid_value", "--abi must be valid JSON"); + } + return svc.deploy(ctx, net, { + ...input, + abi, + parameters: jsonArray(input.params), + }); + }, +}); + +const infoFields = z.object({ + contract: Schemas.addressFor("tron").describe("TRON contract address"), +}); + +export const contractInfoSpec: ChainSpec = { + path: ["contract", "info"], + network: "optional", wallet: "none", auth: "none", + capability: "contract.call", + summary: "Show contract ABI + metadata", + baseFields: infoFields, + examples: [{ cmd: "wallet-cli contract info --contract TR7..." }], + formatText: TextFormatters.contractInfo, +}; + +export const contractInfoTronBinding = (svc: TronContractService): FamilyBinding => ({ + run: async (_ctx, net, input) => svc.info(net, input.contract), +}); diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts index cd6664f8..36696968 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -4,8 +4,7 @@ * with chain-specific amount units + build/estimate) live explicitly in each chain module. */ import { z } from "zod"; -import type { ChainFamily } from "../../../../domain/types/index.js"; -import type { CommandDefinition } from "../contracts/index.js"; +import type { ChainSpec, 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"; @@ -38,28 +37,26 @@ export function amountSelector(v: { amount?: string; rawAmount?: string }, ctx: if (n !== 1) ctx.addIssue({ code: "custom", path: ["amount"], message: "provide exactly one of --amount or --raw-amount" }); } -/** message sign — direct SignerResolver path (no node, no TxPipeline). */ -export function messageSignCommand(family: ChainFamily, service: MessageService): CommandDefinition { - // --message OR --message-stdin (the latter is a global data channel via SecretResolver). - const fields = z.object({ - message: z.string().min(1).optional().describe("message text to sign; provide this OR --message-stdin; exactly one is required"), - }); - return { - path: ["message", "sign"], - stdin: "message", - family, - network: "optional", - wallet: "optional", - auth: "required", - capability: "message.sign", - summary: "Sign an arbitrary message (TIP-191/V2 · EIP-191)", - fields, - input: fields, - examples: [{ cmd: `wallet-cli message sign --message "hello"` }], - formatText: TextFormatters.messageSign, - run: async (ctx, _net, input) => { - const message = ctx.secrets.pick(input.message, "message", "message"); - return service.sign(ctx, family, ctx.activeAccount, message); - }, - }; -} +const messageSignFields = z.object({ + message: z.string().min(1).optional().describe("message text to sign; provide this OR --message-stdin; exactly one is required"), +}); + +export const messageSignSpec: ChainSpec = { + path: ["message", "sign"], + stdin: "message", + network: "optional", + wallet: "optional", + auth: "required", + capability: "message.sign", + summary: "Sign an arbitrary message (TIP-191/V2 · EIP-191)", + baseFields: messageSignFields, + examples: [{ cmd: `wallet-cli message sign --message "hello"` }], + formatText: TextFormatters.messageSign, +}; + +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); + }, +}); diff --git a/ts/src/adapters/inbound/cli/commands/tron/stake.ts b/ts/src/adapters/inbound/cli/commands/stake.ts similarity index 74% rename from ts/src/adapters/inbound/cli/commands/tron/stake.ts rename to ts/src/adapters/inbound/cli/commands/stake.ts index 4d73c36e..34190c5c 100644 --- a/ts/src/adapters/inbound/cli/commands/tron/stake.ts +++ b/ts/src/adapters/inbound/cli/commands/stake.ts @@ -1,15 +1,16 @@ import { z } from "zod"; -import type { NetworkDescriptor } from "../../../../../domain/types/index.js"; +import type { NetworkDescriptor } from "../../../../domain/types/index.js"; import type { - CommandDefinition, + ChainSpec, ExecutionContext, -} from "../../contracts/index.js"; -import type { TronStakeService } from "../../../../../application/use-cases/tron/stake-service.js"; -import { RESOURCES } from "../../../../../domain/resources/index.js"; -import { Schemas } from "../../schemas/index.js"; -import { ciEnum } from "../../arity/index.js"; -import { txModeFields } from "../shared.js"; -import { TextFormatters } from "../../render/index.js"; + FamilyBinding, +} from "../contracts/index.js"; +import type { TronStakeService } from "../../../../application/use-cases/tron/stake-service.js"; +import { RESOURCES } from "../../../../domain/resources/index.js"; +import { Schemas } from "../schemas/index.js"; +import { ciEnum } from "../arity/index.js"; +import { txModeFields } from "./shared.js"; +import { TextFormatters } from "../render/index.js"; const resourceField = (description: string) => ciEnum(RESOURCES).default("bandwidth").describe(description); @@ -26,32 +27,34 @@ type StakeExecutor = ( input: any, ) => Promise; -function command( +function stakeCommand( action: string, summary: string, execute: StakeExecutor, extra: z.ZodRawShape = {}, options: StakeCommandOptions = {}, -): CommandDefinition { +): { spec: ChainSpec; binding: FamilyBinding } { const fields = z.object({ ...extra, ...txModeFields }); return { - path: ["stake", action], family: "tron", - network: "required", wallet: "optional", auth: "required", - broadcasts: true, - capability: options.capability ?? "staking.freeze", - summary, - requires: options.requires, - fields, - input: options.refine ? fields.superRefine(options.refine) : fields, - examples: [{ cmd: `wallet-cli stake ${action}` }], - formatText: TextFormatters.txReceipt, - run: async (context, network, input) => execute(context, network, input), + spec: { + path: ["stake", action], + network: "required", wallet: "optional", auth: "required", + broadcasts: true, + capability: options.capability ?? "staking.freeze", + summary, + requires: options.requires, + baseFields: fields, + baseRefine: options.refine, + examples: [{ cmd: `wallet-cli stake ${action}` }], + formatText: TextFormatters.txReceipt, + }, + binding: { run: async (ctx, net, input) => execute(ctx, net, input) }, }; } -export function stakeCommands(service: TronStakeService): CommandDefinition[] { +export function stakeDefinitions(service: TronStakeService): Array<{ spec: ChainSpec; binding: FamilyBinding }> { return [ - command( + stakeCommand( "freeze", "Stake TRX for energy/bandwidth (FreezeBalanceV2)", (context, network, input) => service.freeze(context, network, input), @@ -60,7 +63,7 @@ export function stakeCommands(service: TronStakeService): CommandDefinition[] { resource: resourceField("resource type to obtain"), }, ), - command( + stakeCommand( "unfreeze", "Unstake TRX (UnfreezeBalanceV2)", (context, network, input) => service.unfreeze(context, network, input), @@ -69,12 +72,12 @@ export function stakeCommands(service: TronStakeService): CommandDefinition[] { resource: resourceField("resource type to release"), }, ), - command( + stakeCommand( "withdraw", "Withdraw expired unfrozen TRX (WithdrawExpireUnfreeze)", (context, network, input) => service.withdraw(context, network, input), ), - command( + stakeCommand( "cancel-unfreeze", "Cancel all pending unstakes (roll back to frozen)", (context, network, input) => service.cancelUnfreeze(context, network, input), @@ -85,7 +88,7 @@ export function stakeCommands(service: TronStakeService): CommandDefinition[] { requires: ["a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type"], }, ), - command( + stakeCommand( "delegate", "Delegate resource to another address (DelegateResourceV2)", (context, network, input) => service.delegate(context, network, input), @@ -113,7 +116,7 @@ export function stakeCommands(service: TronStakeService): CommandDefinition[] { }, }, ), - command( + stakeCommand( "undelegate", "Reclaim delegated resource (UnDelegateResourceV2)", (context, network, input) => service.undelegate(context, network, input), 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 2901e1e3..12fb910b 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { TronUseCases } from "./tron/index.js"; import { CommandRegistry } from "../registry/index.js"; import { registerWalletCommands } from "./wallet.js"; import { registerConfigCommands } from "./config.js"; import { registerNetworkCommands } from "./network.js"; -import { TronModule } from "./tron/index.js"; +import { registerTronChainCommands, type TronChainCommandDependencies } from "../../../../bootstrap/families/tron.js"; import { commandId } from "../command-id.js"; import { TextFormatters } from "../render/index.js"; +import { isChainCommand } from "../contracts/index.js"; import type { TextRenderContext } from "../contracts/index.js"; import type { ConfigService } from "../../../../application/use-cases/config-service.js"; @@ -14,16 +14,15 @@ const ctx = (over: Partial = {}): TextRenderContext => ({ com describe("text formatters", () => { it("every registered command has a command-owned text formatter", () => { - const services = {} as TronUseCases; const registry = new CommandRegistry(); registerWalletCommands(registry, {} as Parameters[1]); registerConfigCommands(registry, {} as ConfigService); registerNetworkCommands(registry); - new TronModule(services).registerCommands(registry); + registerTronChainCommands(registry, {} as TronChainCommandDependencies); const missing = registry.all() - .filter((cmd) => typeof cmd.formatText !== "function") - .map(commandId) + .filter((cmd) => typeof (isChainCommand(cmd) ? cmd.spec.formatText : cmd.formatText) !== "function") + .map((cmd) => commandId(isChainCommand(cmd) ? { path: cmd.spec.path } : cmd)) .sort(); expect(missing).toEqual([]); diff --git a/ts/src/adapters/inbound/cli/commands/tron/shared.ts b/ts/src/adapters/inbound/cli/commands/token-selector.ts similarity index 99% rename from ts/src/adapters/inbound/cli/commands/tron/shared.ts rename to ts/src/adapters/inbound/cli/commands/token-selector.ts index a95b44f7..24277b11 100644 --- a/ts/src/adapters/inbound/cli/commands/tron/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/token-selector.ts @@ -15,4 +15,3 @@ export function tokenSelector( }); } } - diff --git a/ts/src/adapters/inbound/cli/commands/token.ts b/ts/src/adapters/inbound/cli/commands/token.ts new file mode 100644 index 00000000..7abc1d90 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/token.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { TronTokenService } from "../../../../application/use-cases/tron/token-service.js"; +import { Schemas } from "../schemas/index.js"; +import { TextFormatters } from "../render/index.js"; +import { tokenSelector } from "./token-selector.js"; + +const selectorFields = z.object({ + contract: Schemas.addressFor("tron").optional() + .describe("TRC20 contract address; provide exactly one of --contract or --asset-id"), + assetId: z.string().regex(/^\d+$/).optional() + .describe("TRC10 numeric asset id; provide exactly one of --asset-id or --contract"), +}); + +export const tokenBalanceSpec: ChainSpec = { + path: ["token", "balance"], + network: "optional", wallet: "optional", auth: "none", + capability: "account.balance.token", + summary: "Show a single token balance (--contract / --asset-id)", + baseFields: selectorFields, + baseRefine: tokenSelector, + examples: [{ cmd: "wallet-cli token balance --contract TR7..." }], + formatText: TextFormatters.tokenBalance, +}; + +export const tokenBalanceTronBinding = (svc: TronTokenService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.balance(ctx, net, input), +}); + +export const tokenInfoSpec: ChainSpec = { + path: ["token", "info"], + network: "optional", wallet: "none", auth: "none", + capability: "account.balance.token", + summary: "Show token metadata (name/symbol/decimals/totalSupply)", + baseFields: selectorFields, + baseRefine: tokenSelector, + examples: [{ cmd: "wallet-cli token info --contract TR7..." }], + formatText: TextFormatters.tokenInfo, +}; + +export const tokenInfoTronBinding = (svc: TronTokenService): FamilyBinding => ({ + run: async (_ctx, net, input) => svc.info(net, input), +}); + +export const tokenAddSpec: ChainSpec = { + path: ["token", "add"], + network: "optional", wallet: "optional", auth: "none", + capability: "token.tokenbook", + summary: "Add a token to the address book (fetches symbol/decimals)", + baseFields: selectorFields, + baseRefine: tokenSelector, + examples: [{ cmd: "wallet-cli token add --contract TR7..." }], + formatText: TextFormatters.tokenBookAdd, +}; + +export const tokenAddTronBinding = (svc: TronTokenService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.add(ctx, net, input), +}); + +export const tokenListSpec: ChainSpec = { + path: ["token", "list"], + network: "optional", wallet: "optional", auth: "none", + capability: "token.tokenbook", + summary: "List the address book (official + user)", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli token list" }], + formatText: TextFormatters.tokenBookList, +}; + +export const tokenListTronBinding = (svc: TronTokenService): FamilyBinding => ({ + run: async (ctx, net) => svc.list(ctx, net), +}); + +export const tokenRemoveSpec: ChainSpec = { + path: ["token", "remove"], + network: "optional", wallet: "optional", auth: "none", + capability: "token.tokenbook", + summary: "Remove a user-added token from the address book", + baseFields: selectorFields, + baseRefine: tokenSelector, + examples: [{ cmd: "wallet-cli token remove --contract TR7..." }], + formatText: TextFormatters.tokenBookRemove, +}; + +export const tokenRemoveTronBinding = (svc: TronTokenService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.remove(ctx, net, input), +}); diff --git a/ts/src/adapters/inbound/cli/commands/tron/account.ts b/ts/src/adapters/inbound/cli/commands/tron/account.ts deleted file mode 100644 index 4949c307..00000000 --- a/ts/src/adapters/inbound/cli/commands/tron/account.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { z } from "zod"; -import type { CommandDefinition } from "../../contracts/index.js"; -import type { TronAccountService } from "../../../../../application/use-cases/tron/account-service.js"; -import { ciEnum } from "../../arity/index.js"; -import { TextFormatters } from "../../render/index.js"; - -export function accountCommands(service: TronAccountService): CommandDefinition[] { - return [balance(service), info(service), history(service), portfolio(service)]; -} - -function balance(service: TronAccountService): CommandDefinition { - const fields = z.object({}); - return { - path: ["account", "balance"], family: "tron", - network: "optional", wallet: "optional", auth: "none", - capability: "account.balance.native", - summary: "Show native balance (TRX/SUN)", - fields, - input: fields, - examples: [{ cmd: "wallet-cli account balance" }], - formatText: TextFormatters.accountBalance, - run: async (ctx, network) => service.balance(ctx, network, "tron"), - }; -} - -function info(service: TronAccountService): CommandDefinition { - const fields = z.object({}); - return { - path: ["account", "info"], family: "tron", - network: "optional", wallet: "optional", auth: "none", - summary: "Show raw account data (getAccount; TRON includes resources)", - fields, - input: fields, - examples: [{ cmd: "wallet-cli account info" }], - formatText: TextFormatters.accountInfo, - run: async (ctx, network) => service.info(ctx, network), - }; -} - -function history(service: TronAccountService): CommandDefinition { - const fields = z.object({ - limit: z.coerce.number().int().positive().max(200).default(20) - .describe("maximum records to return, in records; range: 1-200"), - only: ciEnum(["native", "token"]).optional() - .describe("filter history by transfer type; omit to show all transfer types"), - }); - return { - path: ["account", "history"], family: "tron", - network: "optional", wallet: "optional", auth: "none", - summary: "Show transaction history (requires TronGrid)", - fields, - input: fields, - examples: [{ cmd: "wallet-cli account history --limit 10" }], - formatText: TextFormatters.accountHistory, - run: async (ctx, network, input) => service.historyFor(ctx, network, input), - }; -} - -function portfolio(service: TronAccountService): CommandDefinition { - const fields = z.object({}); - return { - path: ["account", "portfolio"], family: "tron", - network: "optional", wallet: "optional", auth: "none", - capability: "account.portfolio", - summary: "Show native + token balances with best-effort USD value", - fields, - input: fields, - examples: [{ cmd: "wallet-cli account portfolio" }], - formatText: TextFormatters.accountPortfolio, - run: async (ctx, network) => service.portfolio(ctx, network), - }; -} diff --git a/ts/src/adapters/inbound/cli/commands/tron/block.ts b/ts/src/adapters/inbound/cli/commands/tron/block.ts deleted file mode 100644 index 14a5fa51..00000000 --- a/ts/src/adapters/inbound/cli/commands/tron/block.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * TRON block group — block lookup. - */ -import { z } from "zod"; -import type { CommandDefinition } from "../../contracts/index.js"; -import type { TronBlockService } from "../../../../../application/use-cases/tron/block-service.js"; -import { Schemas } from "../../schemas/index.js"; -import { TextFormatters } from "../../render/index.js"; - -function blockGet(service: TronBlockService): CommandDefinition { - const fields = z.object({ number: Schemas.uintString().optional().describe("block number to fetch, in block height; omit to fetch the latest block") }); - return { - path: ["block"], family: "tron", - network: "optional", wallet: "none", auth: "none", - positionals: [{ field: "number" }], - summary: "Get a block (latest if omitted)", fields, input: fields, - examples: [ - { cmd: "wallet-cli block" }, - { cmd: "wallet-cli block 12345" }, - ], - formatText: TextFormatters.block, - run: async (_ctx, net, input) => service.get(net, input.number), - }; -} - -export function blockCommands(service: TronBlockService): CommandDefinition[] { - return [blockGet(service)]; -} diff --git a/ts/src/adapters/inbound/cli/commands/tron/contract.ts b/ts/src/adapters/inbound/cli/commands/tron/contract.ts deleted file mode 100644 index 36528d21..00000000 --- a/ts/src/adapters/inbound/cli/commands/tron/contract.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { z } from "zod"; -import type { CommandDefinition } from "../../contracts/index.js"; -import { UsageError } from "../../../../../domain/errors/index.js"; -import type { TronContractService } from "../../../../../application/use-cases/tron/contract-service.js"; -import type { TronContractParameter } from "../../../../../application/ports/chain/tron-gateway.js"; -import { Schemas } from "../../schemas/index.js"; -import { txModeFields } from "../shared.js"; -import { TextFormatters } from "../../render/index.js"; - -function jsonArray(raw: string | undefined, flag = "--params"): unknown[] { - if (!raw) return []; - try { - const value = JSON.parse(raw); - if (Array.isArray(value)) return value; - } catch { - // Fall through to the stable usage error. - } - throw new UsageError("invalid_value", `${flag} must be a JSON array`); -} - -// call/send 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 encoder/RPC -// error deep in TronWeb. (deploy params are raw positional values — they use jsonArray, not this.) -const typedParam = z - .object({ type: z.string().min(1), value: z.unknown() }) - .refine((e) => e.value !== undefined, { message: "value is required" }); - -function typedParams(raw: string | undefined): TronContractParameter[] { - const arr = jsonArray(raw); - if (!z.array(typedParam).safeParse(arr).success) { - throw new UsageError( - "invalid_value", - '--params entries must be {"type","value"} objects with a non-empty ABI type', - ); - } - return arr as TronContractParameter[]; -} - -export function contractCommands(service: TronContractService): CommandDefinition[] { - return [call(service), send(service), deploy(service), info(service)]; -} - -function call(service: TronContractService): CommandDefinition { - const fields = z.object({ - contract: Schemas.addressFor("tron").describe("TRON contract address"), - method: z.string().min(1).describe("function signature, e.g. balanceOf(address)"), - params: z.string().optional() - .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), - }); - return { - path: ["contract", "call"], family: "tron", - network: "optional", wallet: "none", auth: "none", - capability: "contract.call", - summary: "Read-only call (triggerConstantContract)", - fields, - input: fields, - examples: [{ - cmd: `wallet-cli contract call --contract TR7... --method "balanceOf(address)" --params '[{"type":"address","value":"T..."}]'`, - }], - formatText: TextFormatters.contractCall, - run: async (_ctx, network, input) => service.call( - network, input.contract, input.method, typedParams(input.params), - ), - }; -} - -function send(service: TronContractService): CommandDefinition { - const fields = z.object({ - contract: Schemas.addressFor("tron").describe("TRON contract address"), - method: z.string().min(1).describe("function signature, e.g. transfer(address,uint256)"), - params: z.string().optional() - .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), - callValueSun: Schemas.uintString().default("0") - .describe("native TRX attached to the call, in SUN"), - feeLimit: Schemas.positiveIntString().default("100000000") - .describe("maximum energy fee to burn, in SUN"), - ...txModeFields, - }); - return { - path: ["contract", "send"], family: "tron", - network: "required", wallet: "optional", auth: "required", - broadcasts: true, - capability: "contract.call", - summary: "State-changing call (triggerSmartContract)", - fields, - input: fields, - examples: [{ - cmd: `wallet-cli contract send --contract TR7... --method "transfer(address,uint256)" --params '[...]'`, - }], - formatText: TextFormatters.txReceipt, - run: async (ctx, network, input) => service.send(ctx, network, { - ...input, - parameters: typedParams(input.params), - }), - }; -} - -function deploy(service: TronContractService): CommandDefinition { - const fields = z.object({ - abi: z.string().min(1).describe("contract ABI as a JSON array string"), - bytecode: z.string().min(1).describe("compiled contract bytecode as hex, 0x-prefixed or bare"), - feeLimit: Schemas.positiveIntString().describe("maximum energy fee to burn, in SUN"), - params: z.string().optional() - .describe("constructor args as a JSON array of raw positional values, e.g. [100, \"T...\"]; types are taken from the ABI constructor; omit to pass no constructor args"), - ...txModeFields, - }); - return { - path: ["contract", "deploy"], family: "tron", - network: "required", wallet: "optional", auth: "required", - broadcasts: true, - capability: "contract.deploy", - summary: "Deploy a smart contract", - // The Ledger TRON app firmware rejects CreateSmartContract (APDU 0x6a80), even with - // blind-signing enabled; software accounts sign and deploy it fine. - requires: ["a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type"], - fields, - input: fields, - examples: [{ - cmd: "wallet-cli contract deploy --abi '[...]' --bytecode 60... --fee-limit 1000000000 --params '[100, \"T...\"]'", - }], - formatText: TextFormatters.txReceipt, - run: async (ctx, network, input) => { - let abi: unknown; - try { - abi = JSON.parse(input.abi); - } catch { - throw new UsageError("invalid_value", "--abi must be valid JSON"); - } - return service.deploy(ctx, network, { - ...input, - abi, - parameters: jsonArray(input.params), - }); - }, - }; -} - -function info(service: TronContractService): CommandDefinition { - const fields = z.object({ - contract: Schemas.addressFor("tron").describe("TRON contract address"), - }); - return { - path: ["contract", "info"], family: "tron", - network: "optional", wallet: "none", auth: "none", - capability: "contract.call", - summary: "Show contract ABI + metadata", - fields, - input: fields, - examples: [{ cmd: "wallet-cli contract info --contract TR7..." }], - formatText: TextFormatters.contractInfo, - run: async (_ctx, network, input) => service.info(network, input.contract), - }; -} diff --git a/ts/src/adapters/inbound/cli/commands/tron/index.ts b/ts/src/adapters/inbound/cli/commands/tron/index.ts deleted file mode 100644 index 6d7244b9..00000000 --- a/ts/src/adapters/inbound/cli/commands/tron/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * TronModule — TRON's own command surface. No universal - * provider: TRON-specific build/estimate/codecs live in the per-group files; only infra - * (TxPipeline, SignerResolver, RpcProvider) is shared. Implements the ChainModule contract. - */ -import type { ChainModule, CommandRegistryLike } from "../../contracts/index.js"; -import type { TronAccountService } from "../../../../../application/use-cases/tron/account-service.js"; -import type { TronTokenService } from "../../../../../application/use-cases/tron/token-service.js"; -import type { TronTransactionService } from "../../../../../application/use-cases/tron/transaction-service.js"; -import type { TronStakeService } from "../../../../../application/use-cases/tron/stake-service.js"; -import type { TronBlockService } from "../../../../../application/use-cases/tron/block-service.js"; -import type { TronContractService } from "../../../../../application/use-cases/tron/contract-service.js"; -import type { MessageService } from "../../../../../application/use-cases/message-service.js"; -import { accountCommands } from "./account.js"; -import { tokenCommands } from "./token.js"; -import { txCommands } from "./tx.js"; -import { stakeCommands } from "./stake.js"; -import { blockCommands } from "./block.js"; -import { contractCommands } from "./contract.js"; -import { messageCommands } from "./message.js"; - -export interface TronUseCases { - tronAccount: TronAccountService; - tronToken: TronTokenService; - tronTransaction: TronTransactionService; - tronStake: TronStakeService; - tronBlock: TronBlockService; - tronContract: TronContractService; - message: MessageService; -} - -export class TronModule implements ChainModule { - readonly family = "tron" as const; - constructor(private readonly services: TronUseCases) {} - - registerCommands(reg: CommandRegistryLike): void { - const groups = [ - accountCommands(this.services.tronAccount), - tokenCommands(this.services.tronToken), - txCommands(this.services.tronTransaction), - stakeCommands(this.services.tronStake), - blockCommands(this.services.tronBlock), - contractCommands(this.services.tronContract), - messageCommands(this.services.message), - ]; - for (const cmds of groups) for (const cmd of cmds) reg.add(cmd); - } -} diff --git a/ts/src/adapters/inbound/cli/commands/tron/message.ts b/ts/src/adapters/inbound/cli/commands/tron/message.ts deleted file mode 100644 index afffe2e6..00000000 --- a/ts/src/adapters/inbound/cli/commands/tron/message.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * TRON message group — message signing (TIP-191/V2). The command itself comes from the - * shared (family-agnostic) factory; this file just scopes it to TRON. - */ -import type { CommandDefinition } from "../../contracts/index.js"; -import type { MessageService } from "../../../../../application/use-cases/message-service.js"; -import { messageSignCommand } from "../shared.js"; - -export function messageCommands(service: MessageService): CommandDefinition[] { - return [messageSignCommand("tron", service)]; -} diff --git a/ts/src/adapters/inbound/cli/commands/tron/token.ts b/ts/src/adapters/inbound/cli/commands/tron/token.ts deleted file mode 100644 index 66efe85f..00000000 --- a/ts/src/adapters/inbound/cli/commands/tron/token.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { z } from "zod"; -import type { CommandDefinition } from "../../contracts/index.js"; -import type { TronTokenService } from "../../../../../application/use-cases/tron/token-service.js"; -import { Schemas } from "../../schemas/index.js"; -import { TextFormatters } from "../../render/index.js"; -import { tokenSelector } from "./shared.js"; - -const selectorFields = z.object({ - contract: Schemas.addressFor("tron").optional() - .describe("TRC20 contract address; provide exactly one of --contract or --asset-id"), - assetId: z.string().regex(/^\d+$/).optional() - .describe("TRC10 numeric asset id; provide exactly one of --asset-id or --contract"), -}); - -export function tokenCommands(service: TronTokenService): CommandDefinition[] { - return [balance(service), info(service), add(service), list(service), remove(service)]; -} - -function balance(service: TronTokenService): CommandDefinition { - return { - path: ["token", "balance"], family: "tron", - network: "optional", wallet: "optional", auth: "none", - capability: "account.balance.token", - summary: "Show a single token balance (--contract / --asset-id)", - fields: selectorFields, - input: selectorFields.superRefine(tokenSelector), - examples: [{ cmd: "wallet-cli token balance --contract TR7..." }], - formatText: TextFormatters.tokenBalance, - run: async (ctx, network, input) => service.balance(ctx, network, input), - }; -} - -function info(service: TronTokenService): CommandDefinition { - return { - path: ["token", "info"], family: "tron", - network: "optional", wallet: "none", auth: "none", - capability: "account.balance.token", - summary: "Show token metadata (name/symbol/decimals/totalSupply)", - fields: selectorFields, - input: selectorFields.superRefine(tokenSelector), - examples: [{ cmd: "wallet-cli token info --contract TR7..." }], - formatText: TextFormatters.tokenInfo, - run: async (_ctx, network, input) => service.info(network, input), - }; -} - -function add(service: TronTokenService): CommandDefinition { - return { - path: ["token", "add"], family: "tron", - network: "optional", wallet: "optional", auth: "none", - capability: "token.tokenbook", - summary: "Add a token to the address book (fetches symbol/decimals)", - fields: selectorFields, - input: selectorFields.superRefine(tokenSelector), - examples: [{ cmd: "wallet-cli token add --contract TR7..." }], - formatText: TextFormatters.tokenBookAdd, - run: async (ctx, network, input) => service.add(ctx, network, input), - }; -} - -function list(service: TronTokenService): CommandDefinition { - const fields = z.object({}); - return { - path: ["token", "list"], family: "tron", - network: "optional", wallet: "optional", auth: "none", - capability: "token.tokenbook", - summary: "List the address book (official + user)", - fields, - input: fields, - examples: [{ cmd: "wallet-cli token list" }], - formatText: TextFormatters.tokenBookList, - run: async (ctx, network) => service.list(ctx, network), - }; -} - -function remove(service: TronTokenService): CommandDefinition { - return { - path: ["token", "remove"], family: "tron", - network: "optional", wallet: "optional", auth: "none", - capability: "token.tokenbook", - summary: "Remove a user-added token from the address book", - fields: selectorFields, - input: selectorFields.superRefine(tokenSelector), - examples: [{ cmd: "wallet-cli token remove --contract TR7..." }], - formatText: TextFormatters.tokenBookRemove, - run: async (ctx, network, input) => service.remove(ctx, network, input), - }; -} diff --git a/ts/src/adapters/inbound/cli/commands/tron/tx.ts b/ts/src/adapters/inbound/cli/commands/tron/tx.ts deleted file mode 100644 index e68b4605..00000000 --- a/ts/src/adapters/inbound/cli/commands/tron/tx.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { z } from "zod"; -import type { CommandDefinition } from "../../contracts/index.js"; -import { UsageError } from "../../../../../domain/errors/index.js"; -import type { TronTransactionService } from "../../../../../application/use-cases/tron/transaction-service.js"; -import { Schemas } from "../../schemas/index.js"; -import { - amountSelector, - txModeFields, - unifiedAmountFields, -} from "../shared.js"; -import { TextFormatters } from "../../render/index.js"; - -export function txCommands(service: TronTransactionService): CommandDefinition[] { - return [send(service), broadcast(service), status(service), info(service)]; -} - -function send(service: TronTransactionService): CommandDefinition { - const fields = z.object({ - to: Schemas.addressFor("tron").describe("recipient TRON base58 address"), - token: z.string().min(1).optional() - .describe("token symbol from the address book; mutually exclusive with --contract and --asset-id"), - contract: Schemas.addressFor("tron").optional() - .describe("TRC20 contract address; omit with --asset-id for native TRX"), - assetId: z.string().regex(/^\d+$/).optional() - .describe("TRC10 numeric asset id; omit with --contract for native TRX"), - feeLimit: Schemas.positiveIntString().default("100000000") - .describe("maximum TRX energy fee to burn for TRC20 transfers, in SUN"), - ...unifiedAmountFields( - "human amount: TRX for native, token units for TRC20/TRC10; mutually exclusive with --raw-amount", - "raw integer amount in SUN or token base units; mutually exclusive with --amount", - ), - ...txModeFields, - }); - return { - path: ["tx", "send"], family: "tron", - network: "optional", wallet: "optional", auth: "required", - broadcasts: true, - capability: "tx.send", - summary: "Send native TRX or TRC20/TRC10 tokens with human --amount", - fields, - input: fields.superRefine(amountSelector).superRefine(tokenOptional), - examples: [ - { cmd: "wallet-cli tx send --to T... --amount 1" }, - { cmd: "wallet-cli tx send --to T... --token USDT --amount 5" }, - { cmd: "wallet-cli tx send --to T... --contract TR7... --amount 5" }, - { cmd: "wallet-cli tx send --to T... --asset-id 1002000 --raw-amount 1000000" }, - ], - formatText: TextFormatters.txReceipt, - run: async (ctx, network, input) => service.send(ctx, network, input), - }; -} - -function broadcast(service: TronTransactionService): CommandDefinition { - const fields = z.object({ - transaction: z.string().optional() - .describe("signed TRON transaction JSON; provide this OR --tx-stdin; exactly one is required"), - }); - return { - path: ["tx", "broadcast"], stdin: "tx", family: "tron", - network: "required", wallet: "none", auth: "none", - broadcasts: true, - capability: "tx.broadcast", - summary: "Broadcast a presigned transaction", - fields, - input: fields, - examples: [{ cmd: "wallet-cli tx broadcast --tx-stdin < signed.json" }], - formatText: TextFormatters.txReceipt, - run: async (ctx, network, input) => { - const raw = ctx.secrets.pick(input.transaction, "tx", "transaction"); - try { - return service.broadcast(ctx, network, JSON.parse(raw)); - } catch (error) { - if (error instanceof SyntaxError) { - throw new UsageError("invalid_value", "TRON presigned tx must be JSON"); - } - throw error; - } - }, - }; -} - -function status(service: TronTransactionService): CommandDefinition { - const fields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") }); - return { - path: ["tx", "status"], family: "tron", - network: "optional", wallet: "none", auth: "none", - summary: "Show confirmation status of a transaction", - fields, - input: fields, - examples: [{ cmd: "wallet-cli tx status --txid abc123" }], - formatText: TextFormatters.txStatus, - run: async (_ctx, network, input) => service.status(network, input.txid), - }; -} - -function info(service: TronTransactionService): CommandDefinition { - const fields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") }); - return { - path: ["tx", "info"], family: "tron", - network: "optional", wallet: "none", auth: "none", - summary: "Show full transaction detail + receipt", - fields, - input: fields, - examples: [{ cmd: "wallet-cli tx info --txid abc123" }], - formatText: TextFormatters.txInfo, - run: async (_ctx, network, input) => service.info(network, 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", - }); - } -} diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts new file mode 100644 index 00000000..451dd006 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import { UsageError } from "../../../../domain/errors/index.js"; +import type { TronTransactionService } from "../../../../application/use-cases/tron/transaction-service.js"; +import { Schemas } from "../schemas/index.js"; +import { + amountSelector, + txModeFields, + unifiedAmountFields, +} from "./shared.js"; +import { TextFormatters } from "../render/index.js"; + +// baseFields today (single family). When EVM lands, move feeLimit/assetId/contract into the TRON +// binding.fields and put gasPrice/gasLimit/nonce into the EVM binding.fields (spec §4 base/delta). +const sendFields = z.object({ + to: Schemas.addressFor("tron").describe("recipient TRON base58 address"), + token: z.string().min(1).optional() + .describe("token symbol from the address book; mutually exclusive with --contract and --asset-id"), + contract: Schemas.addressFor("tron").optional() + .describe("TRC20 contract address; omit with --asset-id for native TRX"), + assetId: z.string().regex(/^\d+$/).optional() + .describe("TRC10 numeric asset id; omit with --contract for native TRX"), + feeLimit: Schemas.positiveIntString().default("100000000") + .describe("maximum TRX energy fee to burn for TRC20 transfers, in SUN"), + ...unifiedAmountFields( + "human amount: TRX for native, token units for TRC20/TRC10; mutually exclusive with --raw-amount", + "raw integer amount in SUN or token base units; mutually exclusive with --amount", + ), + ...txModeFields, +}); + +export const txSendSpec: ChainSpec = { + path: ["tx", "send"], + network: "optional", wallet: "optional", auth: "required", + broadcasts: true, + capability: "tx.send", + summary: "Send native TRX or TRC20/TRC10 tokens with human --amount", + baseFields: sendFields, + baseRefine: amountSelector, + examples: [ + { cmd: "wallet-cli tx send --to T... --amount 1" }, + { cmd: "wallet-cli tx send --to T... --token USDT --amount 5" }, + { cmd: "wallet-cli tx send --to T... --contract TR7... --amount 5" }, + { cmd: "wallet-cli tx send --to T... --asset-id 1002000 --raw-amount 1000000" }, + ], + formatText: TextFormatters.txReceipt, +}; + +export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => ({ + refine: tokenOptional, + run: async (ctx, net, input) => svc.send(ctx, net, input), +}); + +const broadcastFields = z.object({ + transaction: z.string().optional() + .describe("signed TRON transaction JSON; provide this OR --tx-stdin; exactly one is required"), +}); + +export const txBroadcastSpec: ChainSpec = { + path: ["tx", "broadcast"], + stdin: "tx", + network: "required", wallet: "none", auth: "none", + broadcasts: true, + capability: "tx.broadcast", + summary: "Broadcast a presigned transaction", + baseFields: broadcastFields, + examples: [{ cmd: "wallet-cli tx broadcast --tx-stdin < signed.json" }], + formatText: TextFormatters.txReceipt, +}; + +export const txBroadcastTronBinding = (svc: TronTransactionService): FamilyBinding => ({ + run: async (ctx, net, input) => { + const raw = ctx.secrets.pick(input.transaction, "tx", "transaction"); + try { + return svc.broadcast(ctx, net, JSON.parse(raw)); + } catch (error) { + if (error instanceof SyntaxError) { + throw new UsageError("invalid_value", "TRON presigned tx must be JSON"); + } + throw error; + } + }, +}); + +const statusFields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") }); + +export const txStatusSpec: ChainSpec = { + path: ["tx", "status"], + network: "optional", wallet: "none", auth: "none", + summary: "Show confirmation status of a transaction", + baseFields: statusFields, + examples: [{ cmd: "wallet-cli tx status --txid abc123" }], + formatText: TextFormatters.txStatus, +}; + +export const txStatusTronBinding = (svc: TronTransactionService): FamilyBinding => ({ + run: async (_ctx, net, input) => svc.status(net, input.txid), +}); + +const infoFields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") }); + +export const txInfoSpec: ChainSpec = { + path: ["tx", "info"], + network: "optional", wallet: "none", auth: "none", + summary: "Show full transaction detail + receipt", + baseFields: infoFields, + examples: [{ cmd: "wallet-cli tx info --txid abc123" }], + formatText: TextFormatters.txInfo, +}; + +export const txInfoTronBinding = (svc: TronTransactionService): FamilyBinding => ({ + run: async (_ctx, net, input) => svc.info(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", + }); + } +} diff --git a/ts/src/adapters/inbound/cli/commands/wallet.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.test.ts index f73a464c..57cc524c 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.test.ts @@ -14,7 +14,8 @@ import { createOutputFormatter } from "../output/index.js"; import { registerWalletCommands, walletImportLedgerFields, walletImportLedgerInput } from "./wallet.js"; import { CommandRegistry } from "../registry/index.js"; import { commandId } from "../command-id.js"; -import type { Globals, NetworklessCommandDefinition } from "../contracts/index.js"; +import { isChainCommand } from "../contracts/index.js"; +import type { CommandDefinition, Globals } from "../contracts/index.js"; import { Derivation } from "../../../../domain/derivation/index.js"; import { WalletService } from "../../../../application/use-cases/wallet-service.js"; @@ -95,9 +96,10 @@ function buildServices(ks: Keystore) { } /** Resolve a command by its derived canonical id (e.g. "create", "import.mnemonic"). */ -function getCmd(registry: CommandRegistry, id: string): NetworklessCommandDefinition | null { - const cmd = registry.all().find((c) => commandId(c) === id) ?? null; +function getCmd(registry: CommandRegistry, id: string): CommandDefinition | null { + const cmd = registry.all().find((c) => commandId(isChainCommand(c) ? { path: c.spec.path } : c) === id) ?? null; if (!cmd) throw new Error(`command not found: ${id}`); + if (isChainCommand(cmd)) throw new Error(`wallet command unexpectedly uses a chain definition: ${id}`); if (cmd.network !== "none") throw new Error(`wallet command unexpectedly requires a network: ${id}`); return cmd; } diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index 00216fe7..e8d4b64d 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -30,12 +30,9 @@ export interface TextRenderContext { export type TextFormatter = (data: O, ctx: TextRenderContext) => string | null; interface CommandDefinitionBase { - /** full typed path. Neutral commands carry their complete path (e.g. ["import","mnemonic"], - * ["config","get"], ["create"]); chain commands carry the logical path (e.g. ["tx","send"]) - * shared across families. The only routing discriminator is `family` present/absent. + /** full typed path (e.g. ["import","mnemonic"], ["config","get"], ["create"]). * The stable identity (envelope `command` field) is derived from command metadata, not stored. */ path: string[]; - family?: ChainFamily; /** declares the command reads from a *-stdin channel; drives help/catalog input-flag docs. */ stdin?: StdinChannel; wallet: WalletRequirement; @@ -66,31 +63,74 @@ interface CommandDefinitionBase { formatText?: TextFormatter; } -/** A networkless command never receives a chain target. */ -export interface NetworklessCommandDefinition - extends CommandDefinitionBase { +/** A neutral (family-less) command — wallet/config/meta operations that never receive a + * chain target. Networked commands are ChainCommandDefinitions. */ +export interface CommandDefinition extends CommandDefinitionBase { network: "none"; run(ctx: ExecutionContext, net: undefined, input: I): Promise; } -/** Both policies resolve a concrete network; "optional" only means the CLI flag may be omitted. */ -export interface NetworkedCommandDefinition - extends CommandDefinitionBase { - network: Exclude; +/** One family's slice of a chain command: how it runs + its extra flags/validation. + * It does NOT render — rendering is shared on the spec (Model P). O is the shared View type. */ +export interface FamilyBinding { run(ctx: ExecutionContext, net: NetworkDescriptor, input: I): Promise; + /** family-specific extra flags merged onto ChainSpec.baseFields; omit when none. */ + fields?: ZodObject; + /** family-specific cross-field validation; composed after ChainSpec.baseRefine. */ + refine?: (value: any, ctx: import("zod").RefinementCtx) => void; } -/** Network policy discriminates the run signature, preventing unsafe `network!` assertions. */ -export type CommandDefinition = - | NetworklessCommandDefinition - | NetworkedCommandDefinition; +/** Neutral, service-free declaration of a logical chain command. Generic over O, the single + * family-agnostic View every family's run returns. */ +export interface ChainSpec { + path: string[]; + network: Exclude; + wallet: WalletRequirement; + auth: AuthRequirement; + broadcasts?: boolean; + capability?: string; + stdin?: StdinChannel; + interactive?: boolean; + passwordMode?: "establish" | "verify"; + positionals?: { field: string; placeholder?: string }[]; + promptHints?: Record; + requires?: string[]; + summary?: string; + examples: Example[]; + baseFields: ZodObject; + baseRefine?: (value: any, ctx: import("zod").RefinementCtx) => void; + /** shared text renderer; uses FAMILY_RENDER[net.family] for family-shaped rows. */ + formatText?: TextFormatter; +} -export interface ChainModule { - family: ChainFamily; - registerCommands(reg: CommandRegistryLike): void; +/** Assembled command held by the registry: one spec + a family→binding table. */ +export interface ChainCommandDefinition { + spec: ChainSpec; + families: Partial>>; } -/** structural view of CommandRegistry needed by ChainModule.registerCommands. */ -export interface CommandRegistryLike { - add(cmd: CommandDefinition): void; +/** Narrow structural command view shared by legacy definitions and assembled chain specs. */ +export type CommandExecutionSpec = Pick< + ChainSpec, + | "path" + | "network" + | "wallet" + | "auth" + | "broadcasts" + | "capability" + | "interactive" + | "passwordMode" + | "positionals" + | "promptHints" + | "requires" +> & { + fields: ZodObject; +}; + +/** The registry stores either the legacy per-family CommandDefinition or the new one. */ +export type StoredCommand = CommandDefinition | ChainCommandDefinition; + +/** discriminates the two stored shapes. */ +export function isChainCommand(c: StoredCommand): c is ChainCommandDefinition { + return (c as ChainCommandDefinition).families !== undefined; } diff --git a/ts/src/adapters/inbound/cli/contracts/command.types.test.ts b/ts/src/adapters/inbound/cli/contracts/command.types.test.ts new file mode 100644 index 00000000..be559a56 --- /dev/null +++ b/ts/src/adapters/inbound/cli/contracts/command.types.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { isChainCommand, type ChainCommandDefinition, type CommandDefinition } from "./command.js"; + +describe("isChainCommand", () => { + it("is true for a ChainCommandDefinition and false for a legacy CommandDefinition", () => { + const chain: ChainCommandDefinition = { + spec: { path: ["block"], network: "optional", wallet: "none", auth: "none", examples: [], baseFields: z.object({}) }, + families: { tron: { run: async () => ({}) } }, + }; + const legacy = { path: ["block"], network: "none", wallet: "none", auth: "none", fields: z.object({}), input: z.object({}), examples: [], run: async () => ({}) } as unknown as CommandDefinition; + expect(isChainCommand(chain)).toBe(true); + expect(isChainCommand(legacy)).toBe(false); + }); +}); diff --git a/ts/src/adapters/inbound/cli/help/catalog.ts b/ts/src/adapters/inbound/cli/help/catalog.ts index 8438bd73..936b98a9 100644 --- a/ts/src/adapters/inbound/cli/help/catalog.ts +++ b/ts/src/adapters/inbound/cli/help/catalog.ts @@ -6,7 +6,8 @@ */ import { z } from "zod"; import type { ChainFamily } from "../../../../domain/types/index.js"; -import type { CommandDefinition } from "../contracts/index.js"; +import { isChainCommand } from "../contracts/index.js"; +import type { ChainCommandDefinition, CommandDefinition, StdinChannel } from "../contracts/index.js"; import { CommandRegistry } from "../registry/index.js"; import { commandId } from "../command-id.js"; import { GLOBAL_FLAG_SPECS, type GlobalFlagSpec } from "../globals/index.js"; @@ -44,7 +45,7 @@ const STDIN_FLAGS = Object.fromEntries( GLOBAL_FLAG_SPECS.filter((f) => f.commandScoped).map((f) => [f.secretKey, globalFlagDoc(f)]), ) as Record, GlobalFlag>; -export function inputFlagsFor(cmd: CommandDefinition): readonly GlobalFlag[] { +export function inputFlagsFor(cmd: { stdin?: StdinChannel }): readonly GlobalFlag[] { return cmd.stdin ? [STDIN_FLAGS[cmd.stdin]] : []; } @@ -54,7 +55,7 @@ export function commandUsage(cmd: CommandDefinition): string { } /** never let one un-convertible schema break the whole catalog. */ -function commandInputSchema(input: CommandDefinition["input"]): unknown { +function commandInputSchema(input: z.ZodType): unknown { try { return z.toJSONSchema(input as z.ZodType); } catch { @@ -66,21 +67,48 @@ function commandInputSchema(input: CommandDefinition["input"]): unknown { export function buildCatalog(registry: CommandRegistry, version: string, familyFilter?: ChainFamily): string { const commands = registry .all() - .filter((c) => !familyFilter || c.family === familyFilter) - .map((cmd) => ({ cmd, id: commandId(cmd) })) + .filter((cmd) => isChainCommand(cmd) + ? !familyFilter || cmd.families[familyFilter] !== undefined + : !familyFilter) + .map((cmd) => isChainCommand(cmd) + ? { + id: commandId({ path: cmd.spec.path }), + kind: "chain", + families: Object.keys(cmd.families), + path: cmd.spec.path, + usage: `wallet-cli ${cmd.spec.path.join(" ")} [options]`, + summary: cmd.spec.summary ?? "", + requires: { network: cmd.spec.network, auth: cmd.spec.auth, wallet: cmd.spec.wallet }, + ...(cmd.spec.capability ? { capability: cmd.spec.capability } : {}), + examples: cmd.spec.examples.map((e: { cmd: string }) => e.cmd), + ...(cmd.spec.stdin ? { inputFlags: inputFlagsFor(cmd.spec) } : {}), + inputSchema: commandInputSchema(mergedInput(cmd)), + } + : { + id: commandId(cmd), + kind: "neutral", + path: cmd.path, + usage: commandUsage(cmd), + summary: cmd.summary ?? "", + requires: { network: cmd.network, auth: cmd.auth, wallet: cmd.wallet }, + ...(cmd.capability ? { capability: cmd.capability } : {}), + examples: cmd.examples.map((e: { cmd: string }) => e.cmd), + ...(inputFlagsFor(cmd).length ? { inputFlags: inputFlagsFor(cmd) } : {}), + inputSchema: commandInputSchema(cmd.input), + }) .sort((a, b) => a.id.localeCompare(b.id)) - .map(({ cmd, id }) => ({ - id, - kind: cmd.family ? "chain" : "neutral", - ...(cmd.family ? { family: cmd.family } : {}), - path: cmd.path, - usage: commandUsage(cmd), - summary: cmd.summary ?? "", - requires: { network: cmd.network, auth: cmd.auth, wallet: cmd.wallet }, - ...(cmd.capability ? { capability: cmd.capability } : {}), - examples: cmd.examples.map((e) => e.cmd), - ...(inputFlagsFor(cmd).length ? { inputFlags: inputFlagsFor(cmd) } : {}), - inputSchema: commandInputSchema(cmd.input), - })); return JSON.stringify({ tool: "wallet-cli", version, globalFlags: GLOBAL_FLAGS, commands }); } + +function mergedInput(def: ChainCommandDefinition): z.ZodType { + let shape = { ...def.spec.baseFields.shape }; + for (const binding of Object.values(def.families)) { + if (binding?.fields) shape = { ...shape, ...binding.fields.shape }; + } + let input: z.ZodType = z.object(shape); + if (def.spec.baseRefine) input = input.superRefine(def.spec.baseRefine); + for (const binding of Object.values(def.families)) { + if (binding?.refine) input = input.superRefine(binding.refine); + } + return input; +} diff --git a/ts/src/adapters/inbound/cli/help/help.test.ts b/ts/src/adapters/inbound/cli/help/help.test.ts index 4e5e6cdc..3988cb54 100644 --- a/ts/src/adapters/inbound/cli/help/help.test.ts +++ b/ts/src/adapters/inbound/cli/help/help.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest" import { z } from "zod" import { HelpService } from "./index.js" import { CommandRegistry } from "../registry/index.js" -import type { CommandDefinition, StreamManager } from "../contracts/index.js" +import type { ChainSpec, StreamManager } from "../contracts/index.js" // ── minimal fakes ───────────────────────────────────────────────────────────── @@ -15,19 +15,18 @@ function makeStream(): StreamManager & { last: string | undefined } { return s } -function chainCmd(path: string[], shape: z.ZodRawShape): CommandDefinition { - const fields = z.object(shape) +function chainSpec(path: string[], shape: z.ZodRawShape): ChainSpec { return { - path, family: "tron", network: "optional", wallet: "none", auth: "none", - fields, input: fields, examples: [], run: async () => ({}), - } as unknown as CommandDefinition + path, network: "optional", wallet: "none", auth: "none", + baseFields: z.object(shape), examples: [], + } } function build(): { help: HelpService; stream: ReturnType } { const reg = new CommandRegistry() - reg.add(chainCmd(["block"], { number: z.string().optional() })) // single-segment leaf - reg.add(chainCmd(["tx", "info"], { txid: z.string().min(1) })) // multi-segment leaf - reg.add(chainCmd(["tx", "send"], { to: z.string() })) // sibling under the tx group + reg.addChain(chainSpec(["block"], { number: z.string().optional() }), "tron", { run: async () => ({}) }) // single-segment leaf + reg.addChain(chainSpec(["tx", "info"], { txid: z.string().min(1) }), "tron", { run: async () => ({}) }) // multi-segment leaf + reg.addChain(chainSpec(["tx", "send"], { to: z.string() }), "tron", { run: async () => ({}) }) // sibling under the tx group const stream = makeStream() const help = new HelpService(reg, stream, "9.9.9") return { help, stream } @@ -59,3 +58,29 @@ describe("HelpService --json-schema", () => { expect(out).toHaveProperty("commands") // group head → catalog, not a phantom command schema }) }) + +describe("HelpService ChainCommandDefinition", () => { + it("renders one chain leaf and one family-keyed catalog entry", () => { + const reg = new CommandRegistry() + reg.addChain({ + path: ["block"], + network: "optional", + wallet: "none", + auth: "none", + positionals: [{ field: "number" }], + summary: "Get a block by number", + examples: [], + baseFields: z.object({ number: z.string().optional().describe("block number") }), + }, "tron", { run: async () => ({}) }) + const stream = makeStream() + const help = new HelpService(reg, stream, "9.9.9") + + help.handleMeta(["block", "--help"]) + expect(stream.last).toContain("Get a block by number") + expect(stream.last!.match(/^ number\s/mg)).toHaveLength(1) + + help.handleMeta(["--json-schema"]) + const catalog = JSON.parse(stream.last!) + expect(catalog.commands).toContainEqual(expect.objectContaining({ id: "block", families: ["tron"] })) + }) +}) diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index de175982..f44e78a3 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -6,9 +6,10 @@ * Two command kinds, discriminated by `family`: neutral (full path) and chain (logical path, * per-family impls). A leading family token (e.g. tron) is an optional addressing prefix here. */ -import { z } from "zod" +import { z, type ZodObject, type ZodRawShape } from "zod" import type { ChainFamily, ExitCode } from "../../../../domain/types/index.js" -import type { CommandDefinition, StreamManager } from "../contracts/index.js" +import { isChainCommand } from "../contracts/index.js" +import type { ChainCommandDefinition, ChainSpec, CommandDefinition, StoredCommand, StreamManager } from "../contracts/index.js" import { CommandRegistry } from "../registry/index.js" import { introspectFields, type FieldInfo } from "../arity/index.js" import { GLOBAL_FLAGS, type GlobalFlag, inputFlagsFor, buildCatalog } from "./catalog.js" @@ -31,13 +32,14 @@ export class HelpService { this.streams.result(this.version) return 0 } - const positionals = tokens.filter((t) => !t.startsWith("-")) + const positionals = metaPositionals(tokens) const { family, path } = this.#split(positionals) const concrete = this.#resolveConcrete(family, path) if (tokens.includes("--json-schema")) { if (concrete) { - this.streams.result(JSON.stringify(z.toJSONSchema(concrete.input))) + const input = isChainCommand(concrete) ? mergedFields(concrete) : concrete.input + this.streams.result(JSON.stringify(z.toJSONSchema(input))) return 0 } // no concrete command → machine catalog (every command + flags), optionally scoped to a @@ -47,21 +49,13 @@ export class HelpService { } if (concrete) { - this.streams.result(this.#renderCommand(concrete)) + this.streams.result(isChainCommand(concrete) ? this.#renderChainCommand(concrete) : this.#renderCommand(concrete)) return 0 } if (!family && path.length === 1 && this.#isNeutralGroup(path[0]!)) { this.streams.result(this.#renderNeutralGroup(path[0]!)) return 0 } - if (path.length > 1 && this.#isChainGroup(path[0]!)) { - let candidates = this.registry.resolveCandidates(path) - if (family) candidates = candidates.filter((c) => c.family === family) - if (candidates.length > 0) { - this.streams.result(this.#renderLogicalCommand(path, candidates)) - return 0 - } - } this.streams.result(this.#renderTree(path[0])) return 0 } @@ -76,22 +70,15 @@ export class HelpService { } /** resolve to a single command: a neutral command by full path, or a family-pinned chain command. */ - #resolveConcrete(family: ChainFamily | undefined, path: string[]): CommandDefinition | null { + #resolveConcrete(family: ChainFamily | undefined, path: string[]): StoredCommand | null { if (path.length === 0) return null - if (family) return this.registry.resolveForFamily(path, family) + const chain = this.registry.resolveChain(path) + if (chain && (!family || chain.families[family])) return chain + const chainHeadLeaf = this.registry.resolveChain([path[0]!]) + if (chainHeadLeaf && (!family || chainHeadLeaf.families[family])) return chainHeadLeaf + if (family) return null const neutral = this.registry.resolveNeutral(path) if (neutral) return neutral - // unique chain leaf: if the full logical path has exactly one impl (single family), render/emit - // that command directly — so `block` and `tx info` behave alike without a family prefix. Once a - // path has multiple families (e.g. tron + evm), it's ambiguous → fall through to the family-scoped - // catalog instead. - const exact = this.registry.resolveCandidates(path) - if (exact.length === 1) return exact[0]! - // single-segment chain leaf (e.g. `block`): resolve by its HEAD so `block 123` and even - // `block ` still render the leaf help instead of a phantom `block COMMAND` group. Group heads - // like `account` have no command at the bare path, so they stay groups (headLeaf is undefined). - const headLeaf = this.registry.resolveCandidates([path[0]!])[0] - if (headLeaf && headLeaf.path.length === 1) return headLeaf return null } @@ -201,28 +188,6 @@ export class HelpService { return lines.join("\n") } - /** logical leaf (`account balance --help`): merge per-family flags; addressing/auth taken from the first impl. */ - #renderLogicalCommand(path: string[], candidates: CommandDefinition[]): string { - const fields = new Map() - for (const cmd of candidates) { - for (const f of introspectFields(cmd.fields)) fields.set(f.name, f) - } - const base = candidates[0]! - return this.#renderLeaf({ - path, - summary: base.summary, - network: base.network, - auth: base.auth, - wallet: base.wallet, - broadcasts: base.broadcasts, - fields: [...fields.values()], - inputFlags: inputFlagsFor(base), - examples: base.examples, - requires: base.requires, - positionals: base.positionals, - }) - } - #renderCommand(cmd: CommandDefinition): string { return this.#renderLeaf({ path: cmd.path, @@ -239,11 +204,28 @@ export class HelpService { }) } + #renderChainCommand(def: ChainCommandDefinition): string { + const { spec } = def + return this.#renderLeaf({ + path: spec.path, + summary: spec.summary, + network: spec.network, + auth: spec.auth, + wallet: spec.wallet, + broadcasts: spec.broadcasts, + fields: introspectFields(mergedFields(def)), + inputFlags: spec.stdin ? inputFlagsFor(spec) : [], + examples: spec.examples, + requires: spec.requires, + positionals: spec.positionals, + }) + } + /** shared leaf skeleton (叶子层): Usage → description → Requires → Options (incl. stdin channel) → Global options → Examples. */ #renderLeaf(c: { path: string[] summary?: string - network: CommandDefinition["network"] + network: ChainSpec["network"] | "none" auth: CommandDefinition["auth"] wallet: CommandDefinition["wallet"] broadcasts?: boolean @@ -307,13 +289,12 @@ export class HelpService { return lines.join("\n") } - /** chain groups = first path segment of every family-bound command. */ + /** chain groups = first path segment of every assembled chain command. */ #chainGroups(): string[] { const seen = new Set() const out: string[] = [] for (const c of this.registry.all()) { - if (!c.family) continue - const group = c.path[0] + const group = isChainCommand(c) ? c.spec.path[0] : undefined if (group && !seen.has(group)) (seen.add(group), out.push(group)) } return out @@ -323,21 +304,22 @@ export class HelpService { return this.#chainGroups().includes(group) } - /** chain group sub-commands, deduped across families by logical path. */ + /** chain group sub-commands, one row per logical chain definition. */ #chainGroupCommands(group: string): Array<{ path: string[]; summary?: string }> { - const seen = new Set() const out: Array<{ path: string[]; summary?: string }> = [] for (const c of this.registry.all()) { - if (!c.family || c.path[0] !== group) continue - const key = c.path.join(".") - if (!seen.has(key)) (seen.add(key), out.push({ path: c.path, summary: c.summary })) + if (isChainCommand(c) && c.spec.path[0] === group) { + out.push({ path: c.spec.path, summary: c.spec.summary }) + } } return out } /** neutral groups = heads of neutral commands that have sub-verbs (e.g. import). */ #neutralGroupCommands(head: string): CommandDefinition[] { - return this.registry.all().filter((c) => !c.family && c.path[0] === head && c.path.length > 1) + return this.registry.all().filter((c): c is CommandDefinition => + !isChainCommand(c) && c.path[0] === head && c.path.length > 1, + ) } #isNeutralGroup(head: string): boolean { @@ -350,6 +332,30 @@ export class HelpService { } } +function mergedFields(def: ChainCommandDefinition): ZodObject { + let shape = { ...def.spec.baseFields.shape } + for (const b of Object.values(def.families)) if (b?.fields) shape = { ...shape, ...b.fields.shape } + return z.object(shape) +} + +function metaPositionals(tokens: string[]): string[] { + const valueFlags = new Set( + GLOBAL_FLAGS + .filter((flag) => flag.type !== "boolean") + .flatMap((flag) => [flag.flag, flag.alias].filter((name): name is string => name !== undefined)), + ) + const positionals: string[] = [] + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]! + if (token.startsWith("-")) { + if (valueFlags.has(token)) i += 1 + continue + } + positionals.push(token) + } + return positionals +} + /** "--flag " header for a command flag — enum fields list their choices instead of . */ function flagHead(f: FieldInfo): string { const typ = f.choices ? ` <${f.choices.join("|")}>` : f.baseType === "boolean" ? "" : ` <${f.baseType}>` @@ -373,7 +379,7 @@ function formatDefault(v: unknown): string { // only for ✍️ broadcast commands; --account only when the command acts as an account (also surfaced, // with fuller semantics, under Requires). The full GLOBAL_FLAGS array still backs the --json-schema catalog. function globalFlagsForText( - network: CommandDefinition["network"], + network: ChainSpec["network"] | "none", auth: CommandDefinition["auth"], wallet: CommandDefinition["wallet"], broadcasts: boolean, diff --git a/ts/src/adapters/inbound/cli/output/envelope.test.ts b/ts/src/adapters/inbound/cli/output/envelope.test.ts index 208d0ceb..171e15d2 100644 --- a/ts/src/adapters/inbound/cli/output/envelope.test.ts +++ b/ts/src/adapters/inbound/cli/output/envelope.test.ts @@ -1,16 +1,15 @@ import { describe, it, expect } from "vitest"; import { OutputEnvelope } from "./envelope.js"; -import type { CommandDefinition } from "../contracts/index.js"; // Single shipping family (TRON); the envelope no longer redacts addresses — it passes the // command's result payload through verbatim under the wallet-cli.result.v1 contract. -const cmd = { path: ["current"] } as CommandDefinition; +const command = "current"; const m = { durationMs: 0, warnings: [] }; describe("OutputEnvelope.success — result payload passthrough", () => { it("passes a single descriptor's addresses map through unchanged", () => { const data = { accountId: "a.0", addresses: { tron: "Ttron" } }; - const env = OutputEnvelope.success(cmd, undefined, data, m); + const env = OutputEnvelope.success(command, undefined, data, m); expect((env.data as { addresses: Record }).addresses).toEqual({ tron: "Ttron" }); }); @@ -19,13 +18,13 @@ describe("OutputEnvelope.success — result payload passthrough", () => { { accountId: "a.0", addresses: { tron: "Ttron0" } }, { accountId: "a.1", addresses: { tron: "Ttron1" } }, ]; - const env = OutputEnvelope.success(cmd, undefined, data, m); + const env = OutputEnvelope.success(command, undefined, data, m); expect(env.data).toEqual(data); }); it("leaves data without an addresses field untouched", () => { const data = { accountId: "a.0", scope: "wallet", secretRemoved: true }; - const env = OutputEnvelope.success(cmd, undefined, data, m); + const env = OutputEnvelope.success(command, undefined, data, m); expect(env.data).toEqual(data); }); }); diff --git a/ts/src/adapters/inbound/cli/output/envelope.ts b/ts/src/adapters/inbound/cli/output/envelope.ts index 7f79718a..14fce602 100644 --- a/ts/src/adapters/inbound/cli/output/envelope.ts +++ b/ts/src/adapters/inbound/cli/output/envelope.ts @@ -4,8 +4,7 @@ * Pure (no I/O); the formatter turns the envelope into strings. */ import type { NetworkDescriptor } from "../../../../domain/types/index.js"; -import type { ChainView, CommandDefinition, ErrorEnvelope, Meta, ResultEnvelope } from "../contracts/index.js"; -import { commandId } from "../command-id.js"; +import type { ChainView, ErrorEnvelope, Meta, ResultEnvelope } from "../contracts/index.js"; type CliErrorEnvelopeShape = { code: string; message: string; details?: object }; @@ -34,7 +33,7 @@ function meta(durationMs: number, warnings: string[]): Meta { export const OutputEnvelope = { success( - cmd: CommandDefinition, + command: string, net: NetworkDescriptor | undefined, data: unknown, m: { durationMs: number; warnings: string[] }, @@ -42,7 +41,7 @@ export const OutputEnvelope = { const env: ResultEnvelope = { schema: SCHEMA_VERSION, success: true, - command: commandId(cmd), + command, data: data ?? {}, meta: meta(m.durationMs, m.warnings), }; @@ -51,7 +50,7 @@ export const OutputEnvelope = { }, error( - commandId: string, + command: string, net: NetworkDescriptor | undefined, err: CliErrorEnvelopeShape, m: { durationMs: number; warnings: string[] }, @@ -59,7 +58,7 @@ export const OutputEnvelope = { const env: ErrorEnvelope = { schema: SCHEMA_VERSION, success: false, - command: commandId, + command, error: err, meta: meta(m.durationMs, m.warnings), }; diff --git a/ts/src/adapters/inbound/cli/output/index.ts b/ts/src/adapters/inbound/cli/output/index.ts index d529d8eb..7b8c41a2 100644 --- a/ts/src/adapters/inbound/cli/output/index.ts +++ b/ts/src/adapters/inbound/cli/output/index.ts @@ -11,7 +11,7 @@ */ import type { NetworkDescriptor, OutputMode } from "../../../../domain/types/index.js"; import type { ProgressEvent } from "../../../../application/contracts/index.js"; -import type { CommandDefinition, StreamManager } from "../contracts/index.js"; +import type { StreamManager, TextFormatter } from "../contracts/index.js"; import type { CliError } from "../../../../domain/errors/index.js"; import { OutputEnvelope, toJson } from "./envelope.js"; import { renderGenericText } from "../render/index.js"; @@ -19,7 +19,7 @@ import { sanitizeText } from "../render/scalars.js"; export interface OutputFormatter { /** the single result frame for the caller to hand to streams.result. */ - success(cmd: CommandDefinition, net: NetworkDescriptor | undefined, data: unknown, accountLabel?: string): string; + success(command: string, net: NetworkDescriptor | undefined, data: unknown, formatText?: TextFormatter, accountLabel?: string): string; /** terminal error output (JSON envelope to stdout, or short line to stderr). */ error(err: CliError, ctx?: { commandId?: string; net?: NetworkDescriptor }): void; /** intermediate progress frame for streams.event; null = this mode does not show it. */ @@ -38,9 +38,9 @@ abstract class BaseOutputFormatter { } class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter { - success(cmd: CommandDefinition, net: NetworkDescriptor | undefined, data: unknown): string { + success(command: string, net: NetworkDescriptor | undefined, data: unknown): string { // JSON mode always uses the envelope; the account label is a text-mode display nicety. - return toJson(OutputEnvelope.success(cmd, net, data, this.meta())); + return toJson(OutputEnvelope.success(command, net, data, this.meta())); } error(err: CliError, ctx?: { commandId?: string; net?: NetworkDescriptor }): void { @@ -56,9 +56,9 @@ class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter 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. - success(cmd: CommandDefinition, net: NetworkDescriptor | undefined, data: unknown, accountLabel?: string): string { - const env = OutputEnvelope.success(cmd, net, data, this.meta()); - const custom = cmd.formatText?.(env.data, { command: env.command, net, accountLabel }); + success(command: string, net: NetworkDescriptor | undefined, data: unknown, formatText?: TextFormatter, accountLabel?: string): string { + const env = OutputEnvelope.success(command, net, data, this.meta()); + const custom = formatText?.(env.data, { command: env.command, net, accountLabel }); return sanitizeText(custom ?? renderGenericText(env.command, net, env.data)); } diff --git a/ts/src/adapters/inbound/cli/output/output.test.ts b/ts/src/adapters/inbound/cli/output/output.test.ts index dc62abfa..6b11fc80 100644 --- a/ts/src/adapters/inbound/cli/output/output.test.ts +++ b/ts/src/adapters/inbound/cli/output/output.test.ts @@ -14,7 +14,7 @@ function capture(output: "text" | "json") { return { sm, out, err }; } -const cmd = { family: "tron", path: ["account", "balance"] } as unknown as CommandDefinition; +const cmd = { path: ["account", "balance"] } as unknown as CommandDefinition; const net: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: ["nile"], capabilities: [], }; @@ -23,9 +23,9 @@ describe("createOutputFormatter (json)", () => { it("success returns a single parseable envelope", () => { const { sm } = capture("json"); const f = createOutputFormatter("json", sm, 0); - const env = JSON.parse(f.success(cmd, net, { balance: "1" })); + const env = JSON.parse(f.success(commandId(cmd), net, { balance: "1" })); expect(env.success).toBe(true); - expect(env.command).toBe("tron.account.balance"); + expect(env.command).toBe("account.balance"); expect(env.chain).toMatchObject({ network: "tron:nile", chainId: "nile" }); expect(env.data).toEqual({ balance: "1" }); expect(env.meta).toMatchObject({ warnings: [] }); @@ -59,8 +59,8 @@ describe("createOutputFormatter (text)", () => { it("success returns human lines naming the command", () => { const { sm } = capture("text"); const f = createOutputFormatter("text", sm, 0); - const text = f.success(cmd, net, { balance: "1" }); - expect(text).toContain("tron.account.balance"); + const text = f.success(commandId(cmd), net, { balance: "1" }); + expect(text).toContain("account.balance"); expect(text).toContain("balance"); }); @@ -90,14 +90,14 @@ describe("createOutputFormatter (text)", () => { "Run `backup` soon and store the file offline.", ]), } as unknown as CommandDefinition; - const text = f.success(walletCmd, undefined, { + const text = f.success(commandId(walletCmd), undefined, { status: "created", accountId: "wlt_abc.0", label: "main", type: "seed", active: true, addresses: { tron: "T1234567890abcdef", evm: "0x1234567890abcdef" }, - }); + }, walletCmd.formatText); expect(text).toContain("Created wallet"); expect(text).toContain("main"); expect(text).toContain("Run `backup`"); @@ -112,13 +112,13 @@ describe("createOutputFormatter (text)", () => { "Private key was read from hidden input and was not printed.", ]), } as unknown as CommandDefinition; - const text = f.success(walletCmd, undefined, { + const text = f.success(commandId(walletCmd), undefined, { status: "existing", accountId: "wlt_abc.0", label: "main", type: "seed", addresses: { tron: "T1234567890abcdef", evm: "0x1234567890abcdef" }, - }); + }, walletCmd.formatText); // icon and label live in separate ANSI spans, so assert on the pieces (not a fused substring). expect(text).toContain("⚠"); expect(text).toContain("Existing wallet"); @@ -129,7 +129,7 @@ describe("createOutputFormatter (text)", () => { 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. - const text = f.success(cmd, net, { balance: "1\x1b[31mHACKED\x1b]0;pwn\x07\x9bK" }); + 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"); @@ -139,7 +139,7 @@ describe("createOutputFormatter (text)", () => { it("preserves newlines while stripping control bytes", () => { const { sm } = capture("text"); const f = createOutputFormatter("text", sm, 0); - const text = f.success(cmd, net, { balance: "1" }); + const text = f.success(commandId(cmd), net, { balance: "1" }); expect(text).toContain("\n"); // layout line breaks must remain intact }); @@ -157,7 +157,7 @@ describe("createOutputFormatter (text)", () => { it("json mode keeps data raw (no sanitization)", () => { const { sm } = capture("json"); const f = createOutputFormatter("json", sm, 0); - const env = JSON.parse(f.success(cmd, net, { balance: "1\x1b[31m" })); + 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 }); @@ -165,7 +165,7 @@ describe("createOutputFormatter (text)", () => { const { sm } = capture("text"); const f = createOutputFormatter("text", sm, 0); const backupCmd = { path: ["backup"], formatText: TextFormatters.walletBackup } as unknown as CommandDefinition; - const text = f.success(backupCmd, undefined, { + const text = f.success(commandId(backupCmd), undefined, { accountId: "wlt_abc.0", secretType: "mnemonic", out: "/tmp/main-backup.json", @@ -173,7 +173,7 @@ describe("createOutputFormatter (text)", () => { bytes: 512, mnemonic: "test test test test test test test test test test test junk", privateKey: "00".repeat(32), - }); + }, backupCmd.formatText); expect(text).toContain("/tmp/main-backup.json"); expect(text).not.toContain("test test"); expect(text).not.toContain("000000"); diff --git a/ts/src/adapters/inbound/cli/registry/index.ts b/ts/src/adapters/inbound/cli/registry/index.ts index 7fda1186..363e4b4b 100644 --- a/ts/src/adapters/inbound/cli/registry/index.ts +++ b/ts/src/adapters/inbound/cli/registry/index.ts @@ -1,43 +1,52 @@ /** - * CommandRegistry — holds all CommandDefinitions; resolves a command from a positional path - * (+ family for chain commands); exposes metadata for CliShell (yargs tree) and HelpService. - * Thin: tokenizing, flag collection, and help layout are yargs' job. + * CommandRegistry — holds all commands; resolves one from a positional path; exposes metadata + * for CliShell (yargs tree) and HelpService. Thin: tokenizing, flag collection, and help layout + * are yargs' job. * - * Two command kinds, discriminated solely by `family`: - * - neutral (no family): addressed by its full path (create, import mnemonic, config get…). - * - chain (family set): same logical path may have per-family impls, chosen by --network. + * Two command kinds, in two maps: + * - neutral (CommandDefinition): full path, no chain target (create, import mnemonic, config get…). + * - chain (ChainCommandDefinition): one logical path + a family→binding table, chosen by --network. */ import type { ChainFamily } from "../../../../domain/types/index.js"; -import type { CommandDefinition, CommandRegistryLike } from "../contracts/index.js"; -import { commandId } from "../command-id.js"; +import type { + ChainCommandDefinition, + ChainSpec, + CommandDefinition, + FamilyBinding, + StoredCommand, +} from "../contracts/index.js"; -/** flat command-tree metadata for CliShell (yargs tree) + HelpService. */ -export interface CommandTreeMeta { - commands: Array<{ path: string[]; id: string; family?: ChainFamily; summary?: string }>; -} - -/** storage/lookup key: family scopes chain commands; neutral commands share the empty scope. */ -function keyOf(cmd: CommandDefinition): string { - return `${cmd.family ?? ""}:${cmd.path.join(".")}`; -} - -export class CommandRegistry implements CommandRegistryLike { - #byKey = new Map(); +export class CommandRegistry { + #byPath = new Map(); + #chainByPath = new Map(); add(cmd: CommandDefinition): void { - // Family commands are selected through a resolved network, so this combination cannot be - // dispatched. Keep this routing invariant here instead of coupling it to the run signature. - if (cmd.family && cmd.network === "none") { - throw new Error(`family command must resolve a network: ${commandId(cmd)}`); + const key = cmd.path.join("."); + if (this.#byPath.has(key)) throw new Error(`duplicate command ${key}`); + this.#byPath.set(key, cmd); + } + + addChain(spec: ChainSpec, family: ChainFamily, binding: FamilyBinding): void { + const key = spec.path.join("."); + const existing = this.#chainByPath.get(key); + if (!existing) { + this.#chainByPath.set(key, { spec, families: { [family]: binding } }); + return; } - const key = keyOf(cmd); - if (this.#byKey.has(key)) throw new Error(`duplicate command ${key}`); - this.#byKey.set(key, cmd); + if (existing.spec !== spec) throw new Error(`chain command ${key} registered with two different specs`); + if (existing.families[family]) throw new Error(`duplicate command ${family}:${key}`); + existing.families[family] = binding; + } + + resolveChain(path: string[]): ChainCommandDefinition | null { + return this.#chainByPath.get(path.join(".")) ?? null; } families(): ChainFamily[] { const set = new Set(); - for (const cmd of this.#byKey.values()) if (cmd.family) set.add(cmd.family); + for (const cmd of this.#chainByPath.values()) { + for (const family of Object.keys(cmd.families)) set.add(family as ChainFamily); + } return [...set]; } @@ -45,37 +54,23 @@ export class CommandRegistry implements CommandRegistryLike { * (runner) against CAP_SUMMARIES — the registry stays free of presentation/infra concerns. */ capabilityKeysByFamily(): Map { const out = new Map>(); - for (const cmd of this.#byKey.values()) { - if (!cmd.family || !cmd.capability) continue; - const set = out.get(cmd.family) ?? new Set(); - set.add(cmd.capability); - out.set(cmd.family, set); + for (const cmd of this.#chainByPath.values()) { + if (!cmd.spec.capability) continue; + for (const family of Object.keys(cmd.families) as ChainFamily[]) { + const set = out.get(family) ?? new Set(); + set.add(cmd.spec.capability); + out.set(family, set); + } } return new Map([...out].map(([f, s]) => [f, [...s]])); } - /** Resolve a neutral command (no family) by its full path. */ + /** Resolve a neutral command by its full path. */ resolveNeutral(path: string[]): CommandDefinition | null { - return this.#byKey.get(`:${path.join(".")}`) ?? null; - } - - /** All commands matching a logical path, regardless of family (used to pick by --network). */ - resolveCandidates(path: string[]): CommandDefinition[] { - const key = path.join("."); - return this.all().filter((c) => c.path.join(".") === key); - } - - /** Family-specific implementation of a logical path. */ - resolveForFamily(path: string[], family: ChainFamily): CommandDefinition | null { - return this.resolveCandidates(path).find((c) => c.family === family) ?? null; - } - - all(): CommandDefinition[] { - return [...this.#byKey.values()]; + return this.#byPath.get(path.join(".")) ?? null; } - tree(): CommandTreeMeta { - const commands = this.all().map((c) => ({ path: c.path, id: commandId(c), family: c.family, summary: c.summary })); - return { commands }; + all(): StoredCommand[] { + return [...this.#byPath.values(), ...this.#chainByPath.values()]; } } diff --git a/ts/src/adapters/inbound/cli/registry/registry.chain.test.ts b/ts/src/adapters/inbound/cli/registry/registry.chain.test.ts new file mode 100644 index 00000000..c5cb3d45 --- /dev/null +++ b/ts/src/adapters/inbound/cli/registry/registry.chain.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { CommandRegistry } from "./index.js"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; + +const spec: ChainSpec = { path: ["block"], network: "optional", wallet: "none", auth: "none", examples: [], baseFields: z.object({}) }; +const tron: FamilyBinding = { run: async () => ({ block: {} }) }; + +describe("CommandRegistry.addChain", () => { + it("creates a ChainCommandDefinition resolvable by path", () => { + const reg = new CommandRegistry(); + reg.addChain(spec, "tron", tron); + const def = reg.resolveChain(["block"]); + expect(def).not.toBeNull(); + expect(def!.spec).toBe(spec); + expect(def!.families.tron).toBe(tron); + }); + + it("rejects a duplicate (path, family) binding", () => { + const reg = new CommandRegistry(); + reg.addChain(spec, "tron", tron); + expect(() => reg.addChain(spec, "tron", tron)).toThrow(/duplicate/); + }); + + it("resolveChain returns null for an unknown path", () => { + expect(new CommandRegistry().resolveChain(["nope"])).toBeNull(); + }); +}); diff --git a/ts/src/adapters/inbound/cli/registry/registry.test.ts b/ts/src/adapters/inbound/cli/registry/registry.test.ts index 4651f243..3d872ea9 100644 --- a/ts/src/adapters/inbound/cli/registry/registry.test.ts +++ b/ts/src/adapters/inbound/cli/registry/registry.test.ts @@ -1,17 +1,14 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import type { ChainFamily } from "../../../../domain/types/index.js"; import type { CommandDefinition } from "../contracts/index.js"; import { CommandRegistry } from "./index.js"; -import { commandId } from "../command-id.js"; -function command(family: ChainFamily, path: string[]): CommandDefinition { +function command(path: string[]): CommandDefinition { const fields = z.object({}); return { - family, path, - network: "optional", - wallet: "optional", + network: "none", + wallet: "none", auth: "none", fields, input: fields, @@ -20,42 +17,17 @@ function command(family: ChainFamily, path: string[]): CommandDefinition { }; } -describe("CommandRegistry logical resolution", () => { - it("rejects a family command that cannot resolve a network", () => { +describe("CommandRegistry neutral resolution", () => { + it("resolves a neutral command by its full path", () => { const reg = new CommandRegistry(); - const fields = z.object({}); - expect(() => reg.add({ - family: "tron", - path: ["invalid"], - network: "none", - wallet: "none", - auth: "none", - fields, - input: fields, - examples: [], - run: async () => ({}), - })).toThrow("family command must resolve a network: tron.invalid"); + reg.add(command(["import", "mnemonic"])); + expect(reg.resolveNeutral(["import", "mnemonic"])?.path).toEqual(["import", "mnemonic"]); + expect(reg.resolveNeutral(["import", "missing"])).toBeNull(); }); - it("returns every implementation for a logical path", () => { + it("rejects a duplicate path", () => { const reg = new CommandRegistry(); - // synthetic second family via cast: only tron ships, but the registry keys on the family - // string, so this still exercises multi-implementation logical resolution. - reg.add(command("tron", ["account", "balance"])); - reg.add(command("evm" as any, ["account", "balance"])); - - expect(reg.resolveCandidates(["account", "balance"]).map((c) => commandId(c))).toEqual([ - "tron.account.balance", - "evm.account.balance", - ]); - }); - - it("selects one implementation by family", () => { - const reg = new CommandRegistry(); - reg.add(command("tron", ["account", "balance"])); - reg.add(command("evm" as any, ["account", "balance"])); - - expect(commandId(reg.resolveForFamily(["account", "balance"], "evm" as any)!)).toBe("evm.account.balance"); - expect(reg.resolveForFamily(["account", "missing"], "evm" as any)).toBeNull(); + reg.add(command(["create"])); + expect(() => reg.add(command(["create"]))).toThrow("duplicate command create"); }); }); diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts index e08b5fae..ecf788ec 100644 --- a/ts/src/adapters/inbound/cli/shell/index.ts +++ b/ts/src/adapters/inbound/cli/shell/index.ts @@ -5,9 +5,10 @@ */ import yargs, { type Argv } from "yargs" import { randomBytes } from "node:crypto" -import { z } from "zod" +import { z, type RefinementCtx, type ZodObject, type ZodRawShape, type ZodType } from "zod" import type { AccountDescriptor, NetworkDescriptor } from "../../../../domain/types/index.js"; -import type { CommandDefinition, ExecutionContext, Globals, SessionRef, StreamManager } from "../contracts/index.js"; +import { isChainCommand } from "../contracts/index.js"; +import type { ChainCommandDefinition, ChainSpec, CommandDefinition, CommandExecutionSpec, ExecutionContext, Globals, SessionRef, StreamManager } from "../contracts/index.js"; import { CommandRegistry } from "../registry/index.js" import { CapabilityRegistry } from "../../../../application/services/capability/index.js" import { buildExecutionContext, type RuntimeDeps } from "../context/index.js" @@ -37,7 +38,7 @@ const GLOBAL_OPTS = globalYargsOptions() // Interactivity (password/secret prompts, gap-fill, account select, delete confirm) is declared // per command via `interactive`. Every other command runs as if non-TTY: missing input fails fast // rather than prompting — safer for scripts/agents. -export function isInteractiveCommand(cmd: CommandDefinition): boolean { +export function isInteractiveCommand(cmd: Pick): boolean { return cmd.interactive === true } @@ -53,9 +54,9 @@ export function buildCli(opts: ShellOptions): Argv { .options(GLOBAL_OPTS as any) const all = opts.registry.all() - // Two kinds, discriminated by family: neutral (full path) vs chain (logical path + --network). + // Two kinds: neutral (full path) vs chain (logical path + family binding chosen by --network). const neutralByHead = new Map() - for (const c of all.filter((c) => !c.family)) { + for (const c of all.filter((c): c is CommandDefinition => !isChainCommand(c))) { const head = c.path[0]! const bucket = neutralByHead.get(head) ?? [] bucket.push(c) @@ -86,16 +87,22 @@ export function buildCli(opts: ShellOptions): Argv { } } - const chainCommands = all.filter((c) => c.family) - const chainGroups = [...new Set(chainCommands.map((c) => c.path[0]).filter(Boolean) as string[])] - const fieldsOfLogicalGroup = (group: string) => chainCommands.filter((c) => c.path[0] === group).map((c) => c.fields) + const assembledChainCommands = all.filter(isChainCommand) + const chainGroups = [...new Set(assembledChainCommands.map((c) => c.spec.path[0]).filter(Boolean) as string[])] + const fieldsOfLogicalGroup = (group: string) => [ + ...assembledChainCommands.filter((c) => c.spec.path[0] === group).flatMap((c) => [ + c.spec.baseFields, + ...Object.values(c.families).flatMap((binding) => binding?.fields ? [binding.fields] : []), + ]), + ] for (const group of chainGroups) { - const leaves = chainCommands.filter((c) => c.path[0] === group) - if (leaves.every((c) => c.path.length === 1)) { + const assembledLeaves = assembledChainCommands.filter((c) => c.spec.path[0] === group) + const paths = assembledLeaves.map((c) => c.spec.path) + if (paths.every((path) => path.length === 1)) { // single-segment chain leaf (e.g. `block []`): no sub-verb; bind its own positional. cli.command( `${group} [number]`, - leaves[0]?.summary ?? "", + assembledLeaves[0]?.spec.summary ?? "", (y) => applyCommands(y, fieldsOfLogicalGroup(group)), (argv) => { // a non-numeric positional (e.g. `block get`) is a mistyped subcommand, not a block height: @@ -141,22 +148,49 @@ async function dispatchNeutral(opts: ShellOptions, path: string[], argv: any): P } async function dispatchLogical(opts: ShellOptions, path: string[], argv: any): Promise { - const { registry, globals, targetResolver } = opts - const candidates = registry.resolveCandidates(path) - if (candidates.length === 0) { - throw new UsageError("unknown_command", `unknown command: ${path.join(" ")}`) - } + const chain = opts.registry.resolveChain(path) + if (chain) return executeChainCommand(opts, chain, argv) + throw new UsageError("unknown_command", `unknown command: ${path.join(" ")}`) +} - const { network } = targetResolver.resolveNetwork(globals) - const cmd = candidates.find((c) => c.family === network.family) - if (!cmd) { - const families = [...new Set(candidates.map((c) => c.family).filter(Boolean))].join(", ") +async function executeChainCommand(opts: ShellOptions, def: ChainCommandDefinition, argv: any): Promise { + const { globals, deps, targetResolver, caps, streams, formatter, session } = opts + const { spec } = def + const id = commandId({ path: spec.path }) + session.current = { commandId: id } + + const target = targetResolver.resolve(spec, globals) + const net = requireResolvedNetwork(spec, target.network) + const binding = def.families[net.family] + if (!binding) { + const families = Object.keys(def.families).join(", ") throw new UsageError( "network_family_mismatch", - `command ${path.join(" ")} supports ${families || "no chain"} but selected network ${network.id} is ${network.family}`, + `command ${spec.path.join(" ")} supports ${families} but selected network ${net.id} is ${net.family}`, ) } - await executeCommand(opts, cmd, argv) + session.current = { commandId: id, net } + + const effectiveFields = binding.fields ? spec.baseFields.extend(binding.fields.shape) : spec.baseFields + const effectiveInput = composeRefines(effectiveFields, spec.baseRefine, binding.refine) + const executionSpec = withFields(spec, effectiveFields) + assertKnownFlags(executionSpec, argv) + deps.prompter.setInteractive(isInteractiveCommand(spec)) + caps.check(spec, net) + + if (spec.passwordMode) { + const initialized = deps.keystore.isInitialized() + const mode = spec.passwordMode === "establish" ? (initialized ? "verify" : "set") : "verify" + await deps.secrets.primePassword({ mode, verify: (pw) => deps.keystore.verifyPassword(pw) }) + } + + await gapFillRequiredFields(executionSpec, argv, deps.prompter, () => deps.keystore.list().map((d) => ({ value: d.accountId, label: accountChoiceLabel(d) }))) + const input = parseInputSchema(effectiveInput, argv) + + const ctx = buildExecutionContext(globals, deps) + if (spec.wallet !== "none") void ctx.activeAccount + const data = await binding.run(ctx, net, input) + streams.result(formatter.success(id, net, data, spec.formatText, activeAccountLabel(spec, ctx, deps))) } async function executeCommand(opts: ShellOptions, cmd: CommandDefinition, argv: any): Promise { @@ -192,15 +226,13 @@ async function executeCommand(opts: ShellOptions, cmd: CommandDefinition, argv: const ctx = buildExecutionContext(globals, deps) if (cmd.wallet !== "none") void ctx.activeAccount // resolve account (default active) up front; throws missing_wallet_address if none exists - const data = cmd.network === "none" - ? await cmd.run(ctx, undefined, input) - : await cmd.run(ctx, requireResolvedNetwork(cmd, net), input) - streams.result(formatter.success(cmd, net, data, activeAccountLabel(cmd, ctx, deps))) + const data = await cmd.run(ctx, undefined, input) + streams.result(formatter.success(commandId(cmd), net, data, cmd.formatText, activeAccountLabel(cmd, ctx, deps))) } /** Runtime counterpart to CommandDefinition's network-policy discrimination. */ function requireResolvedNetwork( - cmd: CommandDefinition, + cmd: Pick, net: NetworkDescriptor | undefined, ): NetworkDescriptor { if (net) return net @@ -210,7 +242,7 @@ function requireResolvedNetwork( /** Central account-label resolution for text receipts: the user's --account label (or active * account's label) so command formatters can show "main" instead of a shortened address. * Best-effort — wallet:"none" commands have no account, and resolution never fails the command. */ -function activeAccountLabel(cmd: CommandDefinition, ctx: ExecutionContext, deps: RuntimeDeps): string | undefined { +function activeAccountLabel(cmd: Pick, ctx: ExecutionContext, deps: RuntimeDeps): string | undefined { if (cmd.wallet === "none") return undefined try { return deps.keystore.describe(ctx.activeAccount).label @@ -237,7 +269,7 @@ function accountChoiceLabel(d: AccountDescriptor): string { * zod. */ export async function gapFillRequiredFields( - cmd: CommandDefinition, + cmd: Pick, argv: any, prompter: Pick, accountChoices?: () => Array<{ value: string; label: string }>, @@ -291,7 +323,7 @@ function randomWalletLabel(): string { * and zod would silently strip them). Allowed = positionals + globals + THIS command's fields * (a sibling command's flag in the same namespace is unknown here). → invalid_option, exit 2. */ -function assertKnownFlags(cmd: CommandDefinition, argv: any): void { +function assertKnownFlags(cmd: Pick, argv: any): void { const allowed = new Set(["_", "$0", "group", "verb", "source", "key", "value"]) const add = (name: string) => { allowed.add(name) @@ -309,7 +341,11 @@ function assertKnownFlags(cmd: CommandDefinition, argv: any): void { } function parseInput(cmd: CommandDefinition, argv: any): unknown { - const result = (cmd.input as z.ZodType).safeParse(argv) + return parseInputSchema(cmd.input, argv) +} + +function parseInputSchema(schema: ZodType, argv: any): unknown { + const result = schema.safeParse(argv) if (result.success) return result.data const issue = result.error.issues[0]! const key = issue.path[0] @@ -320,3 +356,18 @@ function parseInput(cmd: CommandDefinition, argv: any): unknown { } throw new UsageError("invalid_value", `invalid --${camelToKebab(field)}: ${issue.message}`) } + +function withFields(spec: ChainSpec, fields: ZodObject): CommandExecutionSpec { + return { ...spec, fields } +} + +function composeRefines( + fields: ZodObject, + baseRefine?: (value: any, ctx: RefinementCtx) => void, + familyRefine?: (value: any, ctx: RefinementCtx) => void, +): ZodType { + let schema: ZodType = fields + if (baseRefine) schema = schema.superRefine(baseRefine) + if (familyRefine) schema = schema.superRefine(familyRefine) + return schema +} diff --git a/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts new file mode 100644 index 00000000..b2fe73c7 --- /dev/null +++ b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { z } from "zod"; +import { buildCli, type ShellOptions } from "./index.js"; +import type { ChainSpec, SessionRef } from "../contracts/index.js"; +import { CommandRegistry } from "../registry/index.js"; +import { CapabilityRegistry } from "../../../../application/services/capability/index.js"; +import { TargetResolver } from "../../../../application/services/target/index.js"; +import { StreamManager } from "../stream/index.js"; +import { createOutputFormatter } from "../output/index.js"; +import { ConfigLoader, NetworkRegistry } from "../../../outbound/config/index.js"; +import { AtomicFileStore } from "../../../outbound/persistence/fs/index.js"; +import { Keystore } from "../../../outbound/keystore/index.js"; +import { SecretResolver } from "../input/secret/index.js"; +import { Prompter } from "../input/prompt/index.js"; + +describe("ChainCommandDefinition dispatch", () => { + it("routes a positional through the selected family binding", async () => { + const tmpRoot = mkdtempSync(join(tmpdir(), "wallet-cli-chain-test-")); + const store = new AtomicFileStore(); + const backend = { + isTTY: () => false, + async question() { return ""; }, + async readKey() { return { name: "return" }; }, + write() {}, + beginRaw() {}, + endRaw() {}, + }; + const prompter = new Prompter(backend); + const out: string[] = []; + const streams = new StreamManager("json", false, (s) => out.push(s)); + const secrets = new SecretResolver(streams, {}, prompter); + const keystore = new Keystore(tmpRoot, store, () => secrets.masterPassword()); + const config = ConfigLoader.load(); + const networkRegistry = new NetworkRegistry(config); + const formatter = createOutputFormatter("json", streams, Date.now()); + const registry = new CommandRegistry(); + const spec: ChainSpec = { + path: ["block"], + network: "optional", + wallet: "none", + auth: "none", + positionals: [{ field: "number" }], + examples: [], + baseFields: z.object({ number: z.string().optional() }), + }; + const run = vi.fn(async (_ctx, _net, input) => ({ block: { number: input.number } })); + registry.addChain(spec, "tron", { run }); + + const globals = { output: "json" as const, verbose: false, network: "tron:mainnet" }; + const deps = { config, networkRegistry, streams, secrets, keystore, prompter, formatter }; + const shellOpts: ShellOptions = { + registry, + globals, + deps, + targetResolver: new TargetResolver({ networkRegistry, keystore }), + caps: new CapabilityRegistry(), + streams, + formatter, + session: {} as SessionRef, + }; + + await buildCli(shellOpts).parseAsync(["block", "123"]); + + expect(run).toHaveBeenCalledOnce(); + expect(run.mock.calls[0]![2]).toMatchObject({ number: "123" }); + expect(JSON.parse(out[0]!).data).toEqual({ block: { number: "123" } }); + }); +}); diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index 0cf7c3ff..bc2d4546 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -1,5 +1,5 @@ import type { OutputMode } from "../domain/types/index.js"; -import type { ChainModule, Globals, SessionRef } from "../adapters/inbound/cli/contracts/index.js"; +import type { Globals, SessionRef } from "../adapters/inbound/cli/contracts/index.js"; import { ConfigLoader, NetworkRegistry } from "../adapters/outbound/config/index.js"; import { YamlConfigDocument } from "../adapters/outbound/config/yaml-config-document.js"; import { @@ -28,6 +28,7 @@ import { TxPipeline } from "../application/services/pipeline/index.js"; import { ConfigService } from "../application/use-cases/config-service.js"; import { WalletService } from "../application/use-cases/wallet-service.js"; import { FAMILY_REGISTRY, familyMap } from "./family-registry.js"; +import { registerTronChainCommands } from "./families/tron.js"; export interface BootstrapOptions { readonly globals: Globals; @@ -77,17 +78,14 @@ export function composeCliRuntime(options: BootstrapOptions) { registerWalletCommands(registry, { walletService, ledger }); registerConfigCommands(registry, configService); registerNetworkCommands(registry); - const chainModules: ChainModule[] = FAMILY_REGISTRY.map((definition) => - definition.createModule({ - gateways: gatewayProvider, - tokens: tokenBook, - prices: priceProvider, - signers: signerResolver, - transactions: txPipeline, - timeoutMs, - }), - ); - for (const module of chainModules) module.registerCommands(registry); + registerTronChainCommands(registry, { + gateways: gatewayProvider, + tokens: tokenBook, + prices: priceProvider, + signers: signerResolver, + transactions: txPipeline, + timeoutMs, + }); const capabilitiesByFamily = registry.capabilityKeysByFamily(); for (const network of Object.values(config.networks)) { diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index fe51430f..80bd7ad9 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -2,7 +2,52 @@ import { FAMILIES } from "../../domain/family/index.js"; import { tronSignStrategy } from "../../adapters/outbound/chain/tron/signing-strategy.js"; import { TronRpcClient } from "../../adapters/outbound/chain/tron/tron.js"; import { TronGridHistoryReader } from "../../adapters/outbound/chain/tron/history-reader.js"; -import { TronModule } from "../../adapters/inbound/cli/commands/tron/index.js"; +import { blockSpec, blockTronBinding } from "../../adapters/inbound/cli/commands/block.js"; +import { + accountBalanceSpec, + accountBalanceTronBinding, + accountHistorySpec, + accountHistoryTronBinding, + accountInfoSpec, + accountInfoTronBinding, + accountPortfolioSpec, + accountPortfolioTronBinding, +} from "../../adapters/inbound/cli/commands/account.js"; +import { + tokenAddSpec, + tokenAddTronBinding, + tokenBalanceSpec, + tokenBalanceTronBinding, + tokenInfoSpec, + tokenInfoTronBinding, + tokenListSpec, + tokenListTronBinding, + tokenRemoveSpec, + tokenRemoveTronBinding, +} from "../../adapters/inbound/cli/commands/token.js"; +import { messageSignSpec, messageSignBinding } from "../../adapters/inbound/cli/commands/shared.js"; +import { + txBroadcastSpec, + txBroadcastTronBinding, + txInfoSpec, + txInfoTronBinding, + txSendSpec, + txSendTronBinding, + txStatusSpec, + txStatusTronBinding, +} from "../../adapters/inbound/cli/commands/tx.js"; +import { stakeDefinitions } from "../../adapters/inbound/cli/commands/stake.js"; +import { + contractCallSpec, + contractCallTronBinding, + contractDeploySpec, + contractDeployTronBinding, + contractInfoSpec, + contractInfoTronBinding, + contractSendSpec, + contractSendTronBinding, +} from "../../adapters/inbound/cli/commands/contract.js"; +import type { CommandRegistry } from "../../adapters/inbound/cli/registry/index.js"; import { TronAccountService } from "../../application/use-cases/tron/account-service.js"; import { TronTokenService } from "../../application/use-cases/tron/token-service.js"; import { TronTransactionService } from "../../application/use-cases/tron/transaction-service.js"; @@ -10,25 +55,61 @@ import { TronContractService } from "../../application/use-cases/tron/contract-s import { TronStakeService } from "../../application/use-cases/tron/stake-service.js"; import { TronBlockService } from "../../application/use-cases/tron/block-service.js"; import { MessageService } from "../../application/use-cases/message-service.js"; +import type { ChainGatewayProvider } from "../../application/ports/chain/gateway-provider.js"; +import type { TokenRepository } from "../../application/ports/token-repository.js"; +import type { PriceProvider } from "../../application/ports/price-provider.js"; +import type { SignerResolver } from "../../application/services/signer/index.js"; +import type { TxPipeline } from "../../application/services/pipeline/index.js"; import type { FamilyPlugin } from "./types.js"; export const tronFamily: FamilyPlugin<"tron"> = { meta: FAMILIES.tron, signStrategy: tronSignStrategy, createGateway: (network, timeoutMs) => new TronRpcClient(network.httpEndpoint ?? "", timeoutMs), - createModule: ({ gateways, tokens, prices, signers, transactions, timeoutMs }) => - new TronModule({ - tronAccount: new TronAccountService( - gateways, - new TronGridHistoryReader(timeoutMs), - tokens, - prices, - ), - tronToken: new TronTokenService(gateways, tokens), - tronTransaction: new TronTransactionService(gateways, tokens, transactions), - tronContract: new TronContractService(gateways, transactions), - tronStake: new TronStakeService(gateways, transactions), - tronBlock: new TronBlockService(gateways), - message: new MessageService(signers), - }), }; + +export interface TronChainCommandDependencies { + gateways: ChainGatewayProvider; + tokens: TokenRepository; + prices: PriceProvider; + signers: SignerResolver; + transactions: TxPipeline; + timeoutMs: number; +} + +export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainCommandDependencies): void { + const account = new TronAccountService( + deps.gateways, + new TronGridHistoryReader(deps.timeoutMs), + deps.tokens, + deps.prices, + ); + const token = new TronTokenService(deps.gateways, deps.tokens); + const message = new MessageService(deps.signers); + const transaction = new TronTransactionService(deps.gateways, deps.tokens, deps.transactions); + const stake = new TronStakeService(deps.gateways, deps.transactions); + const contract = new TronContractService(deps.gateways, deps.transactions); + + reg.addChain(blockSpec, "tron", blockTronBinding(new TronBlockService(deps.gateways))); + reg.addChain(accountBalanceSpec, "tron", accountBalanceTronBinding(account)); + reg.addChain(accountInfoSpec, "tron", accountInfoTronBinding(account)); + reg.addChain(accountHistorySpec, "tron", accountHistoryTronBinding(account)); + reg.addChain(accountPortfolioSpec, "tron", accountPortfolioTronBinding(account)); + reg.addChain(tokenBalanceSpec, "tron", tokenBalanceTronBinding(token)); + reg.addChain(tokenInfoSpec, "tron", tokenInfoTronBinding(token)); + reg.addChain(tokenAddSpec, "tron", tokenAddTronBinding(token)); + reg.addChain(tokenListSpec, "tron", tokenListTronBinding(token)); + reg.addChain(tokenRemoveSpec, "tron", tokenRemoveTronBinding(token)); + reg.addChain(messageSignSpec, "tron", messageSignBinding(message)); + reg.addChain(txSendSpec, "tron", txSendTronBinding(transaction)); + reg.addChain(txBroadcastSpec, "tron", txBroadcastTronBinding(transaction)); + reg.addChain(txStatusSpec, "tron", txStatusTronBinding(transaction)); + reg.addChain(txInfoSpec, "tron", txInfoTronBinding(transaction)); + for (const definition of stakeDefinitions(stake)) { + reg.addChain(definition.spec, "tron", definition.binding); + } + reg.addChain(contractCallSpec, "tron", contractCallTronBinding(contract)); + reg.addChain(contractSendSpec, "tron", contractSendTronBinding(contract)); + reg.addChain(contractDeploySpec, "tron", contractDeployTronBinding(contract)); + reg.addChain(contractInfoSpec, "tron", contractInfoTronBinding(contract)); +} diff --git a/ts/src/bootstrap/families/types.ts b/ts/src/bootstrap/families/types.ts index 77bbec25..315dd397 100644 --- a/ts/src/bootstrap/families/types.ts +++ b/ts/src/bootstrap/families/types.ts @@ -1,30 +1,11 @@ import type { NetworkDescriptor, SignStrategy } from "../../domain/types/index.js"; -import type { ChainModule } from "../../adapters/inbound/cli/contracts/index.js"; import type { ChainFamily, FamilyMeta } from "../../domain/family/index.js"; -import type { - ChainGatewayMap, - ChainGatewayProvider, -} from "../../application/ports/chain/gateway-provider.js"; -import type { PriceProvider } from "../../application/ports/price-provider.js"; -import type { TokenRepository } from "../../application/ports/token-repository.js"; -import type { SignerResolver } from "../../application/services/signer/index.js"; -import type { TxPipeline } from "../../application/services/pipeline/index.js"; - -export interface FamilyApplicationDependencies { - gateways: ChainGatewayProvider; - tokens: TokenRepository; - prices: PriceProvider; - signers: SignerResolver; - transactions: TxPipeline; - /** effective per-invocation RPC timeout, for adapters the module constructs itself (history reader). */ - timeoutMs: number; -} +import type { ChainGatewayMap } from "../../application/ports/chain/gateway-provider.js"; export interface FamilyPlugin { readonly meta: FamilyMeta & { family: F }; readonly signStrategy: SignStrategy; createGateway(network: NetworkDescriptor, timeoutMs: number): ChainGatewayMap[F]; - createModule(dependencies: FamilyApplicationDependencies): ChainModule; } export type AnyFamilyPlugin = { diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 6f5998fa..c423e11d 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -118,13 +118,13 @@ describe("golden CLI — meta & introspection", () => { expect(globalFlags).toContain("--password-stdin"); expect(globalFlags).not.toContain("--mnemonic-stdin"); expect(r.json.aliases).toBeUndefined(); - const cmd = r.json.commands.find((c: { id: string }) => c.id === "tron.tx.send"); + const cmd = r.json.commands.find((c: { id: string }) => c.id === "tx.send"); expect(cmd.usage).toBe("wallet-cli tx send [options]"); expect(cmd.requires).toMatchObject({ network: "optional", auth: "required", wallet: "optional" }); expect(cmd.inputSchema.properties.to).toBeDefined(); const importMnemonic = r.json.commands.find((c: { id: string }) => c.id === "import.mnemonic"); expect(importMnemonic.inputFlags.map((g: { flag: string }) => g.flag)).toContain("--mnemonic-stdin"); - const broadcast = r.json.commands.find((c: { id: string }) => c.id === "tron.tx.broadcast"); + const broadcast = r.json.commands.find((c: { id: string }) => c.id === "tx.broadcast"); expect(broadcast.inputFlags.map((g: { flag: string }) => g.flag)).toContain("--tx-stdin"); const importWatch = r.json.commands.find((c: { id: string }) => c.id === "import.watch"); expect(importWatch.usage).toBe("wallet-cli import watch [options]"); @@ -134,7 +134,7 @@ describe("golden CLI — meta & introspection", () => { const r = run(["tron", "--json-schema"], { password: null }); expect(r.status).toBe(0); expect(r.json.commands.length).toBeGreaterThan(0); - expect(r.json.commands.every((c: { kind: string; family: string }) => c.kind === "chain" && c.family === "tron")).toBe(true); + expect(r.json.commands.every((c: { kind: string; family?: string; families?: string[] }) => c.kind === "chain" && (c.family === "tron" || (c.families?.includes("tron") ?? false)))).toBe(true); }); it("config shorthand shows, reads, and writes defaultNetwork", () => { @@ -175,7 +175,7 @@ describe("golden CLI — wallet lifecycle (shared identity)", () => { seedWallet(); const r = run(["--output", "json", "message", "sign", "--network", "tron:nile", "--message", "hello world"]); expect(r.status).toBe(0); - expect(r.json.command).toBe("tron.message.sign"); + expect(r.json.command).toBe("message.sign"); expect(r.json.chain.network).toBe("tron:nile"); }); @@ -183,7 +183,7 @@ describe("golden CLI — wallet lifecycle (shared identity)", () => { seedWallet(); const r = run(["--output", "json", "tx", "send", "--network", "tron:nile", "--to", TRON1, "--amount", "0.0000000000000000001", "--dry-run"]); expect(r.status).toBe(2); - expect(r.json.command).toBe("tron.tx.send"); + expect(r.json.command).toBe("tx.send"); expect(r.json.error.code).toBe("invalid_amount"); }); @@ -194,7 +194,7 @@ describe("golden CLI — wallet lifecycle (shared identity)", () => { "--token", "USDT", "--amount", "0.0000001", "--dry-run", ]); expect(r.status).toBe(2); - expect(r.json.command).toBe("tron.tx.send"); + expect(r.json.command).toBe("tx.send"); expect(r.json.error.code).toBe("invalid_amount"); }); @@ -401,7 +401,7 @@ describe("golden CLI — token address-book (local, no RPC)", () => { seedWallet(); const r = run(["--output", "json", "token", "list"]); expect(r.status).toBe(0); - expect(r.json.command).toBe("tron.token.list"); + expect(r.json.command).toBe("token.list"); expect(r.json.chain.network).toBe("tron:mainnet"); }); From 288de3ea11400e27982a2df315736dbb61007317 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 8 Jul 2026 00:46:51 +0800 Subject: [PATCH 02/16] refactor(cli): split render/index.ts by command domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure moves, no output change (golden suite byte-identical): family.ts — FAMILY_RENDER hook table + renderFamily wallet.ts — wallet receipts + list tree view account.ts — account/token queries + history/portfolio rows tx.ts — signing receipts + tx status/info misc.ts — config/networks/contract/message/block index.ts stays the barrel: reassembles TextFormatters (spread) and keeps renderGenericText, so no importer changes. methodName moves to scalars.ts (shared by tx receipts and contract call). Co-Authored-By: Claude Opus 4.8 --- ts/src/adapters/inbound/cli/render/account.ts | 148 +++++ ts/src/adapters/inbound/cli/render/family.ts | 51 ++ ts/src/adapters/inbound/cli/render/index.ts | 627 +----------------- ts/src/adapters/inbound/cli/render/misc.ts | 94 +++ ts/src/adapters/inbound/cli/render/scalars.ts | 5 + ts/src/adapters/inbound/cli/render/tx.ts | 182 +++++ ts/src/adapters/inbound/cli/render/wallet.ts | 158 +++++ 7 files changed, 657 insertions(+), 608 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/render/account.ts create mode 100644 ts/src/adapters/inbound/cli/render/family.ts create mode 100644 ts/src/adapters/inbound/cli/render/misc.ts create mode 100644 ts/src/adapters/inbound/cli/render/tx.ts create mode 100644 ts/src/adapters/inbound/cli/render/wallet.ts diff --git a/ts/src/adapters/inbound/cli/render/account.ts b/ts/src/adapters/inbound/cli/render/account.ts new file mode 100644 index 00000000..085e3d9a --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/account.ts @@ -0,0 +1,148 @@ +import type { TextFormatter, TextRenderContext } from "../contracts/index.js" +import { RESOURCES, resourceOfRpcCode, type Resource } from "../../../../domain/resources/index.js" +import { fromBaseUnits } from "../../../../domain/amounts/index.js" +import { formatScalar, formatInt, formatUsd, formatSun, formatTime, num, quote } from "./scalars.js" +import { type Obj, type Pair, asObj, query, receipt, table, ok, fail, warn } from "./layout.js" + +/** humanize a raw base-unit balance: scale by `decimals` when known, else show the raw integer. */ +function humanBalance(d: Obj): string { + return d.decimals !== undefined ? fromBaseUnits(String(d.balance ?? "0"), num(d.decimals, 0)) : formatScalar(d.balance) +} + +export const AccountFormatters = { + accountBalance: ((data, ctx) => { + const d = asObj(data) + const symbol = d.symbol ? ` ${String(d.symbol)}` : "" + return query([identity(ctx, d.address), ["Balance", `${humanBalance(d)}${symbol}`]]) + }) satisfies TextFormatter, + accountInfo: ((data, ctx) => renderAccountInfo(asObj(data), ctx)) satisfies TextFormatter, + accountHistory: ((data, ctx) => { + const d = asObj(data) + const rows = (Array.isArray(d.records) ? d.records : []).map(asObj).map(historyRow) + return [`${quote(acct(ctx, d.address))} recent transactions`, table(["Time", "Type", "Amount", "From / To", "Status"], rows)].join("\n") + }) satisfies TextFormatter, + tokenBookAdd: ((data) => { + const d = asObj(data) + const token = asObj(d.token) + const verb = d.action === "updated" ? "Updated token book" : "Added to token book" + return receipt(ok(), verb, [ + ["Name", String(token.name ?? "")], + ["Symbol", String(token.symbol ?? token.id ?? "")], + ["Decimals", token.decimals === undefined ? "" : String(token.decimals)], + ]) + }) satisfies TextFormatter, + tokenBookList: ((data) => { + const d = asObj(data) + const rows = (Array.isArray(d.tokens) ? d.tokens : []).map(asObj).map((t) => [String(t.symbol ?? ""), String(t.name ?? ""), String(t.source ?? ""), String(t.id ?? "")]) + return table(["Symbol", "Name", "Source", "Contract / ID"], rows) + }) satisfies TextFormatter, + tokenBookRemove: ((data) => { + const removed = asObj(asObj(data).removed) + return receipt(ok(), "Removed from token book", [ + ["Name", String(removed.name ?? "")], + ["Symbol", String(removed.symbol ?? "")], + ]) + }) satisfies TextFormatter, + accountPortfolio: ((data, ctx) => { + const d = asObj(data) + const holdings = (Array.isArray(d.holdings) ? d.holdings : []).map(asObj) + const rows = holdings.map((h) => [ + String(h.symbol ?? ""), + h.balanceUnavailable ? "unavailable" : formatScalar(h.balance), + h.priceUsd === null || h.priceUsd === undefined ? "-" : `$${formatUsd(h.priceUsd)}`, + h.valueUsd === null || h.valueUsd === undefined ? "-" : `$${formatUsd(h.valueUsd)}`, + ]) + const total = d.totalValueUsd === null || d.totalValueUsd === undefined ? "-" : `$${formatUsd(d.totalValueUsd)}` + const lines = [`${quote(acct(ctx, d.address ?? d.account))} Portfolio`, table(["Token", "Balance", "Price (USD)", "Value (USD)"], rows), `Total ≈ ${total}`] + for (const h of holdings) { + if (h.balanceUnavailable) lines.push(`${warn()} ${String(h.symbol ?? "")} balance unavailable (${String(h.reason ?? "")})`) + } + if (d.priceUnavailable) lines.push(`${warn()} price warning (${String(d.priceReason ?? "")})`) + return lines.join("\n") + }) satisfies TextFormatter, + + tokenBalance: ((data, ctx) => { + const d = asObj(data) + return query([identity(ctx, d.address), ["Name", String(d.name ?? "")], ["Symbol", String(d.symbol ?? "")], ["Balance", humanBalance(d)]]) + }) satisfies TextFormatter, + tokenInfo: ((data) => { + const d = asObj(data) + return query([ + ["Name", String(d.name ?? d.token_name ?? d.id ?? "")], + ["Symbol", String(d.symbol ?? d.abbr ?? "")], + ["Decimals", String(d.decimals ?? d.precision ?? "")], + ]) + }) satisfies TextFormatter, +} + +function renderAccountInfo(d: Obj, ctx: TextRenderContext): string { + 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 resources = asObj(d.resources) + const bandwidth = asObj(resources.bandwidth) + const energy = asObj(resources.energy) + const pairs: Pair[] = [] + if (ctx.accountLabel) pairs.push(["Label", ctx.accountLabel]) + pairs.push(["Address", String(d.address ?? "")]) + pairs.push(["Balance", `${formatSun(account.balance)} TRX`]) + const staked = stakedSummary(account) + if (staked) pairs.push(["Staked", staked]) + if (resources.energy) pairs.push(["Energy", `used ${formatInt(energy.used)} / ${formatInt(energy.limit)}`]) + if (resources.bandwidth) pairs.push(["Bandwidth", `used ${formatInt(bandwidth.used)} / ${formatInt(bandwidth.limit)}`]) + pairs.push(["Created", created]) + pairs.push(["Permissions", `owner ${String(owner.threshold ?? "?")}-of-${ownerKeys}, ${active} active group${active === 1 ? "" : "s"}`]) + return query(pairs) +} + +/** Sum FreezeBalanceV2 stakes into a " TRX (energy + bandwidth )" summary. */ +function stakedSummary(account: Obj): 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)} TRX (${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 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()] +} + +/** 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 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 (§0.4). */ +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/family.ts b/ts/src/adapters/inbound/cli/render/family.ts new file mode 100644 index 00000000..59592a3c --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/family.ts @@ -0,0 +1,51 @@ +import type { TxInfoView } from "../../../../domain/types/index.js" +import type { TextRenderContext } from "../contracts/index.js" +import { ChainFamily } from "../../../../domain/family/index.js" +import { formatScalar, formatInt, formatSun } from "./scalars.js" +import { 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). + */ +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): Pair[] + /** native smallest-unit amount → display string (sun→TRX / wei). */ + nativeAmount(raw: string): string + /** fee fallback when no structured fee object is present. */ + feeFallback(fee: unknown): string + /** address-type label for the per-family address rows. */ + addressLabel: string +} + +const txInfoAmount = (v: string | undefined, suffix: string): string => (v === undefined || v === "" ? "" : `${formatScalar(v)}${suffix}`) + +export const FAMILY_RENDER: Record = { + tron: { + nativeAmount: (raw) => `${formatSun(raw)} TRX`, + feeFallback: (fee) => `${formatSun(fee)} TRX`, + addressLabel: "TRON address", + txInfoRows: (r) => [ + ["TxID", r.txid], + ["From", r.from ?? ""], + ["To", r.to ?? ""], + ["Amount", txInfoAmount(r.amount, r.symbol ? ` ${r.symbol}` : "")], + ["Status", r.status ?? "unknown"], + ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], + ["Energy", r.energyUsed === undefined ? "" : formatInt(r.energyUsed)], + ["Fee", r.feeSun === undefined ? "" : `${formatSun(r.feeSun)} TRX`], + ], + }, +} + +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; the tron fallback only guards a shape that can't occur. */ +export function renderFamily(ctx?: TextRenderContext): ChainFamily { + return ctx?.net?.family ?? "tron" +} diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index a54b90aa..6a1b65ec 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -1,230 +1,27 @@ -import type { NetworkDescriptor, TxInfoView, TxReceiptKind, TxReceiptView, TxStatusView } from "../../../../domain/types/index.js" -import type { TextFormatter, TextRenderContext } from "../contracts/index.js" -import { ChainFamily } from "../../../../domain/family/index.js" -import { RESOURCES, resourceOfRpcCode, type Resource } from "../../../../domain/resources/index.js" -import { sourceLabel } from "../../../../domain/sources/index.js" -import { fromBaseUnits } from "../../../../domain/amounts/index.js" -import { formatScalar, formatInt, formatUsd, formatSun, formatTime, formatUtc, num, shorten, quote } from "./scalars.js" -import { type Obj, type Pair, asObj, kv, query, receipt, titled, table, ok, fail, pending, warn, unknown } 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). + * 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 + * misc.ts — config, networks, contract call/info, message sign, block + * This barrel reassembles the one TextFormatters table command specs import. */ -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): Pair[] - /** native smallest-unit amount → display string (sun→TRX / wei). */ - nativeAmount(raw: string): string - /** fee fallback when no structured fee object is present. */ - feeFallback(fee: unknown): string - /** address-type label for the per-family address rows. */ - addressLabel: string -} - -const txInfoAmount = (v: string | undefined, suffix: string): string => (v === undefined || v === "" ? "" : `${formatScalar(v)}${suffix}`) +import type { NetworkDescriptor } from "../../../../domain/types/index.js" +import { formatScalar } from "./scalars.js" +import { type Obj, ok } from "./layout.js" +import { WalletFormatters } from "./wallet.js" +import { AccountFormatters } from "./account.js" +import { TxFormatters } from "./tx.js" +import { MiscFormatters } from "./misc.js" -export const FAMILY_RENDER: Record = { - tron: { - nativeAmount: (raw) => `${formatSun(raw)} TRX`, - feeFallback: (fee) => `${formatSun(fee)} TRX`, - addressLabel: "TRON address", - txInfoRows: (r) => [ - ["TxID", r.txid], - ["From", r.from ?? ""], - ["To", r.to ?? ""], - ["Amount", txInfoAmount(r.amount, r.symbol ? ` ${r.symbol}` : "")], - ["Status", r.status ?? "unknown"], - ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], - ["Energy", r.energyUsed === undefined ? "" : formatInt(r.energyUsed)], - ["Fee", r.feeSun === undefined ? "" : `${formatSun(r.feeSun)} TRX`], - ], - }, -} - -/** humanize a raw base-unit balance: scale by `decimals` when known, else show the raw integer. */ -function humanBalance(d: Obj): string { - return d.decimals !== undefined ? fromBaseUnits(String(d.balance ?? "0"), num(d.decimals, 0)) : formatScalar(d.balance) -} +export { FAMILY_RENDER, renderFamily } from "./family.js" export const TextFormatters = { - walletCreated: - (verb: "Created" | "Imported", notes: string[]): TextFormatter => - (data) => - renderWalletCreated(verb, asObj(data), notes), - walletWatch: ((data) => { - const d = asObj(data) - return receipt(ok(), `Added watch-only account ${quote(displayName(d))}`, [ - ["Address", firstAddress(d)], - ["Note", "read-only; signing operations will be rejected"], - ]) - }) satisfies TextFormatter, - walletLedger: ((data) => renderLedgerImported(asObj(data))) satisfies TextFormatter, - walletList: ((data) => renderWalletList(Array.isArray(data) ? data.map(asObj) : [])) satisfies TextFormatter, - walletUse: ((data) => { - const d = asObj(data) - return receipt(ok(), `Active account: ${displayName(d)}`, addressPairs(d)) - }) satisfies TextFormatter, - walletCurrent: ((data) => { - const d = asObj(data) - return titled(`Active account: ${displayName(d)}`, addressPairs(d)) - }) satisfies TextFormatter, - walletRename: ((data) => { - const d = asObj(data) - return receipt(ok(), "Renamed account", [ - ["Old label", String(d.previousLabel ?? "")], - ["New label", displayName(d)], - ]) - }) satisfies TextFormatter, - walletDerive: ((data) => { - const d = asObj(data) - return receipt(ok(), `Derived sub-account ${quote(displayName(d))}`, [ - ["Address", firstAddress(d)], - ["Active", d.active === true ? "yes" : ""], - ["Note", "shares master mnemonic; no separate backup needed"], - ]) - }) satisfies TextFormatter, - walletDelete: ((data) => { - const d = asObj(data) - const scope = d.scope === "account" ? "account" : "wallet" - return receipt(ok(), `Deleted ${scope} ${String(d.accountId ?? "")}`, [ - ["Secret removed", d.secretRemoved === false ? "no" : "yes"], - ["New active", d.newActive ? String(d.newActive) : ""], - ]) - }) satisfies TextFormatter, - walletBackup: ((data) => { - const d = asObj(data) - return [ - receipt(warn(), `Backup written ${String(d.out ?? "")}`, [ - ["Account ID", String(d.accountId ?? "")], - ["Secret", secretLabel(d.secretType)], - ["File mode", String(d.fileMode ?? "0600")], - ["Bytes", String(d.bytes ?? "?")], - ]), - "", - `${warn()} Secret material was written only to the backup file, never to stdout.`, - ].join("\n") - }) satisfies TextFormatter, - - config: ((data) => renderConfig(asObj(data))) satisfies TextFormatter, - networks: ((data) => - table( - ["Network", "Family", "Chain", "Fee model"], - (Array.isArray(data) ? data : []).map(asObj).map((n) => [String(n.id ?? ""), String(n.family ?? ""), String(n.chainId ?? ""), String(n.feeModel ?? "")]), - )) satisfies TextFormatter, - - accountBalance: ((data, ctx) => { - const d = asObj(data) - const symbol = d.symbol ? ` ${String(d.symbol)}` : "" - return query([identity(ctx, d.address), ["Balance", `${humanBalance(d)}${symbol}`]]) - }) satisfies TextFormatter, - accountInfo: ((data, ctx) => renderAccountInfo(asObj(data), ctx)) satisfies TextFormatter, - accountHistory: ((data, ctx) => { - const d = asObj(data) - const rows = (Array.isArray(d.records) ? d.records : []).map(asObj).map(historyRow) - return [`${quote(acct(ctx, d.address))} recent transactions`, table(["Time", "Type", "Amount", "From / To", "Status"], rows)].join("\n") - }) satisfies TextFormatter, - tokenBookAdd: ((data) => { - const d = asObj(data) - const token = asObj(d.token) - const verb = d.action === "updated" ? "Updated token book" : "Added to token book" - return receipt(ok(), verb, [ - ["Name", String(token.name ?? "")], - ["Symbol", String(token.symbol ?? token.id ?? "")], - ["Decimals", token.decimals === undefined ? "" : String(token.decimals)], - ]) - }) satisfies TextFormatter, - tokenBookList: ((data) => { - const d = asObj(data) - const rows = (Array.isArray(d.tokens) ? d.tokens : []).map(asObj).map((t) => [String(t.symbol ?? ""), String(t.name ?? ""), String(t.source ?? ""), String(t.id ?? "")]) - return table(["Symbol", "Name", "Source", "Contract / ID"], rows) - }) satisfies TextFormatter, - tokenBookRemove: ((data) => { - const removed = asObj(asObj(data).removed) - return receipt(ok(), "Removed from token book", [ - ["Name", String(removed.name ?? "")], - ["Symbol", String(removed.symbol ?? "")], - ]) - }) satisfies TextFormatter, - accountPortfolio: ((data, ctx) => { - const d = asObj(data) - const holdings = (Array.isArray(d.holdings) ? d.holdings : []).map(asObj) - const rows = holdings.map((h) => [ - String(h.symbol ?? ""), - h.balanceUnavailable ? "unavailable" : formatScalar(h.balance), - h.priceUsd === null || h.priceUsd === undefined ? "-" : `$${formatUsd(h.priceUsd)}`, - h.valueUsd === null || h.valueUsd === undefined ? "-" : `$${formatUsd(h.valueUsd)}`, - ]) - const total = d.totalValueUsd === null || d.totalValueUsd === undefined ? "-" : `$${formatUsd(d.totalValueUsd)}` - const lines = [`${quote(acct(ctx, d.address ?? d.account))} Portfolio`, table(["Token", "Balance", "Price (USD)", "Value (USD)"], rows), `Total ≈ ${total}`] - for (const h of holdings) { - if (h.balanceUnavailable) lines.push(`${warn()} ${String(h.symbol ?? "")} balance unavailable (${String(h.reason ?? "")})`) - } - if (d.priceUnavailable) lines.push(`${warn()} price warning (${String(d.priceReason ?? "")})`) - return lines.join("\n") - }) satisfies TextFormatter, - - tokenBalance: ((data, ctx) => { - const d = asObj(data) - return query([identity(ctx, d.address), ["Name", String(d.name ?? "")], ["Symbol", String(d.symbol ?? "")], ["Balance", humanBalance(d)]]) - }) satisfies TextFormatter, - tokenInfo: ((data) => { - const d = asObj(data) - return query([ - ["Name", String(d.name ?? d.token_name ?? d.id ?? "")], - ["Symbol", String(d.symbol ?? d.abbr ?? "")], - ["Decimals", String(d.decimals ?? d.precision ?? "")], - ]) - }) satisfies TextFormatter, - - txReceipt: ((r, ctx?: TextRenderContext) => renderTxReceipt(r, ctx)) satisfies TextFormatter, - txStatus: ((r) => { - // `state` is computed by the command (tron: getTransactionById + receipt result) — no family branch. - const status = { - confirmed: `confirmed ${ok()}`, - failed: `failed ${fail()}`, - pending: `pending ${pending()}`, - not_found: `not found ${unknown()}`, - }[r.state] - return query([ - ["TxID", r.txid], - ["Status", status], - ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], - ]) - }) satisfies TextFormatter, - txInfo: ((r, ctx) => { - return query(FAMILY_RENDER[renderFamily(ctx)].txInfoRows(r)) - }) satisfies TextFormatter, - - contractCall: ((data) => { - const d = asObj(data) - return query([ - ["Method", methodName(String(d.method ?? ""))], - ["Result", `${formatResult(d.result)} (raw)`], - ]) - }) satisfies TextFormatter, - contractInfo: ((data) => renderContractInfo(asObj(data))) satisfies TextFormatter, - - messageSign: ((data) => { - const d = asObj(data) - return receipt(ok(), "Signed", [ - ["Address", String(d.address ?? "")], - ["Signature", String(d.signature ?? "")], - ]) - }) satisfies TextFormatter, - block: ((data) => { - const block = asObj(asObj(data).block) - const header = asObj(asObj(block.block_header).raw_data) - const n = block.number ?? header.number - const ts = block.timestamp ?? header.timestamp - const txs = Array.isArray(block.transactions) ? block.transactions.length : 0 - return query([ - ["Number", n === undefined ? "" : `#${formatInt(n)}`], - ["Time", ts ? formatUtc(ts) : "unknown"], - ["Transactions", String(txs)], - ]) - }) satisfies TextFormatter, + ...WalletFormatters, + ...AccountFormatters, + ...TxFormatters, + ...MiscFormatters, } export function renderGenericText(command: string, net: NetworkDescriptor | undefined, data: unknown): string { @@ -246,389 +43,3 @@ export function renderGenericText(command: string, net: NetworkDescriptor | unde } return lines.join("\n") } - -function renderWalletCreated(verb: "Created" | "Imported", d: Obj, notes: string[]): string { - const existing = d.status === "existing" - const title = existing ? "Existing wallet" : `${verb} wallet` - const lines = [ - receipt(existing ? warn() : ok(), `${title} ${quote(displayName(d))}`, [ - ["Account ID", String(d.accountId ?? "")], - ["Type", typeLabel(d.type)], - ...addressPairs(d), - ["Active", d.active === true ? "yes" : ""], - ]), - ] - if (notes.length) lines.push("", ...notes.map((n) => `${warn()} ${n}`)) - return lines.join("\n") -} - -function renderLedgerImported(d: Obj): string { - const existing = d.status === "existing" - return [ - receipt(existing ? warn() : ok(), `${existing ? "Existing Ledger account" : "Registered Ledger account"} ${quote(displayName(d))}`, [ - ["Account ID", String(d.accountId ?? "")], - ["App", String(d.family ?? "")], - ["Path", String(d.path ?? "")], - ...addressPairs(d), - ]), - "", - `${warn()} No private key is stored locally. Signing requires device confirmation.`, - ].join("\n") -} - -/** Tree view: each seed wallet is a group headed by its seed id (the `--seed` handle for `derive`), - * its accounts listed under `├─/└─` connectors as `[index] label`. Non-HD accounts group by type. - * Plain text only — the text-mode frame is control-byte-stripped (CLI-OUT-001) so ANSI colour - * can't survive here anyway; the active account is marked with a trailing `(active)`. */ -function renderWalletList(items: Obj[]): string { - if (items.length === 0) return "No wallets found." - // group seeds by their seed id (wlt_x); non-HD accounts by type. Insertion order preserved. - const groups = new Map() - for (const d of items) { - const isSeed = d.type === "seed" - const seedId = String(d.accountId ?? "").split(".")[0] ?? "" - const key = isSeed ? `hd:${seedId}` : `type:${String(d.type)}` - const header = isSeed ? `HD ${seedId}` : typeLabel(d.type) - ;(groups.get(key) ?? groups.set(key, { hd: isSeed, header, rows: [] }).get(key)!).rows.push(d) - } - const leftOf = (d: Obj): string => (d.type === "seed" ? `[${d.index ?? "?"}] ${displayName(d)}` : displayName(d)) - const leftW = Math.max(...items.map((d) => leftOf(d).length)) - const addrW = Math.max(...items.map((d) => firstAddress(d).length)) - const row = (d: Obj, last: boolean): string => - `${last ? "└─ " : "├─ "}${leftOf(d).padEnd(leftW)} ${firstAddress(d).padEnd(addrW)} ${d.active ? "(active)" : ""}`.replace(/\s+$/, "") - const blocks: string[] = [] - for (const g of groups.values()) { - const rows = g.hd ? [...g.rows].sort((a, b) => Number(a.index) - Number(b.index)) : g.rows - blocks.push([g.header, ...rows.map((d, i) => row(d, i === rows.length - 1))].join("\n")) - } - return blocks.join("\n\n") -} - -function renderAccountInfo(d: Obj, ctx: TextRenderContext): string { - 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 resources = asObj(d.resources) - const bandwidth = asObj(resources.bandwidth) - const energy = asObj(resources.energy) - const pairs: Pair[] = [] - if (ctx.accountLabel) pairs.push(["Label", ctx.accountLabel]) - pairs.push(["Address", String(d.address ?? "")]) - pairs.push(["Balance", `${formatSun(account.balance)} TRX`]) - const staked = stakedSummary(account) - if (staked) pairs.push(["Staked", staked]) - if (resources.energy) pairs.push(["Energy", `used ${formatInt(energy.used)} / ${formatInt(energy.limit)}`]) - if (resources.bandwidth) pairs.push(["Bandwidth", `used ${formatInt(bandwidth.used)} / ${formatInt(bandwidth.limit)}`]) - pairs.push(["Created", created]) - pairs.push(["Permissions", `owner ${String(owner.threshold ?? "?")}-of-${ownerKeys}, ${active} active group${active === 1 ? "" : "s"}`]) - return query(pairs) -} - -/** Sum FreezeBalanceV2 stakes into a " TRX (energy + bandwidth )" summary. */ -function stakedSummary(account: Obj): 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)} TRX (${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 renderContractInfo(d: Obj): string { - let names: string[] - let count: number - if (Array.isArray(d.methods)) { - names = d.methods.map(String) - count = num(d.functionCount, names.length) - } else { - const contract = asObj(d.contract) - const info = asObj(d.info) - const abi = contract.abi ?? info.abi ?? contract.ABI ?? info.ABI - const nestedEntries = asObj(abi).entrys - const entries: unknown[] = Array.isArray(abi) ? abi : Array.isArray(nestedEntries) ? nestedEntries : [] - const methods = entries.map(asObj).filter((e) => e.type === "Function" || e.type === "function") - names = methods - .map((e) => e.name) - .filter(Boolean) - .map(String) - count = methods.length - } - const name = String(d.name ?? asObj(d.contract).name ?? asObj(d.info).name ?? "") - const preview = names.slice(0, 3).join(" / ") - return query([ - ["Contract", String(d.address ?? "")], - ["Name", name], - ["Methods", `${count}${preview ? ` (${preview}${count > 3 ? " …" : ""})` : ""}`], - ]) -} - -function renderConfig(d: Obj): string { - if ("input" in d) { - return receipt(ok(), "Set config", [ - ["Key", String(d.key)], - ["Value", configValue(d.value)], - ]) - } - if ("key" in d) return kv([[String(d.key), configValue(d.value)]], "") - return kv( - Object.entries(d).map(([k, v]) => [k, configValue(v)] as Pair), - "", - ) -} - -/** config values keep their literal form (no thousands grouping, raw key names). */ -function configValue(v: unknown): string { - if (Array.isArray(v)) return v.map(String).join(", ") - return v === null || v === undefined ? "" : String(v) -} - -/** Default-mode broadcast/dry-run/sign-only receipt for tx/stake/contract signing commands. - * Narrows on the typed `kind`; the active family comes from `ctx.net` (see renderFamily) — no - * `family` in the payload, no stringly command-id matching, no alias probing. */ -function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { - const family = renderFamily(ctx) - if (r.mode === "dry-run") { - return receipt(pending(), `Dry run ${actionLabel(r.kind)}`, [ - ["Fee", formatFee(r.fee, family)], - ["Tx", summarizeTx(r.tx)], - ]) - } - if (r.mode === "sign-only") { - return receipt(ok(), `Signed ${actionLabel(r.kind)}`, [ - ["Fee", formatFee(r.fee, family)], - ["Signed", summarizeTx(r.signed)], - ]) - } - const txid = String(r.txId ?? r.hash ?? "") - const stage = r.stage ?? "submitted" - const summary = receiptSummary(r, family) - const pairs: Pair[] = [...receiptRows(r)] - if (txid) pairs.push(["TxID", txid]) - - // submitted (default, non-blocking): txid only, no fee/energy yet — those need confirmation. - if (stage === "submitted") { - pairs.push(["Status", "pending — not yet on-chain"]) - const body = receipt(pending(), summary, pairs) - const networkFlag = ctx?.net ? ` --network ${ctx.net.id}` : "" - return txid ? `${body}\n! Track it: wallet-cli tx info${networkFlag} --txid ${txid}` : body - } - - // confirmed / failed (after --wait): real on-chain block / fee / energy / result. - if (r.blockNumber !== undefined && r.blockNumber !== null) pairs.push(["Block", `#${formatInt(r.blockNumber)}`]) - if (r.energyUsed !== undefined && r.energyUsed !== null) pairs.push(["Energy", formatInt(r.energyUsed)]) - if (r.feeSun !== undefined && r.feeSun !== null) pairs.push(["Fee", `${formatSun(r.feeSun)} TRX`]) - if (r.kind === "stake-unfreeze") pairs.push(["Withdrawable", "after the unlock period — then run `stake withdraw`"]) - if (stage === "failed") { - pairs.push(["Status", "failed"]) - if (r.result) pairs.push(["Reason", String(r.result)]) - return receipt(fail(), summary, pairs) - } - pairs.push(["Status", "success"]) - return receipt(ok(), summary, pairs) -} - -/** the verb-phrase summary for a broadcast receipt, by action kind. */ -function receiptSummary(r: TxReceiptView, family: ChainFamily): string { - const stakeAmt = r.amountSun !== undefined ? `${formatSun(r.amountSun)} TRX` : "TRX" - const resource = r.resource ? String(r.resource) : "" - switch (r.kind) { - case "stake-freeze": - return `Staked ${stakeAmt}${resource ? ` for ${resource}` : ""}` - case "stake-unfreeze": - return `Unstaked ${stakeAmt}` - case "stake-delegate": - return `Delegated ${stakeAmt}${resource ? ` of ${resource}` : ""}` - case "stake-undelegate": - return `Reclaimed ${stakeAmt}${resource ? ` of ${resource}` : ""}` - case "stake-withdraw": - return r.withdrawnSun ? `Withdrew ${formatSun(r.withdrawnSun)} TRX to balance` : "Withdrew expired TRX to balance" - case "stake-cancel": - return "Cancelled pending unstakes" - case "contract-send": - return `Called ${methodName(String(r.method ?? ""))}` - case "contract-deploy": - return "Contract deployed" - case "send": { - const amount = receiptAmount(r, family) - return amount ? `Sent ${amount}` : "Sent" - } - case "broadcast": - return "Broadcast" - } -} - -/** action-specific extra rows (To/From/Address/Contract), by kind. */ -function receiptRows(r: TxReceiptView): Pair[] { - const rows: Pair[] = [] - if (r.kind === "stake-delegate") rows.push(["To", String(r.receiver ?? "")]) - else if (r.kind === "stake-undelegate") rows.push(["From", String(r.receiver ?? "")]) - else if (r.kind === "contract-deploy") rows.push(["Address", String(r.contractAddress ?? "")]) - else if (r.to ?? r.receiver) rows.push(["To", String(r.to ?? r.receiver)]) - if (r.kind === "contract-send") rows.push(["Contract", String(r.contract ?? "")]) - return rows -} - -/** broadcast-receipt amount: token-aware (symbol/decimals when known, else the contract/asset-id - * identifier for raw-amount sends), native smallest-unit → coin only when no token is involved. */ -function receiptAmount(r: TxReceiptView, family: ChainFamily): string { - if (r.rawAmount !== undefined && r.rawAmount !== null && r.rawAmount !== "") { - const raw = String(r.rawAmount) - const isToken = r.token !== undefined || r.contract !== undefined || r.assetId !== undefined - if (isToken) { - const human = r.decimals !== undefined && r.decimals !== null ? fromBaseUnits(raw, num(r.decimals, 0)) : formatScalar(raw) - const label = r.token ?? r.contract ?? (r.assetId !== undefined ? `asset ${String(r.assetId)}` : "") - return label ? `${human} ${String(label)}` : human - } - return FAMILY_RENDER[family].nativeAmount(raw) - } - if (r.amountSun) return `${formatSun(r.amountSun)} TRX` - return "" -} - -/** human label for an action kind, e.g. "send" → "tx send" (for dry-run/sign-only headers). */ -function actionLabel(kind: TxReceiptKind): string { - switch (kind) { - case "send": - return "tx send" - case "broadcast": - return "tx broadcast" - case "stake-freeze": - return "stake freeze" - case "stake-unfreeze": - return "stake unfreeze" - case "stake-delegate": - return "stake delegate" - case "stake-undelegate": - return "stake undelegate" - case "stake-withdraw": - return "stake withdraw" - case "stake-cancel": - return "stake cancel-unfreeze" - case "contract-send": - return "contract send" - case "contract-deploy": - return "contract deploy" - } -} - -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()] -} - -/** 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 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 (§0.4). */ -function identity(ctx: TextRenderContext, address: unknown): Pair { - return ctx.accountLabel ? ["Label", ctx.accountLabel] : ["Address", String(address ?? "")] -} - -function displayName(d: Obj): string { - return String(d.label ?? d.accountId ?? d.id ?? "unnamed") -} - -/** non-empty address entries — drops families whose address is blank/absent. */ -function nonEmptyAddressEntries(d: Obj): Pair[] { - return Object.entries(asObj(d.addresses)) - .filter(([, address]) => typeof address === "string" && address.length > 0) - .map(([family, address]) => [family, String(address)] as Pair) -} - -function firstAddress(d: Obj): string { - const first = nonEmptyAddressEntries(d)[0] - return first ? first[1] : "" -} - -/** per-family address field pairs, addresses shown in full (§0.4 ②). */ -function addressPairs(d: Obj): Pair[] { - return nonEmptyAddressEntries(d).map(([family, address]) => [familyAddressLabel(family), address] as Pair) -} - -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; the tron fallback only guards a shape that can't occur. */ -function renderFamily(ctx?: TextRenderContext): ChainFamily { - return ctx?.net?.family ?? "tron" -} - -function typeLabel(v: unknown): string { - return sourceLabel(v) -} - -function secretLabel(v: unknown): string { - switch (v) { - case "mnemonic": - return "recovery phrase" - case "privateKey": - return "private key" - default: - return String(v ?? "secret") - } -} - -function methodName(sig: string): string { - return sig.replace(/\(.*/, "") || sig -} - -function formatResult(v: unknown): string { - if (Array.isArray(v)) return v.map((x) => formatScalar(x)).join(", ") - return formatScalar(v) -} - -function formatFee(fee: unknown, family: ChainFamily): string { - if (!fee) return "unknown" - if (typeof fee === "object") { - const f = asObj(fee) - if (f.feeSun) return `${formatSun(f.feeSun)} TRX` - if (f.bandwidthBurnSunIfNoFreeze) return `${formatSun(f.bandwidthBurnSunIfNoFreeze)} TRX` - // energy estimate (TRC20/contract via estimateResources): no sun figure — staked energy may - // cover it. Report the estimated energy + whether the account's available energy covers it. - if (f.energy !== undefined) { - const energy = Number(f.energy) - const avail = f.availableEnergy === undefined ? undefined : Number(f.availableEnergy) - const covered = avail !== undefined && avail >= energy ? " (covered by staked energy)" : "" - return `~${energy.toLocaleString()} energy${covered}` - } - if (f.note) return String(f.note) - } - return FAMILY_RENDER[family].feeFallback(fee) -} - -function summarizeTx(tx: unknown): string { - if (!tx || typeof tx !== "object") return formatScalar(tx) - const o = asObj(tx) - return shorten(String(o.txid ?? o.txID ?? o.hash ?? JSON.stringify(o))) -} diff --git a/ts/src/adapters/inbound/cli/render/misc.ts b/ts/src/adapters/inbound/cli/render/misc.ts new file mode 100644 index 00000000..ed794524 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/misc.ts @@ -0,0 +1,94 @@ +import type { TextFormatter } from "../contracts/index.js" +import { formatScalar, formatInt, formatUtc, num, methodName } from "./scalars.js" +import { type Obj, type Pair, asObj, kv, query, receipt, table, ok } from "./layout.js" + +export const MiscFormatters = { + config: ((data) => renderConfig(asObj(data))) satisfies TextFormatter, + networks: ((data) => + table( + ["Network", "Family", "Chain", "Fee model"], + (Array.isArray(data) ? data : []).map(asObj).map((n) => [String(n.id ?? ""), String(n.family ?? ""), String(n.chainId ?? ""), String(n.feeModel ?? "")]), + )) satisfies TextFormatter, + + contractCall: ((data) => { + const d = asObj(data) + return query([ + ["Method", methodName(String(d.method ?? ""))], + ["Result", `${formatResult(d.result)} (raw)`], + ]) + }) satisfies TextFormatter, + contractInfo: ((data) => renderContractInfo(asObj(data))) satisfies TextFormatter, + + messageSign: ((data) => { + const d = asObj(data) + return receipt(ok(), "Signed", [ + ["Address", String(d.address ?? "")], + ["Signature", String(d.signature ?? "")], + ]) + }) satisfies TextFormatter, + block: ((data) => { + const block = asObj(asObj(data).block) + const header = asObj(asObj(block.block_header).raw_data) + const n = block.number ?? header.number + const ts = block.timestamp ?? header.timestamp + const txs = Array.isArray(block.transactions) ? block.transactions.length : 0 + return query([ + ["Number", n === undefined ? "" : `#${formatInt(n)}`], + ["Time", ts ? formatUtc(ts) : "unknown"], + ["Transactions", String(txs)], + ]) + }) satisfies TextFormatter, +} + +function renderContractInfo(d: Obj): string { + let names: string[] + let count: number + if (Array.isArray(d.methods)) { + names = d.methods.map(String) + count = num(d.functionCount, names.length) + } else { + const contract = asObj(d.contract) + const info = asObj(d.info) + const abi = contract.abi ?? info.abi ?? contract.ABI ?? info.ABI + const nestedEntries = asObj(abi).entrys + const entries: unknown[] = Array.isArray(abi) ? abi : Array.isArray(nestedEntries) ? nestedEntries : [] + const methods = entries.map(asObj).filter((e) => e.type === "Function" || e.type === "function") + names = methods + .map((e) => e.name) + .filter(Boolean) + .map(String) + count = methods.length + } + const name = String(d.name ?? asObj(d.contract).name ?? asObj(d.info).name ?? "") + const preview = names.slice(0, 3).join(" / ") + return query([ + ["Contract", String(d.address ?? "")], + ["Name", name], + ["Methods", `${count}${preview ? ` (${preview}${count > 3 ? " …" : ""})` : ""}`], + ]) +} + +function renderConfig(d: Obj): string { + if ("input" in d) { + return receipt(ok(), "Set config", [ + ["Key", String(d.key)], + ["Value", configValue(d.value)], + ]) + } + if ("key" in d) return kv([[String(d.key), configValue(d.value)]], "") + return kv( + Object.entries(d).map(([k, v]) => [k, configValue(v)] as Pair), + "", + ) +} + +/** config values keep their literal form (no thousands grouping, raw key names). */ +function configValue(v: unknown): string { + if (Array.isArray(v)) return v.map(String).join(", ") + return v === null || v === undefined ? "" : String(v) +} + +function formatResult(v: unknown): string { + if (Array.isArray(v)) return v.map((x) => formatScalar(x)).join(", ") + return formatScalar(v) +} diff --git a/ts/src/adapters/inbound/cli/render/scalars.ts b/ts/src/adapters/inbound/cli/render/scalars.ts index 8bedfbf6..8ae51022 100644 --- a/ts/src/adapters/inbound/cli/render/scalars.ts +++ b/ts/src/adapters/inbound/cli/render/scalars.ts @@ -58,6 +58,11 @@ export function quote(s: string): string { return `"${s}"`; } +/** contract method display name: strip the signature's parameter list, e.g. "transfer(address,uint256)" -> "transfer". */ +export function methodName(sig: string): string { + return sig.replace(/\(.*/, "") || sig; +} + // Neutralize terminal control-sequence injection from untrusted labels / remote metadata. // Strips C0 (except the newline layout uses for line breaks), DEL, and C1 bytes: removing the // ESC (0x1B) and C1 introducers degrades any ANSI CSI / OSC payload to harmless literal text. diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts new file mode 100644 index 00000000..b057776f --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -0,0 +1,182 @@ +import type { TxInfoView, TxReceiptKind, TxReceiptView, TxStatusView } from "../../../../domain/types/index.js" +import type { TextFormatter, TextRenderContext } from "../contracts/index.js" +import { ChainFamily } from "../../../../domain/family/index.js" +import { fromBaseUnits } from "../../../../domain/amounts/index.js" +import { formatScalar, formatInt, formatSun, num, shorten, methodName } from "./scalars.js" +import { type Pair, asObj, query, receipt, ok, fail, pending, unknown } from "./layout.js" +import { FAMILY_RENDER, renderFamily } from "./family.js" + +export const TxFormatters = { + txReceipt: ((r, ctx?: TextRenderContext) => renderTxReceipt(r, ctx)) satisfies TextFormatter, + txStatus: ((r) => { + // `state` is computed by the command (tron: getTransactionById + receipt result) — no family branch. + const status = { + confirmed: `confirmed ${ok()}`, + failed: `failed ${fail()}`, + pending: `pending ${pending()}`, + not_found: `not found ${unknown()}`, + }[r.state] + return query([ + ["TxID", r.txid], + ["Status", status], + ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], + ]) + }) satisfies TextFormatter, + txInfo: ((r, ctx) => { + return query(FAMILY_RENDER[renderFamily(ctx)].txInfoRows(r)) + }) satisfies TextFormatter, +} + +/** Default-mode broadcast/dry-run/sign-only receipt for tx/stake/contract signing commands. + * Narrows on the typed `kind`; the active family comes from `ctx.net` (see renderFamily) — no + * `family` in the payload, no stringly command-id matching, no alias probing. */ +function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { + const family = renderFamily(ctx) + if (r.mode === "dry-run") { + return receipt(pending(), `Dry run ${actionLabel(r.kind)}`, [ + ["Fee", formatFee(r.fee, family)], + ["Tx", summarizeTx(r.tx)], + ]) + } + if (r.mode === "sign-only") { + return receipt(ok(), `Signed ${actionLabel(r.kind)}`, [ + ["Fee", formatFee(r.fee, family)], + ["Signed", summarizeTx(r.signed)], + ]) + } + const txid = String(r.txId ?? r.hash ?? "") + const stage = r.stage ?? "submitted" + const summary = receiptSummary(r, family) + const pairs: Pair[] = [...receiptRows(r)] + if (txid) pairs.push(["TxID", txid]) + + // submitted (default, non-blocking): txid only, no fee/energy yet — those need confirmation. + if (stage === "submitted") { + pairs.push(["Status", "pending — not yet on-chain"]) + const body = receipt(pending(), summary, pairs) + const networkFlag = ctx?.net ? ` --network ${ctx.net.id}` : "" + return txid ? `${body}\n! Track it: wallet-cli tx info${networkFlag} --txid ${txid}` : body + } + + // confirmed / failed (after --wait): real on-chain block / fee / energy / result. + if (r.blockNumber !== undefined && r.blockNumber !== null) pairs.push(["Block", `#${formatInt(r.blockNumber)}`]) + if (r.energyUsed !== undefined && r.energyUsed !== null) pairs.push(["Energy", formatInt(r.energyUsed)]) + if (r.feeSun !== undefined && r.feeSun !== null) pairs.push(["Fee", `${formatSun(r.feeSun)} TRX`]) + if (r.kind === "stake-unfreeze") pairs.push(["Withdrawable", "after the unlock period — then run `stake withdraw`"]) + if (stage === "failed") { + pairs.push(["Status", "failed"]) + if (r.result) pairs.push(["Reason", String(r.result)]) + return receipt(fail(), summary, pairs) + } + pairs.push(["Status", "success"]) + return receipt(ok(), summary, pairs) +} + +/** the verb-phrase summary for a broadcast receipt, by action kind. */ +function receiptSummary(r: TxReceiptView, family: ChainFamily): string { + const stakeAmt = r.amountSun !== undefined ? `${formatSun(r.amountSun)} TRX` : "TRX" + const resource = r.resource ? String(r.resource) : "" + switch (r.kind) { + case "stake-freeze": + return `Staked ${stakeAmt}${resource ? ` for ${resource}` : ""}` + case "stake-unfreeze": + return `Unstaked ${stakeAmt}` + case "stake-delegate": + return `Delegated ${stakeAmt}${resource ? ` of ${resource}` : ""}` + case "stake-undelegate": + return `Reclaimed ${stakeAmt}${resource ? ` of ${resource}` : ""}` + case "stake-withdraw": + return r.withdrawnSun ? `Withdrew ${formatSun(r.withdrawnSun)} TRX to balance` : "Withdrew expired TRX to balance" + case "stake-cancel": + return "Cancelled pending unstakes" + case "contract-send": + return `Called ${methodName(String(r.method ?? ""))}` + case "contract-deploy": + return "Contract deployed" + case "send": { + const amount = receiptAmount(r, family) + return amount ? `Sent ${amount}` : "Sent" + } + case "broadcast": + return "Broadcast" + } +} + +/** action-specific extra rows (To/From/Address/Contract), by kind. */ +function receiptRows(r: TxReceiptView): Pair[] { + const rows: Pair[] = [] + if (r.kind === "stake-delegate") rows.push(["To", String(r.receiver ?? "")]) + else if (r.kind === "stake-undelegate") rows.push(["From", String(r.receiver ?? "")]) + else if (r.kind === "contract-deploy") rows.push(["Address", String(r.contractAddress ?? "")]) + else if (r.to ?? r.receiver) rows.push(["To", String(r.to ?? r.receiver)]) + if (r.kind === "contract-send") rows.push(["Contract", String(r.contract ?? "")]) + return rows +} + +/** broadcast-receipt amount: token-aware (symbol/decimals when known, else the contract/asset-id + * identifier for raw-amount sends), native smallest-unit → coin only when no token is involved. */ +function receiptAmount(r: TxReceiptView, family: ChainFamily): string { + if (r.rawAmount !== undefined && r.rawAmount !== null && r.rawAmount !== "") { + const raw = String(r.rawAmount) + const isToken = r.token !== undefined || r.contract !== undefined || r.assetId !== undefined + if (isToken) { + const human = r.decimals !== undefined && r.decimals !== null ? fromBaseUnits(raw, num(r.decimals, 0)) : formatScalar(raw) + const label = r.token ?? r.contract ?? (r.assetId !== undefined ? `asset ${String(r.assetId)}` : "") + return label ? `${human} ${String(label)}` : human + } + return FAMILY_RENDER[family].nativeAmount(raw) + } + if (r.amountSun) return `${formatSun(r.amountSun)} TRX` + return "" +} + +/** human label for an action kind, e.g. "send" → "tx send" (for dry-run/sign-only headers). */ +function actionLabel(kind: TxReceiptKind): string { + switch (kind) { + case "send": + return "tx send" + case "broadcast": + return "tx broadcast" + case "stake-freeze": + return "stake freeze" + case "stake-unfreeze": + return "stake unfreeze" + case "stake-delegate": + return "stake delegate" + case "stake-undelegate": + return "stake undelegate" + case "stake-withdraw": + return "stake withdraw" + case "stake-cancel": + return "stake cancel-unfreeze" + case "contract-send": + return "contract send" + case "contract-deploy": + return "contract deploy" + } +} + +function formatFee(fee: unknown, family: ChainFamily): string { + if (!fee) return "unknown" + if (typeof fee === "object") { + const f = asObj(fee) + if (f.feeSun) return `${formatSun(f.feeSun)} TRX` + if (f.bandwidthBurnSunIfNoFreeze) return `${formatSun(f.bandwidthBurnSunIfNoFreeze)} TRX` + // energy estimate (TRC20/contract via estimateResources): no sun figure — staked energy may + // cover it. Report the estimated energy + whether the account's available energy covers it. + if (f.energy !== undefined) { + const energy = Number(f.energy) + const avail = f.availableEnergy === undefined ? undefined : Number(f.availableEnergy) + const covered = avail !== undefined && avail >= energy ? " (covered by staked energy)" : "" + return `~${energy.toLocaleString()} energy${covered}` + } + if (f.note) return String(f.note) + } + return FAMILY_RENDER[family].feeFallback(fee) +} + +function summarizeTx(tx: unknown): string { + if (!tx || typeof tx !== "object") return formatScalar(tx) + const o = asObj(tx) + return shorten(String(o.txid ?? o.txID ?? o.hash ?? JSON.stringify(o))) +} diff --git a/ts/src/adapters/inbound/cli/render/wallet.ts b/ts/src/adapters/inbound/cli/render/wallet.ts new file mode 100644 index 00000000..730b6bd2 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/wallet.ts @@ -0,0 +1,158 @@ +import type { TextFormatter } from "../contracts/index.js" +import { sourceLabel } from "../../../../domain/sources/index.js" +import { quote } from "./scalars.js" +import { type Obj, type Pair, asObj, receipt, titled, ok, warn } from "./layout.js" +import { familyAddressLabel } from "./family.js" + +export const WalletFormatters = { + walletCreated: + (verb: "Created" | "Imported", notes: string[]): TextFormatter => + (data) => + renderWalletCreated(verb, asObj(data), notes), + walletWatch: ((data) => { + const d = asObj(data) + return receipt(ok(), `Added watch-only account ${quote(displayName(d))}`, [ + ["Address", firstAddress(d)], + ["Note", "read-only; signing operations will be rejected"], + ]) + }) satisfies TextFormatter, + walletLedger: ((data) => renderLedgerImported(asObj(data))) satisfies TextFormatter, + walletList: ((data) => renderWalletList(Array.isArray(data) ? data.map(asObj) : [])) satisfies TextFormatter, + walletUse: ((data) => { + const d = asObj(data) + return receipt(ok(), `Active account: ${displayName(d)}`, addressPairs(d)) + }) satisfies TextFormatter, + walletCurrent: ((data) => { + const d = asObj(data) + return titled(`Active account: ${displayName(d)}`, addressPairs(d)) + }) satisfies TextFormatter, + walletRename: ((data) => { + const d = asObj(data) + return receipt(ok(), "Renamed account", [ + ["Old label", String(d.previousLabel ?? "")], + ["New label", displayName(d)], + ]) + }) satisfies TextFormatter, + walletDerive: ((data) => { + const d = asObj(data) + return receipt(ok(), `Derived sub-account ${quote(displayName(d))}`, [ + ["Address", firstAddress(d)], + ["Active", d.active === true ? "yes" : ""], + ["Note", "shares master mnemonic; no separate backup needed"], + ]) + }) satisfies TextFormatter, + walletDelete: ((data) => { + const d = asObj(data) + const scope = d.scope === "account" ? "account" : "wallet" + return receipt(ok(), `Deleted ${scope} ${String(d.accountId ?? "")}`, [ + ["Secret removed", d.secretRemoved === false ? "no" : "yes"], + ["New active", d.newActive ? String(d.newActive) : ""], + ]) + }) satisfies TextFormatter, + walletBackup: ((data) => { + const d = asObj(data) + return [ + receipt(warn(), `Backup written ${String(d.out ?? "")}`, [ + ["Account ID", String(d.accountId ?? "")], + ["Secret", secretLabel(d.secretType)], + ["File mode", String(d.fileMode ?? "0600")], + ["Bytes", String(d.bytes ?? "?")], + ]), + "", + `${warn()} Secret material was written only to the backup file, never to stdout.`, + ].join("\n") + }) satisfies TextFormatter, +} + +function renderWalletCreated(verb: "Created" | "Imported", d: Obj, notes: string[]): string { + const existing = d.status === "existing" + const title = existing ? "Existing wallet" : `${verb} wallet` + const lines = [ + receipt(existing ? warn() : ok(), `${title} ${quote(displayName(d))}`, [ + ["Account ID", String(d.accountId ?? "")], + ["Type", typeLabel(d.type)], + ...addressPairs(d), + ["Active", d.active === true ? "yes" : ""], + ]), + ] + if (notes.length) lines.push("", ...notes.map((n) => `${warn()} ${n}`)) + return lines.join("\n") +} + +function renderLedgerImported(d: Obj): string { + const existing = d.status === "existing" + return [ + receipt(existing ? warn() : ok(), `${existing ? "Existing Ledger account" : "Registered Ledger account"} ${quote(displayName(d))}`, [ + ["Account ID", String(d.accountId ?? "")], + ["App", String(d.family ?? "")], + ["Path", String(d.path ?? "")], + ...addressPairs(d), + ]), + "", + `${warn()} No private key is stored locally. Signing requires device confirmation.`, + ].join("\n") +} + +/** Tree view: each seed wallet is a group headed by its seed id (the `--seed` handle for `derive`), + * its accounts listed under `├─/└─` connectors as `[index] label`. Non-HD accounts group by type. + * Plain text only — the text-mode frame is control-byte-stripped (CLI-OUT-001) so ANSI colour + * can't survive here anyway; the active account is marked with a trailing `(active)`. */ +function renderWalletList(items: Obj[]): string { + if (items.length === 0) return "No wallets found." + // group seeds by their seed id (wlt_x); non-HD accounts by type. Insertion order preserved. + const groups = new Map() + for (const d of items) { + const isSeed = d.type === "seed" + const seedId = String(d.accountId ?? "").split(".")[0] ?? "" + const key = isSeed ? `hd:${seedId}` : `type:${String(d.type)}` + const header = isSeed ? `HD ${seedId}` : typeLabel(d.type) + ;(groups.get(key) ?? groups.set(key, { hd: isSeed, header, rows: [] }).get(key)!).rows.push(d) + } + const leftOf = (d: Obj): string => (d.type === "seed" ? `[${d.index ?? "?"}] ${displayName(d)}` : displayName(d)) + const leftW = Math.max(...items.map((d) => leftOf(d).length)) + const addrW = Math.max(...items.map((d) => firstAddress(d).length)) + const row = (d: Obj, last: boolean): string => + `${last ? "└─ " : "├─ "}${leftOf(d).padEnd(leftW)} ${firstAddress(d).padEnd(addrW)} ${d.active ? "(active)" : ""}`.replace(/\s+$/, "") + const blocks: string[] = [] + for (const g of groups.values()) { + const rows = g.hd ? [...g.rows].sort((a, b) => Number(a.index) - Number(b.index)) : g.rows + blocks.push([g.header, ...rows.map((d, i) => row(d, i === rows.length - 1))].join("\n")) + } + return blocks.join("\n\n") +} + +function displayName(d: Obj): string { + return String(d.label ?? d.accountId ?? d.id ?? "unnamed") +} + +/** non-empty address entries — drops families whose address is blank/absent. */ +function nonEmptyAddressEntries(d: Obj): Pair[] { + return Object.entries(asObj(d.addresses)) + .filter(([, address]) => typeof address === "string" && address.length > 0) + .map(([family, address]) => [family, String(address)] as Pair) +} + +function firstAddress(d: Obj): string { + const first = nonEmptyAddressEntries(d)[0] + return first ? first[1] : "" +} + +/** per-family address field pairs, addresses shown in full (§0.4 ②). */ +function addressPairs(d: Obj): Pair[] { + return nonEmptyAddressEntries(d).map(([family, address]) => [familyAddressLabel(family), address] as Pair) +} + +function typeLabel(v: unknown): string { + return sourceLabel(v) +} + +function secretLabel(v: unknown): string { + switch (v) { + case "mnemonic": + return "recovery phrase" + case "privateKey": + return "private key" + default: + return String(v ?? "secret") + } +} From 4e84d37a5dec9bfd55b6f966738377a9ec7e310f Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Wed, 8 Jul 2026 19:30:54 +0800 Subject: [PATCH 03/16] feat(ts): add vote and reward commands --- .../adapters/inbound/cli/commands/reward.ts | 37 +++ ts/src/adapters/inbound/cli/commands/vote.ts | 68 +++++ .../adapters/inbound/cli/contracts/command.ts | 2 + ts/src/adapters/inbound/cli/help/index.ts | 7 +- ts/src/adapters/inbound/cli/output/index.ts | 24 +- ts/src/adapters/inbound/cli/render/index.ts | 6 + ts/src/adapters/inbound/cli/render/reward.ts | 40 +++ ts/src/adapters/inbound/cli/render/tx.ts | 39 ++- ts/src/adapters/inbound/cli/render/vote.ts | 56 ++++ ts/src/adapters/outbound/chain/tron/tron.ts | 105 +++++++ ts/src/adapters/outbound/config/builtins.ts | 5 + .../application/ports/chain/tron-gateway.ts | 31 ++ .../use-cases/tron/reward-service.test.ts | 102 +++++++ .../use-cases/tron/reward-service.ts | 80 ++++++ .../use-cases/tron/vote-service.test.ts | 95 +++++++ .../use-cases/tron/vote-service.ts | 268 ++++++++++++++++++ ts/src/bootstrap/families/tron.ts | 23 ++ ts/src/domain/types/tx.ts | 6 +- 18 files changed, 985 insertions(+), 9 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/reward.ts create mode 100644 ts/src/adapters/inbound/cli/commands/vote.ts create mode 100644 ts/src/adapters/inbound/cli/render/reward.ts create mode 100644 ts/src/adapters/inbound/cli/render/vote.ts create mode 100644 ts/src/application/use-cases/tron/reward-service.test.ts create mode 100644 ts/src/application/use-cases/tron/reward-service.ts create mode 100644 ts/src/application/use-cases/tron/vote-service.test.ts create mode 100644 ts/src/application/use-cases/tron/vote-service.ts diff --git a/ts/src/adapters/inbound/cli/commands/reward.ts b/ts/src/adapters/inbound/cli/commands/reward.ts new file mode 100644 index 00000000..97961b53 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/reward.ts @@ -0,0 +1,37 @@ +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 } from "./shared.js"; +import { TextFormatters } from "../render/index.js"; + +export const rewardBalanceSpec: ChainSpec = { + path: ["reward", "balance"], + network: "optional", wallet: "optional", auth: "none", + capability: "reward.balance", + summary: "Show claimable voting/block reward and withdraw status", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli reward balance" }], + formatText: TextFormatters.rewardBalance, +}; + +export const rewardBalanceTronBinding = (svc: TronRewardService): FamilyBinding => ({ + run: async (ctx, net) => svc.balance(ctx, net), +}); + +export const rewardWithdrawSpec: ChainSpec = { + path: ["reward", "withdraw"], + network: "optional", wallet: "optional", auth: "required", + broadcasts: true, + capability: "reward.withdraw", + summary: "Withdraw accrued voting/block rewards", + baseFields: z.object({ ...txModeFields }), + examples: [ + { cmd: "wallet-cli reward withdraw" }, + { cmd: "wallet-cli reward withdraw --wait" }, + ], + formatText: TextFormatters.txReceipt, +}; + +export const rewardWithdrawTronBinding = (svc: TronRewardService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.withdraw(ctx, net, input), +}); diff --git a/ts/src/adapters/inbound/cli/commands/vote.ts b/ts/src/adapters/inbound/cli/commands/vote.ts new file mode 100644 index 00000000..37950384 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/vote.ts @@ -0,0 +1,68 @@ +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 } from "./shared.js"; +import { TextFormatters } from "../render/index.js"; + +const voteForField = z.preprocess( + (value) => Array.isArray(value) + ? value.map(String) + : value === undefined + ? undefined + : [String(value)], + z.array(z.string().min(1)).min(1).max(30), +).describe("witness address = vote count, as SR=votes; repeatable; replaces all prior votes"); + +export const voteCastSpec: ChainSpec = { + path: ["vote", "cast"], + network: "optional", wallet: "optional", auth: "required", + broadcasts: true, + capability: "vote.cast", + summary: "Cast or replace your full SR vote allocation", + baseFields: z.object({ + for: voteForField, + ...txModeFields, + }), + examples: [{ cmd: "wallet-cli vote cast --for TZ4...=600 --for TT5...=400" }], + formatText: TextFormatters.txReceipt, +}; + +export const voteCastTronBinding = (svc: TronVoteService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.cast(ctx, net, input), +}); + +export const voteListSpec: ChainSpec = { + path: ["vote", "list"], + network: "optional", wallet: "none", auth: "none", + capability: "vote.list", + summary: "List super representatives and candidates", + baseFields: z.object({ + limit: z.coerce.number().int().positive().max(127).default(27) + .describe("number of ranks to return; max 127"), + candidates: z.boolean().default(false) + .describe("include non-elected candidates"), + }), + examples: [ + { cmd: "wallet-cli vote list" }, + { cmd: "wallet-cli vote list --candidates --limit 100" }, + ], + formatText: TextFormatters.voteList, +}; + +export const voteListTronBinding = (svc: TronVoteService): FamilyBinding => ({ + run: async (_ctx, net, input) => svc.list(net, input), +}); + +export const voteStatusSpec: ChainSpec = { + path: ["vote", "status"], + network: "optional", wallet: "optional", auth: "none", + capability: "vote.status", + summary: "Show current votes, voting power, and reward overview", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli vote status" }], + formatText: TextFormatters.voteStatus, +}; + +export const voteStatusTronBinding = (svc: TronVoteService): FamilyBinding => ({ + run: async (ctx, net) => svc.status(ctx, net), +}); diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index e8d4b64d..ad5f0da1 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -25,6 +25,8 @@ export interface TextRenderContext { net?: NetworkDescriptor; /** label of the resolved active account, injected centrally; absent for wallet:"none" commands. */ accountLabel?: string; + /** command-supplied non-fatal warnings, already captured in the result envelope meta. */ + warnings?: string[]; } export type TextFormatter = (data: O, ctx: TextRenderContext) => string | null; diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index f44e78a3..c43f0bef 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -103,6 +103,8 @@ export class HelpService { ["tx", "Build, send, broadcast, and inspect transactions", ""], ["contract", "Call, send, deploy, and inspect smart contracts", ""], ["stake", "Stake / delegate resources", "tron"], + ["vote", "Vote for super representatives", "tron"], + ["reward", "Query / withdraw voting rewards", "tron"], ["message", "Sign arbitrary messages", ""], ["block", "Get a block (latest if omitted)", ""], ] as const @@ -358,7 +360,8 @@ function metaPositionals(tokens: string[]): string[] { /** "--flag " header for a command flag — enum fields list their choices instead of . */ function flagHead(f: FieldInfo): string { - const typ = f.choices ? ` <${f.choices.join("|")}>` : f.baseType === "boolean" ? "" : ` <${f.baseType}>` + const typeName = f.baseType === "pipe" ? "string" : f.baseType + const typ = f.choices ? ` <${f.choices.join("|")}>` : typeName === "boolean" ? "" : ` <${typeName}>` return `--${f.kebab}${typ}` } @@ -408,6 +411,8 @@ const GROUP_DESCRIPTIONS: Record = { tx: "Build, send, broadcast, and inspect transactions.", contract: "Call, send, deploy, and inspect smart contracts.", stake: "Stake / delegate resources (TRON Stake 2.0).", + vote: "Vote for super representatives (SR).", + reward: "Query and withdraw voting/block rewards.", message: "Sign arbitrary messages.", block: "Get a block (latest if omitted).", } diff --git a/ts/src/adapters/inbound/cli/output/index.ts b/ts/src/adapters/inbound/cli/output/index.ts index 7b8c41a2..24f9afb6 100644 --- a/ts/src/adapters/inbound/cli/output/index.ts +++ b/ts/src/adapters/inbound/cli/output/index.ts @@ -17,6 +17,8 @@ import { OutputEnvelope, toJson } from "./envelope.js"; import { renderGenericText } from "../render/index.js"; import { sanitizeText } from "../render/scalars.js"; +const COMMAND_WARNINGS_KEY = "__walletCliWarnings"; + export interface OutputFormatter { /** the single result frame for the caller to hand to streams.result. */ success(command: string, net: NetworkDescriptor | undefined, data: unknown, formatText?: TextFormatter, accountLabel?: string): string; @@ -32,15 +34,16 @@ abstract class BaseOutputFormatter { protected readonly startedAt: number, ) {} - protected meta() { - return { durationMs: Date.now() - this.startedAt, warnings: this.streams.warnings() }; + protected meta(extraWarnings: string[] = []) { + return { durationMs: Date.now() - this.startedAt, warnings: [...this.streams.warnings(), ...extraWarnings] }; } } 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. - return toJson(OutputEnvelope.success(command, net, data, this.meta())); + const unwrapped = unwrapCommandWarnings(data); + return toJson(OutputEnvelope.success(command, net, unwrapped.data, this.meta(unwrapped.warnings))); } error(err: CliError, ctx?: { commandId?: string; net?: NetworkDescriptor }): void { @@ -57,8 +60,9 @@ class HumanOutputFormatter extends BaseOutputFormatter implements OutputFormatte // 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. success(command: string, net: NetworkDescriptor | undefined, data: unknown, formatText?: TextFormatter, accountLabel?: string): string { - const env = OutputEnvelope.success(command, net, data, this.meta()); - const custom = formatText?.(env.data, { command: env.command, net, accountLabel }); + const unwrapped = unwrapCommandWarnings(data); + const env = OutputEnvelope.success(command, net, unwrapped.data, this.meta(unwrapped.warnings)); + const custom = formatText?.(env.data, { command: env.command, net, accountLabel, warnings: env.meta.warnings }); return sanitizeText(custom ?? renderGenericText(env.command, net, env.data)); } @@ -71,6 +75,16 @@ class HumanOutputFormatter extends BaseOutputFormatter implements OutputFormatte } } +function unwrapCommandWarnings(data: unknown): { data: unknown; warnings: string[] } { + if (!data || typeof data !== "object" || Array.isArray(data)) return { data, warnings: [] }; + const record = data as Record; + const raw = record[COMMAND_WARNINGS_KEY]; + if (!Array.isArray(raw)) return { data, warnings: [] }; + const warnings = raw.filter((item): item is string => typeof item === "string"); + const { [COMMAND_WARNINGS_KEY]: _warnings, ...publicData } = record; + return { data: publicData, warnings }; +} + export function createOutputFormatter( output: OutputMode, streams: StreamManager, diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index 6a1b65ec..5771cd42 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -4,6 +4,8 @@ * 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 + * vote.ts — SR voting list/status views + * reward.ts — voting/block reward views * misc.ts — config, networks, contract call/info, message sign, block * This barrel reassembles the one TextFormatters table command specs import. */ @@ -13,6 +15,8 @@ import { type Obj, ok } from "./layout.js" import { WalletFormatters } from "./wallet.js" import { AccountFormatters } from "./account.js" import { TxFormatters } from "./tx.js" +import { VoteFormatters } from "./vote.js" +import { RewardFormatters } from "./reward.js" import { MiscFormatters } from "./misc.js" export { FAMILY_RENDER, renderFamily } from "./family.js" @@ -21,6 +25,8 @@ export const TextFormatters = { ...WalletFormatters, ...AccountFormatters, ...TxFormatters, + ...VoteFormatters, + ...RewardFormatters, ...MiscFormatters, } diff --git a/ts/src/adapters/inbound/cli/render/reward.ts b/ts/src/adapters/inbound/cli/render/reward.ts new file mode 100644 index 00000000..defcc192 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/reward.ts @@ -0,0 +1,40 @@ +import type { TextFormatter } from "../contracts/index.js" +import { formatSun } from "./scalars.js" +import { type Obj, asObj, query } from "./layout.js" + +export const RewardFormatters = { + rewardBalance: ((data, ctx) => { + const d = asObj(data) + return query([ + ctx.accountLabel ? ["Label", ctx.accountLabel] : ["Address", String(d.address ?? "")], + ["Claimable", `${formatSun(d.rewardSun)} TRX`], + ["Withdraw status", withdrawStatus(d)], + ]) + }) satisfies TextFormatter, +} + +function withdrawStatus(d: Obj): string { + if (d.withdrawableNow === true || d.withdrawableAt === null || d.withdrawableAt === undefined) { + return "available now" + } + const at = Number(d.withdrawableAt) + if (!Number.isFinite(at) || at <= 0) return "available now" + return `available from ${formatLocalMinute(at)} (${relativeFromNow(at)})` +} + +function formatLocalMinute(epochMs: number): string { + const d = new Date(epochMs) + const yyyy = String(d.getFullYear()) + const mm = String(d.getMonth() + 1).padStart(2, "0") + const dd = String(d.getDate()).padStart(2, "0") + const hh = String(d.getHours()).padStart(2, "0") + const mi = String(d.getMinutes()).padStart(2, "0") + return `${yyyy}-${mm}-${dd} ${hh}:${mi}` +} + +function relativeFromNow(epochMs: number): string { + const ms = Math.max(0, epochMs - Date.now()) + const hours = Math.ceil(ms / (60 * 60 * 1000)) + if (hours < 48) return `~${hours}h` + return `~${Math.ceil(hours / 24)}d` +} diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index b057776f..135edbff 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -52,7 +52,7 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { // submitted (default, non-blocking): txid only, no fee/energy yet — those need confirmation. if (stage === "submitted") { - pairs.push(["Status", "pending — not yet on-chain"]) + pairs.push(["Status", submittedStatus(r.kind)]) const body = receipt(pending(), summary, pairs) const networkFlag = ctx?.net ? ` --network ${ctx.net.id}` : "" return txid ? `${body}\n! Track it: wallet-cli tx info${networkFlag} --txid ${txid}` : body @@ -68,10 +68,32 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { if (r.result) pairs.push(["Reason", String(r.result)]) return receipt(fail(), summary, pairs) } - pairs.push(["Status", "success"]) + pairs.push(["Status", successStatus(r.kind)]) return receipt(ok(), summary, pairs) } +function submittedStatus(kind: TxReceiptKind): string { + switch (kind) { + case "vote-cast": + return "pending — tallied at next maintenance cycle (~6h)" + case "reward-withdraw": + return "pending — next withdrawal available in ~24h" + default: + return "pending — not yet on-chain" + } +} + +function successStatus(kind: TxReceiptKind): string { + switch (kind) { + case "vote-cast": + return "success — tallied at next maintenance cycle (~6h)" + case "reward-withdraw": + return "success — next withdrawal available in ~24h" + default: + return "success" + } +} + /** the verb-phrase summary for a broadcast receipt, by action kind. */ function receiptSummary(r: TxReceiptView, family: ChainFamily): string { const stakeAmt = r.amountSun !== undefined ? `${formatSun(r.amountSun)} TRX` : "TRX" @@ -93,6 +115,13 @@ function receiptSummary(r: TxReceiptView, family: ChainFamily): string { return `Called ${methodName(String(r.method ?? ""))}` case "contract-deploy": return "Contract deployed" + case "vote-cast": { + const total = r.totalVotes === undefined ? "" : `${formatInt(r.totalVotes)} TP` + const count = Array.isArray(r.votes) ? r.votes.length : 0 + return `Voted ${total || "TP"} across ${formatInt(count)} witness${count === 1 ? "" : "es"}` + } + case "reward-withdraw": + return "Withdrew voting/block rewards" case "send": { const amount = receiptAmount(r, family) return amount ? `Sent ${amount}` : "Sent" @@ -108,6 +137,8 @@ function receiptRows(r: TxReceiptView): Pair[] { if (r.kind === "stake-delegate") rows.push(["To", String(r.receiver ?? "")]) else if (r.kind === "stake-undelegate") rows.push(["From", String(r.receiver ?? "")]) else if (r.kind === "contract-deploy") rows.push(["Address", String(r.contractAddress ?? "")]) + else if (r.kind === "vote-cast" && Array.isArray(r.votes)) rows.push(["Votes", r.votes.map((vote) => `${vote.witness}=${formatInt(vote.count)}`).join(", ")]) + else if (r.kind === "reward-withdraw") rows.push(["Amount", `${formatSun(r.rewardSun ?? r.withdrawnSun ?? 0)} TRX`]) else if (r.to ?? r.receiver) rows.push(["To", String(r.to ?? r.receiver)]) if (r.kind === "contract-send") rows.push(["Contract", String(r.contract ?? "")]) return rows @@ -153,6 +184,10 @@ function actionLabel(kind: TxReceiptKind): string { return "contract send" case "contract-deploy": return "contract deploy" + case "vote-cast": + return "vote cast" + case "reward-withdraw": + return "reward withdraw" } } diff --git a/ts/src/adapters/inbound/cli/render/vote.ts b/ts/src/adapters/inbound/cli/render/vote.ts new file mode 100644 index 00000000..852a51dc --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/vote.ts @@ -0,0 +1,56 @@ +import type { TextFormatter, TextRenderContext } from "../contracts/index.js" +import { formatInt, formatSun } from "./scalars.js" +import { type Obj, asObj, query, table } from "./layout.js" + +export const VoteFormatters = { + voteList: ((data) => { + const witnesses = rowsOf(asObj(data).witnesses) + return table(["Rank", "Name", "Votes", "APR", "Reward ratio", "Address"], witnesses.map((w) => [ + formatInt(w.rank), + String(w.name ?? ""), + formatInt(w.voteCount), + pct(w.aprPct), + pct(w.rewardRatioPct), + String(w.address ?? ""), + ])) + }) satisfies TextFormatter, + voteStatus: ((data, ctx) => renderVoteStatus(asObj(data), ctx)) satisfies TextFormatter, +} + +function renderVoteStatus(data: Obj, ctx: TextRenderContext): string { + const votingPower = asObj(data.votingPower) + const votes = rowsOf(data.votes) + const lines = [ + query([ + ctx.accountLabel ? ["Label", ctx.accountLabel] : ["Address", String(data.address ?? "")], + ["Voting power", `${formatInt(votingPower.total)} TP (used ${formatInt(votingPower.used)} / available ${formatInt(votingPower.available)})`], + ["Claimable", `${formatSun(data.claimableRewardSun)} TRX`], + ]), + "", + `Current votes (${votes.length})`, + table(["Name", "Votes", "APR", "Reward ratio", "Address"], votes.map((vote) => [ + String(vote.name ?? ""), + formatInt(vote.count), + pct(vote.aprPct), + pct(vote.rewardRatioPct), + String(vote.witness ?? ""), + ])), + ] + for (const warning of ctx.warnings ?? []) { + if (warning.startsWith("zero_reward_ratio:")) { + lines.push(`! ${warning.replace(/^zero_reward_ratio:\s*/, "")}`) + } + } + return lines.join("\n") +} + +function rowsOf(value: unknown): Obj[] { + return (Array.isArray(value) ? value : []).map(asObj) +} + +function pct(value: unknown): string { + if (value === null || value === undefined) return "-" + const n = Number(value) + if (!Number.isFinite(n)) return "-" + return `${Number.isInteger(n) ? String(n) : String(n)}%` +} diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 8176c884..6c47b780 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -18,6 +18,8 @@ import type { TronTokenInfo, TronTx, TronTxInfo, + TronVote, + TronWitness, } from "../../../../application/ports/chain/tron-gateway.js"; import { ChainError, TransportError, UsageError } from "../../../../domain/errors/index.js"; import { redactErrorMessage } from "../../../../domain/errors/redact.js"; @@ -268,6 +270,64 @@ export class TronRpcClient implements TronGateway, Broadcaster { ); } + // ── voting / witnesses ───────────────────────────────────────────────────── + async buildVoteWitness(owner: string, votes: TronVote[]): Promise { + const voteInfo = Object.fromEntries( + votes.map((vote) => [vote.witness, this.#safeNumber(vote.count, "vote count")]), + ); + return this.#wrap("voteWitness", async () => + assertBuiltTx(await this.#tw.transactionBuilder.vote(voteInfo, owner), "VoteWitnessContract"), + ); + } + async buildWithdrawBalance(owner: string): Promise { + return this.#wrap("withdrawBlockRewards", async () => + assertBuiltTx(await this.#tw.transactionBuilder.withdrawBlockRewards(owner), "WithdrawBalanceContract"), + ); + } + async getWitnesses(limit: number): Promise { + const capped = Math.min(Math.max(Math.trunc(limit), 1), 127); + return this.#wrap("getNowWitnessList", async () => { + const response = await fetch(`${this.#fullHost}/walletsolidity/getpaginatednowwitnesslist`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ offset: 0, limit: capped, visible: true }), + signal: AbortSignal.timeout(this.#timeoutMs), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record; + const witnesses = Array.isArray(raw.witnesses) ? raw.witnesses : []; + return witnesses.map(normalizeWitness).filter((w): w is TronWitness => w !== null); + }); + } + async getBrokerage(address: string): Promise { + return this.#wrap("getBrokerage", async () => { + const response = await fetch(`${this.#fullHost}/walletsolidity/getBrokerage`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address: this.#tw.address.toHex(address) }), + signal: AbortSignal.timeout(this.#timeoutMs), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record; + if (raw.brokerage === undefined) throw new Error("brokerage not found"); + const brokerage = Number(raw.brokerage); + return Number.isFinite(brokerage) ? brokerage : 0; + }); + } + async getReward(address: string): Promise { + return this.#wrap("getReward", async () => { + const response = await fetch(`${this.#fullHost}/walletsolidity/getReward`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address: this.#tw.address.toHex(address) }), + signal: AbortSignal.timeout(this.#timeoutMs), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record; + return quantityString(raw.reward); + }); + } + // ── prices ───────────────────────────────────────────────────────────────────── async getEnergyPrices(): Promise { return this.#wrap("getEnergyPrices", () => this.#tw.trx.getEnergyPrices()); @@ -350,11 +410,56 @@ const ACCOUNT_QUANTITY_KEYS = new Set([ "balance", "frozen_amount", "frozen_balance", + "latest_withdraw_time", + "reward", "total_supply", "unfreeze_amount", "value", + "vote_count", + "voteCount", ]); +function quantityString(value: unknown): string { + if (typeof value === "bigint") return value >= 0n ? value.toString() : "0"; + if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? String(value) : "0"; + if (typeof value === "string" && /^\d+$/.test(value)) return value; + return "0"; +} + +function optionalNumber(value: unknown): number | undefined { + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} + +function decodeAsciiHex(value: unknown): string | undefined { + const raw = String(value ?? ""); + const hex = raw.replace(/^0x/, ""); + if (!hex || !/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) return raw || undefined; + try { + const text = Buffer.from(hex, "hex").toString("utf8").replace(/\0+$/, ""); + return text || undefined; + } catch { + return raw || undefined; + } +} + +function normalizeWitness(value: unknown): TronWitness | null { + if (!value || typeof value !== "object") return null; + const raw = value as Record; + const address = hexToBase58(raw.address); + if (!address) return null; + return { + address, + voteCount: quantityString(raw.voteCount ?? raw.vote_count), + url: decodeAsciiHex(raw.url), + totalProduced: optionalNumber(raw.totalProduced), + totalMissed: optionalNumber(raw.totalMissed), + latestBlockNum: optionalNumber(raw.latestBlockNum), + latestSlotNum: optionalNumber(raw.latestSlotNum), + isJobs: typeof raw.isJobs === "boolean" ? raw.isJobs : undefined, + }; +} + /** Parse node account JSON without first coercing 64-bit quantities through JS number. */ export function parseTronAccountResponse(text: string): TronAccount { return normalizeAccountValue(parseLosslessJson(text)) as TronAccount; diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index c497993a..d5c12753 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -26,6 +26,11 @@ export const CAP_SUMMARIES: Record = { "contract.deploy": "deploy a smart contract", "staking.freeze": "freeze/unfreeze (Stake 2.0)", "staking.delegate": "delegate/undelegate resource (Stake 2.0)", + "vote.cast": "cast/replace SR votes", + "vote.list": "list super representatives", + "vote.status": "current SR votes and voting power", + "reward.balance": "claimable voting/block reward", + "reward.withdraw": "withdraw voting/block rewards", } export const BUILTIN_NETWORKS: Record = { diff --git a/ts/src/application/ports/chain/tron-gateway.ts b/ts/src/application/ports/chain/tron-gateway.ts index 8f728564..6b3a599a 100644 --- a/ts/src/application/ports/chain/tron-gateway.ts +++ b/ts/src/application/ports/chain/tron-gateway.ts @@ -30,6 +30,14 @@ export interface TronFrozenBalance { [key: string]: unknown; } +export interface TronVoteAllocation { + vote_address?: unknown; + voteAddress?: unknown; + vote_count?: unknown; + voteCount?: unknown; + [key: string]: unknown; +} + /** Account payload normalized at the adapter boundary; all SUN/token quantities are strings. */ export interface TronAccount { balance?: string; @@ -39,9 +47,27 @@ export interface TronAccount { frozen?: TronFrozenBalance[]; frozenV2?: TronFrozenBalance[]; unfrozenV2?: TronFrozenBalance[]; + votes?: TronVoteAllocation[]; + [key: string]: unknown; +} + +export interface TronWitness { + address: string; + voteCount: string; + url?: string; + totalProduced?: number; + totalMissed?: number; + latestBlockNum?: number; + latestSlotNum?: number; + isJobs?: boolean; [key: string]: unknown; } +export interface TronVote { + witness: string; + count: string; +} + export interface TronTokenInfo { contract?: string; name?: unknown; @@ -145,6 +171,11 @@ export interface TronGateway extends Broadcaster { resource: RpcResourceCode, receiver: string, ): Promise; + buildVoteWitness(owner: string, votes: TronVote[]): Promise; + buildWithdrawBalance(owner: string): Promise; + getWitnesses(limit: number): Promise; + getBrokerage(address: string): Promise; + getReward(address: string): Promise; triggerConstantContract( contract: string, method: string, diff --git a/ts/src/application/use-cases/tron/reward-service.test.ts b/ts/src/application/use-cases/tron/reward-service.test.ts new file mode 100644 index 00000000..7a5316e9 --- /dev/null +++ b/ts/src/application/use-cases/tron/reward-service.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { TronRewardService } from "./reward-service.js"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; + +const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] }; +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; + +const scope: TransactionScope = { + activeAccount: "wlt_test.0", + resolveAddress: () => OWNER, + timeoutMs: 60_000, + wait: false, + waitTimeoutMs: 60_000, + emit: () => {}, + warn: () => {}, +}; + +function service(gateway: Partial, pipeline?: Partial, now = 1_700_000_000_000) { + const g = gateway as TronGateway; + return new TronRewardService( + { get: () => g } as unknown as ChainGatewayProvider, + { run: async () => ({ stage: "submitted", txId: "txid" }), ...pipeline } as unknown as TxPipeline, + () => now, + ); +} + +describe("TronRewardService.balance", () => { + it("returns claimable reward and available-now status for first withdrawal", async () => { + const svc = service({ + getReward: async () => "123456789", + getAccount: async () => ({}), + }); + await expect(svc.balance(scope, NET)).resolves.toEqual({ + address: OWNER, + rewardSun: "123456789", + withdrawableNow: true, + withdrawableAt: null, + }); + }); + + it("returns the next withdrawable timestamp inside the 24h interval", async () => { + const now = 1_700_000_000_000; + const latest = String(now - 6 * 60 * 60 * 1000); + const svc = service({ + getReward: async () => "1", + getAccount: async () => ({ latest_withdraw_time: latest }), + }, undefined, now); + await expect(svc.balance(scope, NET)).resolves.toMatchObject({ + withdrawableNow: false, + withdrawableAt: Number(latest) + 24 * 60 * 60 * 1000, + }); + }); +}); + +describe("TronRewardService.withdraw", () => { + it("rejects when no reward is claimable", async () => { + const svc = service({ + getReward: async () => "0", + getAccount: async () => ({}), + }); + await expect(svc.withdraw(scope, NET, {})).rejects.toMatchObject({ code: "no_reward" }); + }); + + it("rejects inside the 24h withdraw interval", async () => { + const now = 1_700_000_000_000; + const svc = service({ + getReward: async () => "10", + getAccount: async () => ({ latest_withdraw_time: String(now - 1_000) }), + }, undefined, now); + await expect(svc.withdraw(scope, NET, {})).rejects.toMatchObject({ code: "withdraw_too_frequent" }); + }); + + it("echoes submitted rewardSun and confirmed withdrawnSun as rewardSun", async () => { + const baseGateway = { + getReward: async () => "10", + getAccount: async () => ({}), + buildWithdrawBalance: async () => ({}), + }; + const submitted = service(baseGateway).withdraw(scope, NET, {}); + await expect(submitted).resolves.toMatchObject({ + kind: "reward-withdraw", + stage: "submitted", + txId: "txid", + rewardSun: "10", + }); + + const confirmed = service(baseGateway, { + run: async () => ({ stage: "confirmed", txId: "txid", withdrawnSun: "12" }), + }).withdraw(scope, NET, {}); + await expect(confirmed).resolves.toMatchObject({ + kind: "reward-withdraw", + stage: "confirmed", + txId: "txid", + withdrawnSun: "12", + rewardSun: "12", + }); + }); +}); diff --git a/ts/src/application/use-cases/tron/reward-service.ts b/ts/src/application/use-cases/tron/reward-service.ts new file mode 100644 index 00000000..10f9404a --- /dev/null +++ b/ts/src/application/use-cases/tron/reward-service.ts @@ -0,0 +1,80 @@ +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import { UsageError } from "../../../domain/errors/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronAccount } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; +import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; +import { tronConfirmation } from "../../services/tron-confirmation.js"; + +const WITHDRAW_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export class TronRewardService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly pipeline: TxPipeline, + private readonly now: () => number = () => Date.now(), + ) {} + + async balance(scope: Pick, network: NetworkDescriptor) { + const gateway = this.gateways.get(network, "tron"); + const address = scope.resolveAddress("tron"); + const [rewardSun, account] = await Promise.all([ + gateway.getReward(address), + gateway.getAccount(address), + ]); + return { + address, + rewardSun, + ...withdrawStatus(account, this.now()), + }; + } + + async withdraw(scope: TransactionScope, network: NetworkDescriptor, input: TransactionModeInput) { + const gateway = this.gateways.get(network, "tron"); + const address = scope.resolveAddress("tron"); + const [rewardSun, account] = await Promise.all([ + gateway.getReward(address), + gateway.getAccount(address), + ]); + if (toUnsignedBigInt(rewardSun) === 0n) { + throw new UsageError("no_reward", "no voting/block reward is currently claimable"); + } + const status = withdrawStatus(account, this.now()); + if (!status.withdrawableNow) { + throw new UsageError("withdraw_too_frequent", `reward can be withdrawn after ${new Date(status.withdrawableAt!).toISOString()}`); + } + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...transactionMode(input), + confirm: tronConfirmation(gateway, scope), + build: (owner) => gateway.buildWithdrawBalance(owner), + estimate: async () => ({ feeModel: "tron-resource", note: "reward withdrawal uses bandwidth only" }), + }); + const data = outcomeData(outcome); + return { + kind: "reward-withdraw" as const, + ...data, + rewardSun: String(data.withdrawnSun ?? rewardSun), + }; + } +} + +function withdrawStatus(account: TronAccount, now: number): { withdrawableNow: boolean; withdrawableAt: number | null } { + const latest = toUnsignedBigInt(account.latest_withdraw_time); + if (latest === 0n) return { withdrawableNow: true, withdrawableAt: null }; + const at = Number(latest) + WITHDRAW_INTERVAL_MS; + return at <= now + ? { withdrawableNow: true, withdrawableAt: null } + : { withdrawableNow: false, withdrawableAt: at }; +} + +function toUnsignedBigInt(value: unknown): bigint { + if (typeof value === "bigint") return value >= 0n ? value : 0n; + if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : 0n; + if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value); + return 0n; +} diff --git a/ts/src/application/use-cases/tron/vote-service.test.ts b/ts/src/application/use-cases/tron/vote-service.test.ts new file mode 100644 index 00000000..39695298 --- /dev/null +++ b/ts/src/application/use-cases/tron/vote-service.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { TronVoteService } from "./vote-service.js"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; + +const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] }; +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const SR1 = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; +const SR2 = "TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g"; + +const scope: TransactionScope = { + activeAccount: "wlt_test.0", + resolveAddress: () => OWNER, + timeoutMs: 60_000, + wait: false, + waitTimeoutMs: 60_000, + emit: () => {}, + warn: () => {}, +}; + +function service(gateway: Partial, pipeline?: Partial) { + const g = gateway as TronGateway; + return new TronVoteService( + { get: () => g } as unknown as ChainGatewayProvider, + { run: async () => ({ stage: "submitted", txId: "txid" }), ...pipeline } as unknown as TxPipeline, + ); +} + +describe("TronVoteService.cast", () => { + it("rejects a full vote allocation above voting power", async () => { + const svc = service({ + getAccount: async () => ({ frozenV2: [{ amount: "100000000" }] }), + }); + await expect(svc.cast(scope, NET, { for: [`${SR1}=101`] })).rejects.toMatchObject({ + code: "insufficient_voting_power", + }); + }); + + it("echoes parsed votes and total votes after pipeline submission", async () => { + const svc = service({ + getAccount: async () => ({ frozenV2: [{ amount: "1000000000" }] }), + buildVoteWitness: async () => ({}), + }); + await expect(svc.cast(scope, NET, { for: [`${SR1}=6`, `${SR2}=4`] })).resolves.toMatchObject({ + kind: "vote-cast", + stage: "submitted", + txId: "txid", + totalVotes: 10, + votes: [ + { witness: SR1, count: 6 }, + { witness: SR2, count: 4 }, + ], + }); + }); +}); + +describe("TronVoteService.list/status", () => { + it("sorts witnesses by vote count and converts brokerage to reward ratio", async () => { + const svc = service({ + getWitnesses: async () => [ + { address: SR1, voteCount: "100", url: "https://alpha.example" }, + { address: SR2, voteCount: "200", url: "https://beta.example" }, + ], + getBrokerage: async (address) => address === SR2 ? 100 : 20, + }); + await expect(svc.list(NET, { limit: 2 })).resolves.toEqual({ + witnesses: [ + { rank: 1, name: "beta.example", address: SR2, voteCount: "200", rewardRatioPct: 0, brokeragePct: 100, aprPct: null }, + { rank: 2, name: "alpha.example", address: SR1, voteCount: "100", rewardRatioPct: 80, brokeragePct: 20, aprPct: null }, + ], + }); + }); + + it("reports current voting power, reward, votes, and zero-ratio warning", async () => { + const svc = service({ + getAccount: async () => ({ + frozenV2: [{ amount: "1500000000" }], + votes: [{ vote_address: SR2, vote_count: "400" }], + }), + getReward: async () => "12345678", + getWitnesses: async () => [{ address: SR2, voteCount: "200", url: "https://beta.example" }], + getBrokerage: async () => 100, + }); + await expect(svc.status(scope, NET)).resolves.toMatchObject({ + address: OWNER, + votingPower: { total: 1500, used: 400, available: 1100 }, + claimableRewardSun: "12345678", + votes: [{ witness: SR2, name: "beta.example", count: 400, rewardRatioPct: 0, brokeragePct: 100, aprPct: null }], + __walletCliWarnings: [`zero_reward_ratio: 400 votes on ${SR2} (beta.example) earn nothing: reward ratio is 0%`], + }); + }); +}); diff --git a/ts/src/application/use-cases/tron/vote-service.ts b/ts/src/application/use-cases/tron/vote-service.ts new file mode 100644 index 00000000..7a65fd48 --- /dev/null +++ b/ts/src/application/use-cases/tron/vote-service.ts @@ -0,0 +1,268 @@ +import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index.js"; +import { UsageError } from "../../../domain/errors/index.js"; +import { addressCodec } from "../../../domain/family/index.js"; +import { tronHexToBase58 } from "../../../domain/address/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { + TronAccount, + TronFrozenBalance, + TronGateway, + TronVote, + TronVoteAllocation, + TronWitness, +} from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; +import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; +import { tronConfirmation } from "../../services/tron-confirmation.js"; + +const MAX_VOTE_ITEMS = 30; +const MAX_REWARDED_RANK = 127; +const ELECTED_SR_COUNT = 27; +const SUN_PER_TP = 1_000_000n; +const COMMAND_WARNINGS_KEY = "__walletCliWarnings"; + +export interface VoteCastInput extends TransactionModeInput { + for: string[]; +} + +export interface VoteListInput { + limit: number; + candidates?: boolean; +} + +interface VoteReadScope { + readonly activeAccount: TransactionScope["activeAccount"]; + resolveAddress(family: "tron"): string; +} + +interface WitnessView { + rank: number; + name: string; + address: string; + voteCount: string; + rewardRatioPct: number | null; + brokeragePct: number | null; + aprPct: number | null; +} + +interface CurrentVoteView { + witness: string; + name: string; + count: number; + rewardRatioPct: number | null; + brokeragePct: number | null; + aprPct: number | null; +} + +export class TronVoteService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly pipeline: TxPipeline, + ) {} + + async cast(scope: TransactionScope, network: NetworkDescriptor, input: VoteCastInput) { + const gateway = this.gateways.get(network, "tron"); + const votes = parseVoteInputs(input.for); + const totalVotes = votes.reduce((sum, vote) => sum + BigInt(vote.count), 0n); + if (totalVotes > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new UsageError("invalid_value", "total votes exceed the safe-integer limit for this client"); + } + const owner = scope.resolveAddress("tron"); + const account = await gateway.getAccount(owner); + const votingPower = votingPowerOf(account); + if (totalVotes > votingPower.total) { + throw new UsageError( + "insufficient_voting_power", + `total votes ${totalVotes.toString()} exceed voting power ${votingPower.total.toString()} TP`, + ); + } + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...transactionMode(input), + confirm: tronConfirmation(gateway, scope), + build: (ownerAddress) => gateway.buildVoteWitness(ownerAddress, votes), + estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "voting uses bandwidth only" }), + }); + return { + kind: "vote-cast" as const, + ...outcomeData(outcome), + votes: votes.map((vote) => ({ witness: vote.witness, count: Number(vote.count) })), + totalVotes: Number(totalVotes), + }; + } + + async list(network: NetworkDescriptor, input: VoteListInput) { + const gateway = this.gateways.get(network, "tron"); + const limit = Math.min(input.limit, input.candidates ? MAX_REWARDED_RANK : ELECTED_SR_COUNT); + const witnesses = (await gateway.getWitnesses(limit)) + .sort((a, b) => compareUnsigned(b.voteCount, a.voteCount)) + .slice(0, limit); + return { + witnesses: await Promise.all(witnesses.map((witness, index) => + this.witnessView(gateway, witness, index + 1), + )), + }; + } + + async status(scope: VoteReadScope, network: NetworkDescriptor) { + const gateway = this.gateways.get(network, "tron"); + const address = scope.resolveAddress("tron"); + const [account, claimableRewardSun, witnesses] = await Promise.all([ + gateway.getAccount(address), + gateway.getReward(address), + gateway.getWitnesses(MAX_REWARDED_RANK).catch((): TronWitness[] => []), + ]); + const witnessMap = new Map(witnesses.map((witness, index) => [witness.address, { witness, rank: index + 1 }])); + const currentVotes = normalizeAccountVotes(account); + const votingPower = votingPowerOf(account); + const used = currentVotes.reduce((sum, vote) => sum + BigInt(vote.count), 0n); + const votes = await Promise.all(currentVotes.map(async (vote): Promise => { + const known = witnessMap.get(vote.witness); + const brokeragePct = await gateway.getBrokerage(vote.witness).catch(() => null); + return { + witness: vote.witness, + name: known ? witnessName(known.witness) : vote.witness, + count: Number(vote.count), + brokeragePct, + rewardRatioPct: rewardRatioOf(brokeragePct), + aprPct: null, + }; + })); + const warnings = zeroRewardWarnings(votes); + return { + address, + votingPower: { + total: Number(votingPower.total), + used: Number(used), + available: Number(votingPower.total > used ? votingPower.total - used : 0n), + }, + claimableRewardSun, + votes, + ...(warnings.length ? { [COMMAND_WARNINGS_KEY]: warnings } : {}), + }; + } + + private async witnessView(gateway: TronGateway, witness: TronWitness, rank: number): Promise { + const brokeragePct = await gateway.getBrokerage(witness.address).catch(() => null); + return { + rank, + name: witnessName(witness), + address: witness.address, + voteCount: witness.voteCount, + rewardRatioPct: rewardRatioOf(brokeragePct), + brokeragePct, + aprPct: null, + }; + } +} + +function parseVoteInputs(values: string[]): TronVote[] { + if (values.length === 0) throw new UsageError("invalid_value", "--for must include at least one SR=votes entry"); + if (values.length > MAX_VOTE_ITEMS) throw new UsageError("invalid_value", "--for accepts at most 30 entries"); + const codec = addressCodec("tron"); + const seen = new Set(); + return values.map((entry) => { + const separator = entry.lastIndexOf("="); + if (separator <= 0 || separator === entry.length - 1) { + throw new UsageError("invalid_value", `invalid --for entry '${entry}'; expected SR=votes`); + } + const witness = entry.slice(0, separator).trim(); + const count = entry.slice(separator + 1).trim(); + if (!codec.validate(witness)) throw new UsageError("invalid_value", `invalid witness address: ${witness}`); + if (seen.has(witness)) throw new UsageError("invalid_value", `duplicate witness address: ${witness}`); + seen.add(witness); + if (!/^\d+$/.test(count) || /^0+$/.test(count)) { + throw new UsageError("invalid_value", `vote count for ${witness} must be a positive integer`); + } + if (BigInt(count) > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new UsageError("invalid_value", `vote count for ${witness} exceeds the safe-integer limit for this client`); + } + return { witness, count }; + }); +} + +function votingPowerOf(account: TronAccount): { total: bigint } { + let totalSun = 0n; + for (const frozen of account.frozen ?? []) totalSun += quantity(frozen.frozen_balance); + const resource = objectOf(account.account_resource); + totalSun += nestedQuantity(resource.frozen_balance_for_energy, "frozen_balance"); + totalSun += quantity(account.delegated_frozen_balance_for_bandwidth); + totalSun += quantity(resource.delegated_frozen_balance_for_energy); + for (const frozen of account.frozenV2 ?? []) { + if (!isTronPowerOnlyStake(frozen)) totalSun += quantity(frozen.amount); + } + totalSun += quantity(account.delegated_frozenV2_balance_for_bandwidth); + totalSun += quantity(resource.delegated_frozenV2_balance_for_energy); + return { total: totalSun / SUN_PER_TP }; +} + +function isTronPowerOnlyStake(frozen: TronFrozenBalance): boolean { + const type = String(frozen.type ?? "").toUpperCase(); + return type === "TRON_POWER" || type === "2"; +} + +function normalizeAccountVotes(account: TronAccount): TronVote[] { + return (account.votes ?? []) + .map(normalizeAccountVote) + .filter((vote): vote is TronVote => vote !== null); +} + +function normalizeAccountVote(vote: TronVoteAllocation): TronVote | null { + const witness = tronHexToBase58(vote.vote_address ?? vote.voteAddress); + const count = quantityString(vote.vote_count ?? vote.voteCount); + if (!witness || count === "0") return null; + return { witness, count }; +} + +function zeroRewardWarnings(votes: CurrentVoteView[]): string[] { + return votes + .filter((vote) => vote.rewardRatioPct === 0) + .map((vote) => `zero_reward_ratio: ${vote.count} votes on ${vote.witness} (${vote.name}) earn nothing: reward ratio is 0%`); +} + +function rewardRatioOf(brokeragePct: number | null): number | null { + if (brokeragePct === null || !Number.isFinite(brokeragePct)) return null; + return Math.max(0, Math.min(100, 100 - brokeragePct)); +} + +function witnessName(witness: TronWitness): string { + const url = witness.url?.trim(); + if (!url) return witness.address; + try { + const parsed = new URL(url); + return parsed.hostname || url; + } catch { + return url.replace(/^https?:\/\//i, "").replace(/\/+$/, "") || witness.address; + } +} + +function compareUnsigned(a: unknown, b: unknown): number { + const left = quantity(a); + const right = quantity(b); + if (left === right) return 0; + return left > right ? 1 : -1; +} + +function quantityString(value: unknown): string { + const amount = quantity(value); + return amount.toString(); +} + +function quantity(value: unknown): bigint { + if (typeof value === "bigint") return value >= 0n ? value : 0n; + if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : 0n; + if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value); + return 0n; +} + +function nestedQuantity(value: unknown, key: string): bigint { + return quantity(objectOf(value)[key]); +} + +function objectOf(value: unknown): Record { + return value && typeof value === "object" ? value as Record : {}; +} diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 80bd7ad9..062544e5 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -37,6 +37,20 @@ import { txStatusTronBinding, } from "../../adapters/inbound/cli/commands/tx.js"; import { stakeDefinitions } from "../../adapters/inbound/cli/commands/stake.js"; +import { + voteCastSpec, + voteCastTronBinding, + voteListSpec, + voteListTronBinding, + voteStatusSpec, + voteStatusTronBinding, +} from "../../adapters/inbound/cli/commands/vote.js"; +import { + rewardBalanceSpec, + rewardBalanceTronBinding, + rewardWithdrawSpec, + rewardWithdrawTronBinding, +} from "../../adapters/inbound/cli/commands/reward.js"; import { contractCallSpec, contractCallTronBinding, @@ -53,6 +67,8 @@ import { TronTokenService } from "../../application/use-cases/tron/token-service import { TronTransactionService } from "../../application/use-cases/tron/transaction-service.js"; import { TronContractService } from "../../application/use-cases/tron/contract-service.js"; import { TronStakeService } from "../../application/use-cases/tron/stake-service.js"; +import { TronVoteService } from "../../application/use-cases/tron/vote-service.js"; +import { TronRewardService } from "../../application/use-cases/tron/reward-service.js"; import { TronBlockService } from "../../application/use-cases/tron/block-service.js"; import { MessageService } from "../../application/use-cases/message-service.js"; import type { ChainGatewayProvider } from "../../application/ports/chain/gateway-provider.js"; @@ -88,6 +104,8 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC const message = new MessageService(deps.signers); const transaction = new TronTransactionService(deps.gateways, deps.tokens, deps.transactions); const stake = new TronStakeService(deps.gateways, deps.transactions); + const vote = new TronVoteService(deps.gateways, deps.transactions); + const reward = new TronRewardService(deps.gateways, deps.transactions); const contract = new TronContractService(deps.gateways, deps.transactions); reg.addChain(blockSpec, "tron", blockTronBinding(new TronBlockService(deps.gateways))); @@ -108,6 +126,11 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC for (const definition of stakeDefinitions(stake)) { reg.addChain(definition.spec, "tron", definition.binding); } + reg.addChain(voteCastSpec, "tron", voteCastTronBinding(vote)); + reg.addChain(voteListSpec, "tron", voteListTronBinding(vote)); + reg.addChain(voteStatusSpec, "tron", voteStatusTronBinding(vote)); + reg.addChain(rewardBalanceSpec, "tron", rewardBalanceTronBinding(reward)); + reg.addChain(rewardWithdrawSpec, "tron", rewardWithdrawTronBinding(reward)); reg.addChain(contractCallSpec, "tron", contractCallTronBinding(contract)); reg.addChain(contractSendSpec, "tron", contractSendTronBinding(contract)); reg.addChain(contractDeploySpec, "tron", contractDeployTronBinding(contract)); diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index 193819e2..df9a76de 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -54,7 +54,8 @@ export interface TxParties { from?: string; to?: string; amount?: string; symbol export type TxReceiptKind = | "send" | "broadcast" | "stake-freeze" | "stake-unfreeze" | "stake-delegate" | "stake-undelegate" | "stake-withdraw" | "stake-cancel" - | "contract-send" | "contract-deploy"; + | "contract-send" | "contract-deploy" + | "vote-cast" | "reward-withdraw"; /** * Canonical tx receipt the signing commands return (dry-run / sign-only / broadcast stages). @@ -82,6 +83,9 @@ export interface TxReceiptView { to?: string; receiver?: string; resource?: string; + votes?: Array<{ witness: string; count: number }>; + totalVotes?: number; + rewardSun?: string | number; // contract method?: string; contractAddress?: string; From 0ac4d74bed808d885f9507e767844d87a45d626c Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 9 Jul 2026 18:45:54 +0800 Subject: [PATCH 04/16] =?UTF-8?q?feat(ts):=20v0.1.1=20=E2=80=94=20stake/ch?= =?UTF-8?q?ain=20queries,=20change-password,=20waitTimeoutMs,=20vote/rewar?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steven's v0.1.1 slice plus Leon's vote/reward (#949), rectified to repo conventions: - config: waitTimeoutMs (configurable --wait-timeout default) - gateway: TRON stake/chain read RPCs; reads + tx confirmation via the full node (fast --wait ~3s instead of ~60s solidity lag; fresh, consistent reads) - stake: info / delegated queries + public votingPower() - chain: params / prices / node - change-password + import mnemonic/private-key: TTY-only secret entry (no --*-stdin) - vote: cast / list / status ; reward: balance / withdraw (rectified: reuse stake.votingPower via injected TronStakeService, scope.warn instead of a __walletCliWarnings data side-channel, shared scalars.formatAtWithRelative, bounded/cached brokerage fan-out, pct & receipt-summary cleanups) - arity: first-class array flags (--for) via yargs array:true — no preprocess/pipe patch - render: preserve fractional TRX; chain untagged (generic, not TRON-exclusive) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adapters/inbound/cli/arity/arity.test.ts | 30 +++++ ts/src/adapters/inbound/cli/arity/index.ts | 23 +++- ts/src/adapters/inbound/cli/commands/chain.ts | 44 +++++++ .../cli/commands/change-password.test.ts | 60 +++++++++ ts/src/adapters/inbound/cli/commands/stake.ts | 38 ++++++ .../cli/commands/text-formatters.test.ts | 17 +++ ts/src/adapters/inbound/cli/commands/vote.ts | 12 +- .../adapters/inbound/cli/commands/wallet.ts | 68 +++++++++- ts/src/adapters/inbound/cli/context/index.ts | 2 +- .../adapters/inbound/cli/contracts/command.ts | 13 +- ts/src/adapters/inbound/cli/globals/index.ts | 8 +- ts/src/adapters/inbound/cli/help/index.ts | 21 ++-- .../inbound/cli/input/secret/index.ts | 10 +- .../inbound/cli/input/secret/secret.test.ts | 18 ++- ts/src/adapters/inbound/cli/output/index.ts | 24 +--- ts/src/adapters/inbound/cli/render/chain.ts | 70 +++++++++++ ts/src/adapters/inbound/cli/render/index.ts | 6 + ts/src/adapters/inbound/cli/render/reward.ts | 21 +--- ts/src/adapters/inbound/cli/render/scalars.ts | 26 ++++ ts/src/adapters/inbound/cli/render/stake.ts | 54 ++++++++ ts/src/adapters/inbound/cli/render/tx.ts | 4 +- ts/src/adapters/inbound/cli/render/vote.ts | 14 ++- ts/src/adapters/inbound/cli/render/wallet.ts | 8 ++ ts/src/adapters/inbound/cli/shell/index.ts | 12 ++ ts/src/adapters/outbound/chain/tron/tron.ts | 62 +++++++++- ts/src/adapters/outbound/config/builtins.ts | 1 + ts/src/adapters/outbound/config/index.ts | 4 +- ts/src/adapters/outbound/keystore/index.ts | 49 +++++++- .../outbound/keystore/keystore.test.ts | 51 ++++++++ .../adapters/outbound/persistence/fs/index.ts | 18 +++ .../application/ports/chain/tron-gateway.ts | 29 +++++ ts/src/application/ports/wallet-repository.ts | 2 +- .../use-cases/config-service.test.ts | 28 ++++- .../application/use-cases/config-service.ts | 12 +- .../use-cases/tron/chain-service.test.ts | 74 +++++++++++ .../use-cases/tron/chain-service.ts | 71 +++++++++++ .../tron/stake-service.query.test.ts | 88 +++++++++++++ .../use-cases/tron/stake-service.ts | 112 ++++++++++++++++- .../use-cases/tron/vote-service.test.ts | 30 +++-- .../use-cases/tron/vote-service.ts | 116 +++++++++--------- .../application/use-cases/wallet-service.ts | 4 + ts/src/bootstrap/families/tron.ts | 8 +- ts/src/bootstrap/runner.test.ts | 5 +- ts/src/domain/types/network.ts | 2 + ts/test/golden.test.ts | 26 +++- 45 files changed, 1215 insertions(+), 180 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/chain.ts create mode 100644 ts/src/adapters/inbound/cli/commands/change-password.test.ts create mode 100644 ts/src/adapters/inbound/cli/render/chain.ts create mode 100644 ts/src/adapters/inbound/cli/render/stake.ts create mode 100644 ts/src/application/use-cases/tron/chain-service.test.ts create mode 100644 ts/src/application/use-cases/tron/chain-service.ts create mode 100644 ts/src/application/use-cases/tron/stake-service.query.test.ts diff --git a/ts/src/adapters/inbound/cli/arity/arity.test.ts b/ts/src/adapters/inbound/cli/arity/arity.test.ts index a7c6ee69..9e1b3b19 100644 --- a/ts/src/adapters/inbound/cli/arity/arity.test.ts +++ b/ts/src/adapters/inbound/cli/arity/arity.test.ts @@ -60,3 +60,33 @@ describe("introspectFields — defaults & choices", () => { expect(by("to").choices).toBeUndefined(); }); }); + +describe("introspectFields — baseType & array fields", () => { + const fields = introspectFields( + z.object({ + to: z.string().describe("a string"), + flag: z.boolean().describe("a switch"), + resource: ciEnum(["energy", "bandwidth"]).describe("an enum via ciEnum's preprocess pipe"), + for: z.array(z.string().min(1)).min(1).max(30).describe("a repeatable string flag"), + }), + ); + const by = (name: string) => fields.find((f) => f.name === name)!; + + it("reports a repeatable array field as its per-entry element type, and marks isArray", () => { + expect(by("for").baseType).toBe("string"); + expect(by("for").isArray).toBe(true); + }); + + it("reports scalars as their own type and not an array", () => { + expect(by("to").baseType).toBe("string"); + expect(by("to").isArray).toBe(false); + expect(by("flag").baseType).toBe("boolean"); + expect(by("flag").isArray).toBe(false); + }); + + it("never leaks the internal 'pipe' type — a ciEnum resolves past the preprocess pipe", () => { + expect(by("resource").baseType).not.toBe("pipe"); + expect(by("resource").isArray).toBe(false); + expect(by("resource").choices).toEqual(["energy", "bandwidth"]); + }); +}); diff --git a/ts/src/adapters/inbound/cli/arity/index.ts b/ts/src/adapters/inbound/cli/arity/index.ts index c9291e4b..fbb8da37 100644 --- a/ts/src/adapters/inbound/cli/arity/index.ts +++ b/ts/src/adapters/inbound/cli/arity/index.ts @@ -34,6 +34,8 @@ 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`. */ + isArray: boolean; optional: boolean; hasDefault: boolean; /** the default value (when hasDefault) — surfaced verbatim in --help. */ @@ -66,6 +68,23 @@ function unwrap(schema: ZodType): { base: ZodType; optional: boolean; hasDefault 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". */ +function displayBaseType(base: ZodType): string { + let def: any = (base as any)?.def; + if (def?.type === "pipe") def = def.out?.def; + if (def?.type === "array") def = def.element?.def; + 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; + return def?.type === "array"; +} + export function introspectFields(fields: ZodObject): FieldInfo[] { const shape = fields.shape; return Object.entries(shape).map(([name, schema]) => { @@ -73,7 +92,8 @@ export function introspectFields(fields: ZodObject): FieldInfo[] { return { name, kebab: camelToKebab(name), - baseType: (base as any)?.def?.type ?? "unknown", + baseType: displayBaseType(base), + isArray: isArrayField(base), optional, hasDefault, defaultValue, @@ -98,6 +118,7 @@ function applyArity(y: Argv, fields: ZodObject): Argv { for (const f of introspectFields(fields)) { y.option(f.kebab, { type: f.baseType === "boolean" ? "boolean" : "string", + ...(f.isArray ? { array: true } : {}), // repeatable flag → yargs collects into an array describe: f.description, demandOption: false, // requiredness is enforced by zod, not yargs }); diff --git a/ts/src/adapters/inbound/cli/commands/chain.ts b/ts/src/adapters/inbound/cli/commands/chain.ts new file mode 100644 index 00000000..7f1f0984 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/chain.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { TronChainService } from "../../../../application/use-cases/tron/chain-service.js"; +import { TextFormatters } from "../render/index.js"; + +export function chainDefinitions(service: TronChainService): Array<{ spec: ChainSpec; binding: FamilyBinding }> { + return [ + { + spec: { + path: ["chain", "params"], + network: "optional", wallet: "none", auth: "none", + summary: "On-chain governance parameters", + baseFields: z.object({ + key: z.string().optional().describe("return only this parameter (e.g. getEnergyFee); omit to list all"), + }), + examples: [{ cmd: "wallet-cli chain params" }, { cmd: "wallet-cli chain params --key getEnergyFee" }], + formatText: TextFormatters.chainParams, + }, + binding: { run: async (_ctx, net, input) => service.params(net, input.key) }, + }, + { + spec: { + path: ["chain", "prices"], + network: "optional", wallet: "none", auth: "none", + summary: "Energy/bandwidth unit price and memo fee", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli chain prices" }], + formatText: TextFormatters.chainPrices, + }, + binding: { run: async (_ctx, net) => service.prices(net) }, + }, + { + spec: { + path: ["chain", "node"], + network: "optional", wallet: "none", auth: "none", + summary: "Connected node status (version / sync / peers)", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli chain node" }], + formatText: TextFormatters.chainNode, + }, + binding: { run: async (_ctx, net) => service.node(net) }, + }, + ]; +} diff --git a/ts/src/adapters/inbound/cli/commands/change-password.test.ts b/ts/src/adapters/inbound/cli/commands/change-password.test.ts new file mode 100644 index 00000000..50ceb8ab --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/change-password.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from "vitest"; +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. +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" }, + ], + } as unknown as WalletService; + const secrets = { read: (kind: string) => (kind === "password" ? OLD : undefined) }; + const prompt = { + isTTY: () => true, + hidden: vi.fn(async () => opts.newPrompt ?? NEW), + confirm: vi.fn(async () => opts.confirm ?? true), + }; + const ctx = { secrets, prompt } as unknown as ExecutionContext; + const registry = new CommandRegistry(); + registerWalletCommands(registry, { walletService: wallets, ledger: {} as LedgerDevice }); + const command = registry.resolveNeutral(["change-password"]!); + if (!command) throw new Error("change-password command not registered"); + return { command, ctx, changePassword, prompt }; +} + +describe("change-password command (TTY-only)", () => { + it("prompts for the new password and returns the changePassword receipt", async () => { + const { command, ctx, changePassword } = setup(); + await expect(command.run(ctx, undefined, { yes: true })).resolves.toEqual({ wallets: ["seed", "hot"], count: 2 }); + expect(changePassword).toHaveBeenCalledWith(OLD, NEW); + }); + + it("rejects a new password equal to the old password", async () => { + const { command, ctx } = setup({ newPrompt: OLD }); + await expect(command.run(ctx, undefined, { yes: true })).rejects.toMatchObject({ code: "invalid_value" }); + }); + + it("returns aborted when the confirmation is declined", async () => { + const { command, ctx } = setup({ confirm: false }); + await expect(command.run(ctx, undefined, { yes: false })).rejects.toMatchObject({ code: "aborted" }); + }); + + it("skips the confirmation prompt with --yes", async () => { + const { command, ctx, prompt } = setup(); + await command.run(ctx, undefined, { yes: true }); + expect(prompt.confirm).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/stake.ts b/ts/src/adapters/inbound/cli/commands/stake.ts index 34190c5c..532ba244 100644 --- a/ts/src/adapters/inbound/cli/commands/stake.ts +++ b/ts/src/adapters/inbound/cli/commands/stake.ts @@ -129,5 +129,43 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain }, { capability: "staking.delegate" }, ), + { + spec: { + path: ["stake", "info"], + network: "optional", wallet: "optional", auth: "none", + summary: "Staking & resource overview (staked / voting power / resource / unfreezing / withdrawable)", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli stake info" }, { cmd: "wallet-cli stake info --account main -o json" }], + formatText: TextFormatters.stakeInfo, + }, + binding: { run: async (ctx, net) => service.info(ctx, net) }, + }, + { + spec: { + path: ["stake", "delegated"], + network: "optional", wallet: "optional", auth: "none", + summary: "Delegation details and max delegatable size", + baseFields: z.object({ + direction: ciEnum(["out", "in"]).default("out") + .describe("out = delegated to others; in = delegated to me"), + resource: ciEnum(RESOURCES).optional() + .describe("filter to a single resource type; omit to show both"), + to: Schemas.addressFor("tron").optional() + .describe("only show delegation to this receiver (out only)"), + }), + baseRefine: (value, context) => { + if (value.to !== undefined && value.direction === "in") { + context.addIssue({ code: "custom", path: ["to"], message: "--to only applies to --direction out" }); + } + }, + examples: [ + { cmd: "wallet-cli stake delegated" }, + { cmd: "wallet-cli stake delegated --direction in" }, + { cmd: "wallet-cli stake delegated --to TBy..." }, + ], + formatText: TextFormatters.stakeDelegated, + }, + binding: { run: async (ctx, net, input) => service.delegated(ctx, net, input) }, + }, ]; } 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 12fb910b..43855755 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -46,6 +46,23 @@ describe("accountBalance formatter", () => { }); }); +describe("stake/chain TRX amount formatting", () => { + it("groups the integer part without truncating fractional TRX", () => { + const stake = TextFormatters.stakeDelegated({ + direction: "out", + canDelegateMaxSun: { energy: "1234456789", bandwidth: "0" }, + delegations: [], + }, ctx()); + const chain = TextFormatters.chainPrices({ + energy: { currentSunPerUnit: 210 }, + bandwidth: { currentSunPerUnit: 1000 }, + memoFeeSun: "1234456789", + }); + expect(stake).toContain("1,234.456789 TRX"); + expect(chain).toContain("1,234.456789 TRX"); + }); +}); + describe("tokenBalance formatter", () => { it("formats balance with decimals and symbol when metadata is present", () => { const out = TextFormatters.tokenBalance({ address: "TXaddress", token: "TR7token", balance: "1204560000", symbol: "USDT", decimals: 6 }, ctx()); diff --git a/ts/src/adapters/inbound/cli/commands/vote.ts b/ts/src/adapters/inbound/cli/commands/vote.ts index 37950384..7dc0be3c 100644 --- a/ts/src/adapters/inbound/cli/commands/vote.ts +++ b/ts/src/adapters/inbound/cli/commands/vote.ts @@ -4,14 +4,10 @@ import type { TronVoteService } from "../../../../application/use-cases/tron/vot import { txModeFields } from "./shared.js"; import { TextFormatters } from "../render/index.js"; -const voteForField = z.preprocess( - (value) => Array.isArray(value) - ? value.map(String) - : value === undefined - ? undefined - : [String(value)], - z.array(z.string().min(1)).min(1).max(30), -).describe("witness address = vote count, as SR=votes; repeatable; replaces all prior votes"); +// 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) + .describe("witness address = vote count, as SR=votes; repeatable; replaces all prior votes"); export const voteCastSpec: ChainSpec = { path: ["vote", "cast"], diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index 85529077..505770fa 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -12,6 +12,7 @@ import type { WalletService } from "../../../../application/use-cases/wallet-ser import { resolveLedgerPath, selectLedgerPath } from "../../../../application/services/ledger-account.js" import { ChainFamily, CHAIN_FAMILIES, FAMILIES } from "../../../../domain/family/index.js" import { UsageError } from "../../../../domain/errors/index.js" +import { passwordPolicyErrors } from "../input/prompt/validators.js" import { TextFormatters } from "../render/index.js" // ── wallet import-ledger contract (module scope so it can be unit-tested) ─────── @@ -70,16 +71,16 @@ export function registerWalletCommands(reg: CommandRegistry, services: { walletS const importMnemonicFields = z.object({ label: Schemas.label() .optional() - .describe("human-friendly unique account label, 1-64 chars; omit to auto-generate; mnemonic comes from --mnemonic-stdin or interactive prompt"), + .describe("human-friendly unique account label, 1-64 chars; omit to auto-generate; the mnemonic is entered interactively (hidden)"), }) reg.add({ path: ["import", "mnemonic"], - stdin: "mnemonic", network: "none", wallet: "none", auth: "required", passwordMode: "establish", interactive: true, + secretsTtyOnly: true, promptHints: { label: "default-label" }, summary: "Import a BIP39 mnemonic phrase", fields: importMnemonicFields, @@ -96,16 +97,16 @@ export function registerWalletCommands(reg: CommandRegistry, services: { walletS const importPrivateKeyFields = z.object({ label: Schemas.label() .optional() - .describe("human-friendly unique account label, 1-64 chars; omit to auto-generate; private key comes from --private-key-stdin or interactive prompt"), + .describe("human-friendly unique account label, 1-64 chars; omit to auto-generate; the private key is entered interactively (hidden)"), }) reg.add({ path: ["import", "private-key"], - stdin: "privateKey", network: "none", wallet: "none", auth: "required", passwordMode: "establish", interactive: true, + secretsTtyOnly: true, promptHints: { label: "default-label" }, summary: "Import a raw private key", fields: importPrivateKeyFields, @@ -313,4 +314,63 @@ export function registerWalletCommands(reg: CommandRegistry, services: { walletS formatText: TextFormatters.walletBackup, run: async (_ctx, _net, input) => wallets.backup(input.account, input.out), } satisfies CommandDefinition) + + // ── change-password ─────────────────────────────────────────────────────── + // Verify old, prompt for the new one, confirm, then re-encrypt every software wallet keystore. + // TTY-only (secretsTtyOnly): both passwords are entered interactively — no stdin source, no argv. + // Sibling of `backup` — both are password-gated keystore secret operations. + const changePasswordFields = z.object({ + yes: z.boolean().default(false) + .describe("skip the confirmation prompt; required in non-TTY use"), + }) + reg.add({ + path: ["change-password"], + network: "none", + wallet: "none", + auth: "required", + passwordMode: "verify", + interactive: true, + secretsTtyOnly: true, + requires: ["the new master password — entered interactively in a TTY"], + summary: "Change the master password (re-encrypt keystores)", + fields: changePasswordFields, + input: changePasswordFields, + examples: [{ cmd: "wallet-cli change-password" }], + formatText: TextFormatters.passwordChanged, + run: async (ctx, _net, input) => { + // old password: already verified and primed by dispatch (passwordMode: "verify"), from the TTY. + // secretsTtyOnly guarantees a TTY here (dispatch rejects --password-stdin / fails fast otherwise). + const oldPassword = ctx.secrets.read("password") + + const newPassword = await ctx.prompt.hidden({ + label: "New master password (hidden)", + confirm: true, + confirmLabel: "Confirm new password", + validate: (s) => { const e = passwordPolicyErrors(s); return e.length ? e.join("; ") : null }, + }) + if (newPassword === oldPassword) { + throw new UsageError("invalid_value", "the new password must differ from the current one") + } + + if (!input.yes) { + if (!ctx.prompt.isTTY()) { + throw new UsageError("tty_required", "password change needs confirmation: pass --yes or run in a terminal") + } + const count = countSoftwareWallets(wallets) + const ok = await ctx.prompt.confirm({ label: `Re-encrypt ${count} software wallet(s) with the new password?` }) + if (!ok) throw new UsageError("aborted", "password change not confirmed") + } + return wallets.changePassword(oldPassword, newPassword) + }, + } satisfies CommandDefinition) +} + +/** distinct software (seed/privateKey) wallets — the N in the change-password confirm prompt. */ +function countSoftwareWallets(wallets: WalletService): number { + const ids = new Set( + wallets.list() + .filter((a) => a.type === "seed" || a.type === "privateKey") + .map((a) => a.accountId.split(".")[0]), + ) + return ids.size } diff --git a/ts/src/adapters/inbound/cli/context/index.ts b/ts/src/adapters/inbound/cli/context/index.ts index 2f548ff5..bb49993a 100644 --- a/ts/src/adapters/inbound/cli/context/index.ts +++ b/ts/src/adapters/inbound/cli/context/index.ts @@ -38,7 +38,7 @@ class ExecutionContextImpl implements ExecutionContext { this.output = globals.output ?? deps.config.defaultOutput; this.timeoutMs = globals.timeoutMs ?? deps.config.timeoutMs; this.wait = globals.wait ?? false; - this.waitTimeoutMs = globals.waitTimeoutMs ?? 60_000; + this.waitTimeoutMs = globals.waitTimeoutMs ?? deps.config.waitTimeoutMs; } get config(): Config { diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index ad5f0da1..7cbceb33 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -17,16 +17,16 @@ export interface Example { // "none" = never unlocks. (No middle state — a command either needs the password or it doesn't.) export type AuthRequirement = "none" | "required"; -/** secret/payload channel a command reads from stdin; documents the matching --*-stdin flag. */ -export type StdinChannel = "privateKey" | "mnemonic" | "tx" | "message"; +/** secret/payload channel a command reads from stdin; documents the matching --*-stdin flag. + * (Wallet-secret entry — mnemonic/private-key/master-password — is TTY-only, so those never + * appear here; see `secretsTtyOnly`.) */ +export type StdinChannel = "tx" | "message"; export interface TextRenderContext { command: string; net?: NetworkDescriptor; /** label of the resolved active account, injected centrally; absent for wallet:"none" commands. */ accountLabel?: string; - /** command-supplied non-fatal warnings, already captured in the result envelope meta. */ - warnings?: string[]; } export type TextFormatter = (data: O, ctx: TextRenderContext) => string | null; @@ -49,6 +49,11 @@ interface CommandDefinitionBase { positionals?: { field: string; placeholder?: string }[]; /** allow interactive TTY prompts (master password, secret, gap-fill, confirm). Absent ⇒ fail fast — safer for scripts/agents. */ interactive?: boolean; + /** secrets are entered interactively ONLY (no stdin source): every `--*-stdin` secret flag, + * including the global `--password-stdin`, is rejected for this command and hidden from its help. + * Set on the ultra-sensitive setup ops (import mnemonic/private-key, change-password) — a human + * moment, no agent/CI path. Does NOT affect create/backup/signing commands. */ + secretsTtyOnly?: boolean; /** gap-fill prompt hints, by field name: "skip" = never prompt this optional field; "default-label" = offer a generated default. */ promptHints?: Record; capability?: string; diff --git a/ts/src/adapters/inbound/cli/globals/index.ts b/ts/src/adapters/inbound/cli/globals/index.ts index ef4a4fc8..8e127b75 100644 --- a/ts/src/adapters/inbound/cli/globals/index.ts +++ b/ts/src/adapters/inbound/cli/globals/index.ts @@ -53,13 +53,11 @@ export const GLOBAL_FLAG_SPECS: readonly GlobalFlagSpec[] = [ { name: "wait", kind: "boolean", description: "after broadcast, poll until the tx is confirmed/failed before returning; default returns the submitted txid without blocking", defaultValue: false }, { name: "wait-timeout", kind: "value", valueType: "number", field: "waitTimeoutMs", - description: "--wait polling cap, in milliseconds; on timeout return the submitted receipt", defaultValue: 60000 }, + description: "--wait polling cap, in milliseconds; on timeout return the submitted receipt", defaultValue: "config.waitTimeoutMs (built-in: 60000)" }, { name: "password-stdin", kind: "secret-stdin", secretKey: "password", description: "read the master password from stdin (fd 0); only one *-stdin flag can consume stdin per run" }, - { name: "private-key-stdin", kind: "secret-stdin", secretKey: "privateKey", commandScoped: true, - description: "read the private key from stdin (fd 0)" }, - { name: "mnemonic-stdin", kind: "secret-stdin", secretKey: "mnemonic", commandScoped: true, - description: "read the BIP39 mnemonic from stdin (fd 0)" }, + // NB: mnemonic / private-key / new-password have NO stdin flag — those secrets are TTY-only + // (import mnemonic/private-key, change-password are interactive setup ops; see secretsTtyOnly). { name: "tx-stdin", kind: "secret-stdin", secretKey: "tx", commandScoped: true, description: "read the signed transaction JSON from stdin (fd 0)" }, { name: "message-stdin", kind: "secret-stdin", secretKey: "message", commandScoped: true, diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index c43f0bef..2512504f 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -102,9 +102,10 @@ export class HelpService { ["token", "Manage the token address book and query tokens", ""], ["tx", "Build, send, broadcast, and inspect transactions", ""], ["contract", "Call, send, deploy, and inspect smart contracts", ""], - ["stake", "Stake / delegate resources", "tron"], + ["stake", "Stake / delegate resources & query state", "tron"], ["vote", "Vote for super representatives", "tron"], ["reward", "Query / withdraw voting rewards", "tron"], + ["chain", "Query chain params, prices & node info", ""], ["message", "Sign arbitrary messages", ""], ["block", "Get a block (latest if omitted)", ""], ] as const @@ -117,6 +118,7 @@ export class HelpService { ["delete", "Delete a wallet / account", ""], ["config", "Show / get / set configuration values", ""], ["networks", "List known networks", ""], + ["change-password", "Change the master password (re-encrypt keystores)", ""], ] as const const sections = [common, management, commands] as const const nameWidth = Math.max(...sections.flat().map(([name]) => name.length)) + 2 @@ -203,6 +205,7 @@ export class HelpService { examples: cmd.examples, requires: cmd.requires, positionals: cmd.positionals, + secretsTtyOnly: cmd.secretsTtyOnly, }) } @@ -236,6 +239,7 @@ export class HelpService { examples: CommandDefinition["examples"] requires?: string[] positionals?: { field: string; placeholder?: string }[] + secretsTtyOnly?: boolean }): string { const positionals = (c.positionals ?? []).map((p) => { const field = c.fields.find((f) => f.name === p.field) @@ -255,7 +259,9 @@ export class HelpService { const requires: string[] = [...(c.requires ?? [])] if (c.network === "required") requires.push("--network ") - if (c.auth === "required") requires.push("master password — pass --password-stdin for non-interactive use, or enter it interactively in a TTY") + if (c.auth === "required") requires.push(c.secretsTtyOnly + ? "the master password — entered interactively in a TTY" + : "master password — pass --password-stdin for non-interactive use, or enter it interactively in a TTY") if (c.wallet !== "none") requires.push("an account — defaults to active; override with --account (or run `wallet-cli use ` to change the active account)") if (requires.length) { lines.push("", "Requires:") @@ -282,7 +288,7 @@ export class HelpService { lines.push("", "Global options:") // curated per command: --network only when the command selects a network; --password-stdin // only when it requires unlock; --account only when the command acts as an account. - for (const g of globalFlagsForText(c.network, c.auth, c.wallet, c.broadcasts ?? false)) lines.push(globalFlagLine(g)) + for (const g of globalFlagsForText(c.network, c.auth, c.wallet, c.broadcasts ?? false, c.secretsTtyOnly ?? false)) lines.push(globalFlagLine(g)) if (c.examples.length) { lines.push("", "Examples:") @@ -360,8 +366,7 @@ function metaPositionals(tokens: string[]): string[] { /** "--flag " header for a command flag — enum fields list their choices instead of . */ function flagHead(f: FieldInfo): string { - const typeName = f.baseType === "pipe" ? "string" : f.baseType - const typ = f.choices ? ` <${f.choices.join("|")}>` : typeName === "boolean" ? "" : ` <${typeName}>` + const typ = f.choices ? ` <${f.choices.join("|")}>` : f.baseType === "boolean" ? "" : ` <${f.baseType}>` return `--${f.kebab}${typ}` } @@ -386,11 +391,12 @@ function globalFlagsForText( auth: CommandDefinition["auth"], wallet: CommandDefinition["wallet"], broadcasts: boolean, + secretsTtyOnly: boolean, ): GlobalFlag[] { return GLOBAL_FLAGS.filter((g) => { if (g.flag === "--account") return wallet !== "none" if (g.flag === "--network") return network !== "none" - if (g.flag === "--password-stdin") return auth === "required" + if (g.flag === "--password-stdin") return auth === "required" && !secretsTtyOnly if (g.flag === "--wait" || g.flag === "--wait-timeout") return broadcasts return true }) @@ -410,9 +416,10 @@ const GROUP_DESCRIPTIONS: Record = { token: "Manage the token address book and query tokens.", tx: "Build, send, broadcast, and inspect transactions.", contract: "Call, send, deploy, and inspect smart contracts.", - stake: "Stake / delegate resources (TRON Stake 2.0).", + stake: "Stake / delegate resources & query state (TRON Stake 2.0).", vote: "Vote for super representatives (SR).", reward: "Query and withdraw voting/block rewards.", + chain: "Query on-chain parameters, resource prices, and node status.", message: "Sign arbitrary messages.", block: "Get a block (latest if omitted).", } diff --git a/ts/src/adapters/inbound/cli/input/secret/index.ts b/ts/src/adapters/inbound/cli/input/secret/index.ts index c5f8d465..8b471c99 100644 --- a/ts/src/adapters/inbound/cli/input/secret/index.ts +++ b/ts/src/adapters/inbound/cli/input/secret/index.ts @@ -87,14 +87,10 @@ export class SecretResolver implements ISecretResolver { throw new UsageError("missing_option", `--${inlineFlag} or --${flagOf(kind)}-stdin is required`); } + /** Wallet secrets (mnemonic / private key) are TTY-only: a hidden interactive prompt, or fail. + * There is no `--*-stdin` source for them — importing an existing secret is a human moment. */ async resolveSecret(kind: "mnemonic" | "privateKey"): Promise { const validate = kind === "mnemonic" ? isValidMnemonic : isValidPrivateKeyHex; - if (this.has(kind)) { - const v = this.read(kind).trim(); - if (!validate(v)) throw new UsageError("invalid_secret", `--${flagOf(kind)}-stdin is not a valid ${kind}`); - this.streams.diagnostic("info", `${flagOf(kind)} ✓ via pipe`); - return v; - } if (this.prompter?.isTTY()) { const label = kind === "mnemonic" ? "Paste recovery phrase (hidden)" @@ -107,7 +103,7 @@ export class SecretResolver implements ISecretResolver { this.#primed.set(kind, trimmed); return trimmed; } - throw new UsageError("missing_option", `--${flagOf(kind)}-stdin is required (or run in a terminal)`); + throw new UsageError("tty_required", `${kind} entry is interactive; run in a terminal`); } async primePassword(plan: { mode: "set" | "verify"; verify?: (pw: string) => boolean }): Promise { diff --git a/ts/src/adapters/inbound/cli/input/secret/secret.test.ts b/ts/src/adapters/inbound/cli/input/secret/secret.test.ts index af1d9f43..bb99b96f 100644 --- a/ts/src/adapters/inbound/cli/input/secret/secret.test.ts +++ b/ts/src/adapters/inbound/cli/input/secret/secret.test.ts @@ -22,24 +22,22 @@ class Backend implements PromptBackend { const PW = "Abcdef1!"; -describe("resolveSecret", () => { - it("prompts (hidden) when no stdin source and validates mnemonic", async () => { +describe("resolveSecret (TTY-only)", () => { + // mnemonic / private key have NO --*-stdin source; they are entered via a hidden TTY prompt, + // which re-prompts until the value validates. + it("prompts (hidden) and validates the mnemonic, retrying until valid", async () => { const valid = "legal winner thank year wave sausage worth useful legal winner thank yellow"; const r = new SecretResolver(streams(), {}, new Prompter(new Backend(["bad phrase", valid]))); expect(await r.resolveSecret("mnemonic")).toBe(valid); }); - it("uses the stdin source when present", async () => { + it("prompts (hidden) and validates a private key, retrying until valid", async () => { const k = "a".repeat(64); - const r = new SecretResolver(streams(k + "\n"), { privateKey: "-" }, new Prompter(new Backend([]))); + const r = new SecretResolver(streams(), {}, new Prompter(new Backend(["zzz", k]))); expect(await r.resolveSecret("privateKey")).toBe(k); }); - it("rejects a malformed stdin secret with invalid_secret", async () => { - const r = new SecretResolver(streams("zzz\n"), { privateKey: "-" }, new Prompter(new Backend([]))); - await expect(r.resolveSecret("privateKey")).rejects.toMatchObject({ code: "invalid_secret" }); - }); - it("errors when missing and no TTY", async () => { + it("errors with tty_required when there is no terminal", async () => { const r = new SecretResolver(streams(), {}, new Prompter(new Backend([], false))); - await expect(r.resolveSecret("mnemonic")).rejects.toMatchObject({ code: "missing_option" }); + await expect(r.resolveSecret("mnemonic")).rejects.toMatchObject({ code: "tty_required" }); }); }); diff --git a/ts/src/adapters/inbound/cli/output/index.ts b/ts/src/adapters/inbound/cli/output/index.ts index 24f9afb6..7b8c41a2 100644 --- a/ts/src/adapters/inbound/cli/output/index.ts +++ b/ts/src/adapters/inbound/cli/output/index.ts @@ -17,8 +17,6 @@ import { OutputEnvelope, toJson } from "./envelope.js"; import { renderGenericText } from "../render/index.js"; import { sanitizeText } from "../render/scalars.js"; -const COMMAND_WARNINGS_KEY = "__walletCliWarnings"; - export interface OutputFormatter { /** the single result frame for the caller to hand to streams.result. */ success(command: string, net: NetworkDescriptor | undefined, data: unknown, formatText?: TextFormatter, accountLabel?: string): string; @@ -34,16 +32,15 @@ abstract class BaseOutputFormatter { protected readonly startedAt: number, ) {} - protected meta(extraWarnings: string[] = []) { - return { durationMs: Date.now() - this.startedAt, warnings: [...this.streams.warnings(), ...extraWarnings] }; + protected meta() { + return { durationMs: Date.now() - this.startedAt, warnings: this.streams.warnings() }; } } 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 unwrapped = unwrapCommandWarnings(data); - return toJson(OutputEnvelope.success(command, net, unwrapped.data, this.meta(unwrapped.warnings))); + return toJson(OutputEnvelope.success(command, net, data, this.meta())); } error(err: CliError, ctx?: { commandId?: string; net?: NetworkDescriptor }): void { @@ -60,9 +57,8 @@ class HumanOutputFormatter extends BaseOutputFormatter implements OutputFormatte // 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. success(command: string, net: NetworkDescriptor | undefined, data: unknown, formatText?: TextFormatter, accountLabel?: string): string { - const unwrapped = unwrapCommandWarnings(data); - const env = OutputEnvelope.success(command, net, unwrapped.data, this.meta(unwrapped.warnings)); - const custom = formatText?.(env.data, { command: env.command, net, accountLabel, warnings: env.meta.warnings }); + const env = OutputEnvelope.success(command, net, data, this.meta()); + const custom = formatText?.(env.data, { command: env.command, net, accountLabel }); return sanitizeText(custom ?? renderGenericText(env.command, net, env.data)); } @@ -75,16 +71,6 @@ class HumanOutputFormatter extends BaseOutputFormatter implements OutputFormatte } } -function unwrapCommandWarnings(data: unknown): { data: unknown; warnings: string[] } { - if (!data || typeof data !== "object" || Array.isArray(data)) return { data, warnings: [] }; - const record = data as Record; - const raw = record[COMMAND_WARNINGS_KEY]; - if (!Array.isArray(raw)) return { data, warnings: [] }; - const warnings = raw.filter((item): item is string => typeof item === "string"); - const { [COMMAND_WARNINGS_KEY]: _warnings, ...publicData } = record; - return { data: publicData, warnings }; -} - export function createOutputFormatter( output: OutputMode, streams: StreamManager, diff --git a/ts/src/adapters/inbound/cli/render/chain.ts b/ts/src/adapters/inbound/cli/render/chain.ts new file mode 100644 index 00000000..a8c148f5 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/chain.ts @@ -0,0 +1,70 @@ +import type { TextFormatter } from "../contracts/index.js" +import { formatDecimal, formatInt, formatSun } from "./scalars.js" +import { asObj, query, table } from "./layout.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 timestamp(v: unknown): string { + const n = Number(v) + if (!Number.isFinite(n) || n <= 0) return "—" + return new Date(n).toISOString().replace("T", " ").slice(0, 19) +} + +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)], + ]) + } + 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)]), + ) + }) satisfies TextFormatter, + + chainPrices: ((data) => { + const d = asObj(data) + const energy = asObj(d.energy) + const bandwidth = asObj(d.bandwidth) + return query([ + ["Energy price", `${formatInt(energy.currentSunPerUnit)} SUN / unit (current)`], + ["Bandwidth price", `${formatInt(bandwidth.currentSunPerUnit)} SUN / unit (current)`], + ["Memo fee", `${formatDecimal(formatSun(d.memoFeeSun))} TRX`], + ]) + }) satisfies TextFormatter, + + chainNode: ((data) => { + const d = asObj(data) + const head = asObj(d.headBlock) + const solid = asObj(d.solidBlock) + const peers = asObj(d.peers) + const headTimestamp = Number(head.timestamp ?? 0) + const ageSeconds = headTimestamp > 0 ? Math.max(0, Math.round((Date.now() - headTimestamp) / 1000)) : null + const sync = d.inSync ? "in sync" : "lagging" + return query([ + ["Endpoint", d.endpoint === null ? "—" : String(d.endpoint ?? "—")], + ["Version", d.version === null ? "—" : String(d.version ?? "—")], + ["Head block", `#${formatInt(head.number)} ${timestamp(head.timestamp)} (${ageSeconds === null ? "—" : `~${ageSeconds}s ago — ${sync}`})`], + ["Solid block", d.solidBlock === null ? "—" : `#${formatInt(solid.number)} (${formatInt(d.lagBlocks)} blocks behind head)`], + ["Peers", d.peers === null ? "—" : `${formatInt(peers.connected)} connected / ${formatInt(peers.active)} active`], + ]) + }) satisfies TextFormatter, +} diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index 5771cd42..041647ba 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -4,8 +4,10 @@ * 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) * misc.ts — config, networks, contract call/info, message sign, block * This barrel reassembles the one TextFormatters table command specs import. */ @@ -15,8 +17,10 @@ import { type Obj, ok } from "./layout.js" import { WalletFormatters } from "./wallet.js" import { AccountFormatters } from "./account.js" import { TxFormatters } from "./tx.js" +import { StakeFormatters } from "./stake.js" import { VoteFormatters } from "./vote.js" import { RewardFormatters } from "./reward.js" +import { ChainFormatters } from "./chain.js" import { MiscFormatters } from "./misc.js" export { FAMILY_RENDER, renderFamily } from "./family.js" @@ -25,8 +29,10 @@ export const TextFormatters = { ...WalletFormatters, ...AccountFormatters, ...TxFormatters, + ...StakeFormatters, ...VoteFormatters, ...RewardFormatters, + ...ChainFormatters, ...MiscFormatters, } diff --git a/ts/src/adapters/inbound/cli/render/reward.ts b/ts/src/adapters/inbound/cli/render/reward.ts index defcc192..c39d8712 100644 --- a/ts/src/adapters/inbound/cli/render/reward.ts +++ b/ts/src/adapters/inbound/cli/render/reward.ts @@ -1,5 +1,5 @@ import type { TextFormatter } from "../contracts/index.js" -import { formatSun } from "./scalars.js" +import { formatAtWithRelative, formatSun } from "./scalars.js" import { type Obj, asObj, query } from "./layout.js" export const RewardFormatters = { @@ -19,22 +19,5 @@ function withdrawStatus(d: Obj): string { } const at = Number(d.withdrawableAt) if (!Number.isFinite(at) || at <= 0) return "available now" - return `available from ${formatLocalMinute(at)} (${relativeFromNow(at)})` -} - -function formatLocalMinute(epochMs: number): string { - const d = new Date(epochMs) - const yyyy = String(d.getFullYear()) - const mm = String(d.getMonth() + 1).padStart(2, "0") - const dd = String(d.getDate()).padStart(2, "0") - const hh = String(d.getHours()).padStart(2, "0") - const mi = String(d.getMinutes()).padStart(2, "0") - return `${yyyy}-${mm}-${dd} ${hh}:${mi}` -} - -function relativeFromNow(epochMs: number): string { - const ms = Math.max(0, epochMs - Date.now()) - const hours = Math.ceil(ms / (60 * 60 * 1000)) - if (hours < 48) return `~${hours}h` - return `~${Math.ceil(hours / 24)}d` + return `available from ${formatAtWithRelative(at)}` } diff --git a/ts/src/adapters/inbound/cli/render/scalars.ts b/ts/src/adapters/inbound/cli/render/scalars.ts index 8ae51022..8bab4484 100644 --- a/ts/src/adapters/inbound/cli/render/scalars.ts +++ b/ts/src/adapters/inbound/cli/render/scalars.ts @@ -17,6 +17,15 @@ export function formatInt(v: unknown): string { return Number.isFinite(n) ? Math.trunc(n).toLocaleString("en-US") : String(v ?? ""); } +/** Group a decimal string's integer part without coercing or dropping fractional digits. */ +export function formatDecimal(v: unknown): string { + const raw = String(v ?? ""); + const match = /^(-?)(\d+)(\.\d+)?$/.exec(raw); + if (!match) return raw; + const [, sign, integer, fraction = ""] = match; + return `${sign}${integer!.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}${fraction}`; +} + export function formatUsd(v: unknown): string { const n = Number(v); return Number.isFinite(n) ? n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(v ?? ""); @@ -37,6 +46,23 @@ export function formatTime(v: unknown): string { return `${mm}-${dd} ${hh}:${mi}`; } +/** epoch-ms → "YYYY-MM-DD HH:MM (in ~3 days)" / "(~2h ago)" — local time + a coarse relative hint. + * Shared by the reward / stake / delegated views so time reads consistently across the CLI. + * `now` is injectable for deterministic tests. Empty string for a missing/non-positive value. */ +export function formatAtWithRelative(v: unknown, now: number = Date.now()): string { + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) return ""; + const d = new Date(n); + const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const at = `${date} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + const delta = n - now; + const mag = Math.abs(delta); + const unit = mag >= 86_400_000 + ? `${Math.round(mag / 86_400_000)} day(s)` + : mag >= 3_600_000 ? `${Math.round(mag / 3_600_000)}h` : `${Math.max(1, Math.round(mag / 60_000))}m`; + return `${at} (${delta >= 0 ? `in ~${unit}` : `~${unit} ago`})`; +} + /** block timestamp -> "YYYY-MM-DD HH:MM:SS UTC". */ export function formatUtc(v: unknown): string { const n = Number(v); diff --git a/ts/src/adapters/inbound/cli/render/stake.ts b/ts/src/adapters/inbound/cli/render/stake.ts new file mode 100644 index 00000000..a75929b2 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/stake.ts @@ -0,0 +1,54 @@ +import type { TextFormatter } from "../contracts/index.js" +import { formatAtWithRelative, formatDecimal, formatInt, formatSun } from "./scalars.js" +import { type Pair, asObj, kv, query } from "./layout.js" + +const trx = (sun: unknown) => `${formatDecimal(formatSun(sun))} TRX` + +export const StakeFormatters = { + stakeInfo: ((data, ctx) => { + const d = asObj(data) + const staked = asObj(d.staked); const vp = asObj(d.votingPower) + const res = asObj(d.resource); const energy = asObj(res.energy); const bandwidth = asObj(res.bandwidth) + const unfreeze = asObj(d.unfreeze) + const unfreezing = Array.isArray(d.unfreezing) ? d.unfreezing.map(asObj) : [] + const totalStaked = BigInt(String(staked.energySun ?? "0")) + BigInt(String(staked.bandwidthSun ?? "0")) + const lines: Pair[] = [ + ["Label", ctx.accountLabel ?? ""], + ["Staked", `${trx(totalStaked.toString())} (for energy ${trx(staked.energySun)} + for bandwidth ${trx(staked.bandwidthSun)})`], + ["Voting power", `${formatInt(vp.total)} TP (used ${formatInt(vp.used)} / available ${formatInt(vp.available)})`], + ["Energy", `used ${formatInt(energy.used)} / ${formatInt(energy.limit)}`], + ["Bandwidth", `used ${formatInt(bandwidth.used)} / ${formatInt(bandwidth.limit)}`], + ["Unfreezing", `${unfreezing.length} pending (max ${formatInt(unfreeze.max)} at a time, ${formatInt(unfreeze.remaining)} more allowed)`], + ] + const body = query(lines) + const pendings = unfreezing.map((u, i) => ` ${i + 1}) ${trx(u.amountSun)} withdrawable ${formatAtWithRelative(u.withdrawableAt)}`) + const tail = kv([["Withdrawable", `${trx(d.withdrawableSun)} now`]], "") + return [body, ...pendings, tail].join("\n") + }) satisfies TextFormatter, + + stakeDelegated: ((data, ctx) => { + const d = asObj(data) + const out = d.direction === "out" + const rows = (Array.isArray(d.delegations) ? d.delegations.map(asObj) : []) + const head = query([ + ["Label", ctx.accountLabel ?? ""], + ["Direction", out ? "out (delegated to others)" : "in (delegated to me)"], + ]) + const sections: string[] = [head] + if (out && d.canDelegateMaxSun) { + const max = asObj(d.canDelegateMaxSun) + sections.push("", "Max delegatable", kv([ + ["Energy", `${trx(max.energy)}`], + ["Bandwidth", `${trx(max.bandwidth)}`], + ], " ")) + } + const lockCol = out ? "Locked until" : "Guaranteed until" + const lockText = (v: unknown) => v ? formatAtWithRelative(v) : out ? "not locked" : "none — reclaimable anytime" + const table = rows.map((r) => [String(out ? r.receiver : r.from), String(r.resource), trx(r.amountSun), lockText(r.lockedUntil)]) + const header = [out ? "Receiver" : "From", "Resource", "Amount", lockCol] + const widths = header.map((h, i) => Math.max(h.length, ...table.map((row) => row[i]!.length))) + const fmtRow = (row: string[]) => ` ${row.map((c, i) => c.padEnd(widths[i]!)).join(" ")}`.trimEnd() + sections.push("", `Delegations (${rows.length})`, fmtRow(header), ...table.map(fmtRow)) + return sections.join("\n") + }) satisfies TextFormatter, +} diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index 135edbff..ba71e89a 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -116,9 +116,9 @@ function receiptSummary(r: TxReceiptView, family: ChainFamily): string { case "contract-deploy": return "Contract deployed" case "vote-cast": { - const total = r.totalVotes === undefined ? "" : `${formatInt(r.totalVotes)} TP` const count = Array.isArray(r.votes) ? r.votes.length : 0 - return `Voted ${total || "TP"} across ${formatInt(count)} witness${count === 1 ? "" : "es"}` + const across = `across ${formatInt(count)} witness${count === 1 ? "" : "es"}` + return r.totalVotes === undefined ? `Voted ${across}` : `Voted ${formatInt(r.totalVotes)} TP ${across}` } case "reward-withdraw": return "Withdrew voting/block rewards" diff --git a/ts/src/adapters/inbound/cli/render/vote.ts b/ts/src/adapters/inbound/cli/render/vote.ts index 852a51dc..097c9652 100644 --- a/ts/src/adapters/inbound/cli/render/vote.ts +++ b/ts/src/adapters/inbound/cli/render/vote.ts @@ -36,9 +36,11 @@ function renderVoteStatus(data: Obj, ctx: TextRenderContext): string { String(vote.witness ?? ""), ])), ] - for (const warning of ctx.warnings ?? []) { - if (warning.startsWith("zero_reward_ratio:")) { - lines.push(`! ${warning.replace(/^zero_reward_ratio:\s*/, "")}`) + // votes on a 0%-reward-ratio SR earn nothing — surface a `!` line straight from the data + // (same style as tx.ts's `! Track it:`); the json warning is emitted separately via scope.warn. + for (const vote of votes) { + if (vote.rewardRatioPct === 0) { + lines.push(`! ${formatInt(vote.count)} votes on ${String(vote.name ?? vote.witness ?? "")} earn nothing — 0% reward ratio`) } } return lines.join("\n") @@ -49,8 +51,8 @@ function rowsOf(value: unknown): Obj[] { } function pct(value: unknown): string { - if (value === null || value === undefined) return "-" + if (value === null || value === undefined) return "—" const n = Number(value) - if (!Number.isFinite(n)) return "-" - return `${Number.isInteger(n) ? String(n) : String(n)}%` + if (!Number.isFinite(n)) return "—" + return `${String(n)}%` } diff --git a/ts/src/adapters/inbound/cli/render/wallet.ts b/ts/src/adapters/inbound/cli/render/wallet.ts index 730b6bd2..6677c5fe 100644 --- a/ts/src/adapters/inbound/cli/render/wallet.ts +++ b/ts/src/adapters/inbound/cli/render/wallet.ts @@ -62,6 +62,14 @@ export const WalletFormatters = { `${warn()} Secret material was written only to the backup file, never to stdout.`, ].join("\n") }) satisfies TextFormatter, + passwordChanged: ((data) => { + const d = asObj(data) + const list = Array.isArray(d.wallets) ? d.wallets.map(String).join(", ") : "" + return receipt(ok(), `Master password changed — re-encrypted ${String(d.count ?? 0)} software wallet(s)`, [ + ["Wallets", list], + ["Note", "Ledger / watch-only accounts are unaffected"], + ]) + }) satisfies TextFormatter, } function renderWalletCreated(verb: "Created" | "Imported", d: Obj, notes: string[]): string { diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts index ecf788ec..8421a41a 100644 --- a/ts/src/adapters/inbound/cli/shell/index.ts +++ b/ts/src/adapters/inbound/cli/shell/index.ts @@ -208,6 +208,18 @@ async function executeCommand(opts: ShellOptions, cmd: CommandDefinition, argv: caps.check(cmd, net) + // secretsTtyOnly (import mnemonic/private-key, change-password): the most sensitive setup ops + // read every secret interactively only. Reject any stdin password source and require a real TTY + // BEFORE priming, so no secret is ever taken from a pipe and the error is honest. + if (cmd.secretsTtyOnly) { + if (deps.secrets.has("password")) { + throw new UsageError("invalid_option", `${commandId(cmd)} reads the master password interactively only; --password-stdin is not accepted`) + } + if (!deps.prompter.isTTY()) { + throw new UsageError("tty_required", `${commandId(cmd)} reads secrets interactively; run in a terminal`) + } + } + // auth: opt-in interactive password priming via passwordMode. Commands without passwordMode // (tx/contract/stake/message/derive) demand the password LAZILY — the keystore throws // auth_required only when a sign/decrypt actually happens, so read-only paths like diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 6c47b780..a06e2890 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -14,7 +14,9 @@ import type { TronContractParameter, TronContractMetadata, TronAccount, + TronDelegatedResource, TronGateway, + TronNodeInfo, TronTokenInfo, TronTx, TronTxInfo, @@ -105,7 +107,7 @@ export class TronRpcClient implements TronGateway, Broadcaster { // ── account / query ────────────────────────────────────────────────────────── async getAccount(address: string): Promise { return this.#wrap("getAccount", async () => { - const response = await fetch(`${this.#fullHost}/walletsolidity/getaccount`, { + const response = await fetch(`${this.#fullHost}/wallet/getaccount`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ address: this.#tw.address.toHex(address) }), @@ -129,7 +131,10 @@ export class TronRpcClient implements TronGateway, Broadcaster { return this.#wrap("getTransaction", async () => parseTronTx(await this.#tw.trx.getTransaction(txid))); } async getTransactionInfoById(txid: string): Promise { - return this.#wrap("getTransactionInfo", async () => parseTronTxInfo(await this.#tw.trx.getTransactionInfo(txid))); + // Full-node (unconfirmed) info: available ~one block after inclusion (~3s), not after + // solidification (~19 blocks / ~60s). So `--wait` confirms at "mined in a block" rather than + // "irreversible" — the receipt (fee/energy/result) is already final by then. Same response shape. + return this.#wrap("getTransactionInfo", async () => parseTronTxInfo(await this.#tw.trx.getUnconfirmedTransactionInfo(txid))); } decodeTransaction(transaction: TronTx): DecodedTronTransaction { return decodeTronTransaction(transaction); @@ -287,7 +292,7 @@ export class TronRpcClient implements TronGateway, Broadcaster { async getWitnesses(limit: number): Promise { const capped = Math.min(Math.max(Math.trunc(limit), 1), 127); return this.#wrap("getNowWitnessList", async () => { - const response = await fetch(`${this.#fullHost}/walletsolidity/getpaginatednowwitnesslist`, { + const response = await fetch(`${this.#fullHost}/wallet/getpaginatednowwitnesslist`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ offset: 0, limit: capped, visible: true }), @@ -301,7 +306,7 @@ export class TronRpcClient implements TronGateway, Broadcaster { } async getBrokerage(address: string): Promise { return this.#wrap("getBrokerage", async () => { - const response = await fetch(`${this.#fullHost}/walletsolidity/getBrokerage`, { + const response = await fetch(`${this.#fullHost}/wallet/getBrokerage`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ address: this.#tw.address.toHex(address) }), @@ -316,7 +321,7 @@ export class TronRpcClient implements TronGateway, Broadcaster { } async getReward(address: string): Promise { return this.#wrap("getReward", async () => { - const response = await fetch(`${this.#fullHost}/walletsolidity/getReward`, { + const response = await fetch(`${this.#fullHost}/wallet/getReward`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ address: this.#tw.address.toHex(address) }), @@ -336,6 +341,53 @@ export class TronRpcClient implements TronGateway, Broadcaster { return this.#wrap("getBandwidthPrices", () => this.#tw.trx.getBandwidthPrices()); } + // ── chain info ──────────────────────────────────────────────────────────────── + async getChainParameters(): Promise> { + return this.#wrap("getChainParameters", () => this.#tw.trx.getChainParameters()); + } + async getNodeInfo(): Promise { + return this.#wrap("getNodeInfo", () => this.#tw.trx.getNodeInfo() as Promise); + } + + // ── staking queries (Stake 2.0) ─────────────────────────────────────────────── + async getDelegatedResourceV2(from: string, to: string): Promise { + return this.#wrap("getDelegatedResourceV2", async () => { + const res = await this.#tw.trx.getDelegatedResourceV2(from, to); + return (res.delegatedResource ?? []).map((d) => ({ + from: tronHexToBase58(d.from), + to: tronHexToBase58(d.to), + balanceForEnergySun: String(d.frozen_balance_for_energy ?? 0), + balanceForBandwidthSun: String(d.frozen_balance_for_bandwidth ?? 0), + expireTimeForEnergy: d.expire_time_for_energy ? Number(d.expire_time_for_energy) : null, + expireTimeForBandwidth: d.expire_time_for_bandwidth ? Number(d.expire_time_for_bandwidth) : null, + })); + }); + } + async getDelegatedIndexV2(address: string): Promise<{ fromAccounts: string[]; toAccounts: string[] }> { + return this.#wrap("getDelegatedResourceAccountIndexV2", async () => { + const res = await this.#tw.trx.getDelegatedResourceAccountIndexV2(address); + return { + fromAccounts: (res.fromAccounts ?? []).map((a) => tronHexToBase58(a)), + toAccounts: (res.toAccounts ?? []).map((a) => tronHexToBase58(a)), + }; + }); + } + async getCanDelegatedMaxSize(address: string, resource: RpcResourceCode): Promise { + return this.#wrap("getCanDelegatedMaxSize", async () => + String((await this.#tw.trx.getCanDelegatedMaxSize(address, resource)).max_size ?? 0), + ); + } + async getCanWithdrawUnfreezeAmount(address: string): Promise { + return this.#wrap("getCanWithdrawUnfreezeAmount", async () => + String((await this.#tw.trx.getCanWithdrawUnfreezeAmount(address)).amount ?? 0), + ); + } + async getAvailableUnfreezeCount(address: string): Promise { + return this.#wrap("getAvailableUnfreezeCount", async () => + Number((await this.#tw.trx.getAvailableUnfreezeCount(address)).count ?? 0), + ); + } + // ── contract ────────────────────────────────────────────────────────────────── async triggerConstantContract( contract: string, fn: string, params: TronContractParameter[], owner = TRON_READ_OWNER, diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index d5c12753..47f59f79 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -67,4 +67,5 @@ export const DEFAULT_CONFIG = { defaultNetwork: "tron:mainnet", defaultOutput: "text" as const, timeoutMs: 60000, + waitTimeoutMs: 60000, } diff --git a/ts/src/adapters/outbound/config/index.ts b/ts/src/adapters/outbound/config/index.ts index 57bbb352..da2fa2fd 100644 --- a/ts/src/adapters/outbound/config/index.ts +++ b/ts/src/adapters/outbound/config/index.ts @@ -33,6 +33,7 @@ export class ConfigLoader { let defaultNetwork: string | undefined = DEFAULT_CONFIG.defaultNetwork; let defaultOutput: OutputMode = DEFAULT_CONFIG.defaultOutput; let timeoutMs = DEFAULT_CONFIG.timeoutMs; + let waitTimeoutMs = DEFAULT_CONFIG.waitTimeoutMs; let price: Config["price"]; const path = ConfigLoader.configPath(env); @@ -43,6 +44,7 @@ export class ConfigLoader { } if (raw.defaultOutput === "json" || raw.defaultOutput === "text") defaultOutput = raw.defaultOutput; if (typeof raw.timeoutMs === "number") timeoutMs = raw.timeoutMs; + if (typeof raw.waitTimeoutMs === "number") waitTimeoutMs = raw.waitTimeoutMs; if (raw.price && typeof raw.price === "object") { const p = raw.price as Record; const provider = p.provider === "none" ? "none" : "coingecko"; @@ -55,7 +57,7 @@ export class ConfigLoader { } } } - return { defaultNetwork, defaultOutput, timeoutMs, networks, price }; + return { defaultNetwork, defaultOutput, timeoutMs, waitTimeoutMs, networks, price }; } } diff --git a/ts/src/adapters/outbound/keystore/index.ts b/ts/src/adapters/outbound/keystore/index.ts index 2d3c5d4a..2c5b63f1 100644 --- a/ts/src/adapters/outbound/keystore/index.ts +++ b/ts/src/adapters/outbound/keystore/index.ts @@ -14,7 +14,7 @@ import { Derivation } from "../../../domain/derivation/index.js"; import { familyOf, CHAIN_FAMILIES } from "../../../domain/family/index.js"; import { SOURCE_KINDS, sourceFamily } from "../../../domain/sources/index.js"; import { AtomicFileStore } from "../persistence/fs/index.js"; -import { UsageError, WalletError } from "../../../domain/errors/index.js"; +import { ExecutionError, UsageError, WalletError } from "../../../domain/errors/index.js"; import { accountIndices, accountRefOf, @@ -370,6 +370,53 @@ export class Keystore { } } + /** verify old → decrypt every software blob → re-encrypt with new → staged write incl. verifier. + * Ledger/watch wallets hold no blob and are untouched. */ + changePassword(oldPassword: string, newPassword: string): { wallets: string[]; count: number } { + return this.store.withLock(this.walletsPath, () => { + const verifier = this.store.readJson(this.#verifierPath()); + if (!verifier) { + throw new WalletError("no_software_wallet", "no software wallet; no master password set; nothing to change"); + } + CryptoEnvelope.decrypt(verifier, oldPassword); // MAC mismatch → auth_failed (wrong old password) + + const file = this.#read(); + const entries: Array<{ path: string; value: unknown }> = []; + const labels: string[] = []; + for (const w of file.wallets) { + const s = w.source; + if (s.type !== "seed" && s.type !== "privateKey") continue; + const dir = s.type === "seed" ? "vaults" as const : "keys" as const; + const id = s.type === "seed" ? s.vaultId : s.keyId; + const path = this.#blobPath(dir, id); + const blob = this.store.readJson(path); + if (!blob) { + throw new WalletError( + "invalid_value", + `missing ${dir === "vaults" ? "vault" : "key"} blob ${id}`, + ); + } + const plaintext = CryptoEnvelope.decrypt(blob, oldPassword); + entries.push({ path, value: CryptoEnvelope.encrypt(plaintext, newPassword, id, blob.type) }); + labels.push(file.labels[accountRefOf(w, s.type === "seed" ? 0 : null)] ?? w.id); + } + if (entries.length === 0) { + throw new WalletError("no_software_wallet", "no software wallet keystore to re-encrypt"); + } + entries.push({ + path: this.#verifierPath(), + value: CryptoEnvelope.encrypt(randomBytes(32), newPassword, "verifier", "verifier"), + }); + try { + this.store.writeJsonAll(entries); + } catch { + // staged temps were unlinked by writeJsonAll; every keystore file is unchanged. + throw new ExecutionError("io_error", "failed to write re-encrypted keystores; rolled back, the old password remains in effect"); + } + return { wallets: labels, count: labels.length }; + }); + } + // ── secrets ──────────────────────────────────────────────────────────────── decryptSeed(vaultId: string): Bytes { return this.#decryptSeedFromVault(vaultId); diff --git a/ts/src/adapters/outbound/keystore/keystore.test.ts b/ts/src/adapters/outbound/keystore/keystore.test.ts index 245e61bf..ff48037f 100644 --- a/ts/src/adapters/outbound/keystore/keystore.test.ts +++ b/ts/src/adapters/outbound/keystore/keystore.test.ts @@ -341,3 +341,54 @@ describe("password sentinel queries", () => { expect(ks.verifyPassword("wrong")).toBe(false); }); }); + +describe("changePassword", () => { + it("re-encrypts every software blob and the verifier with the new password", () => { + const root = mkdtempSync(join(tmpdir(), "ks-change-password-")); + const store = new AtomicFileStore(); + const ks = new Keystore(root, store, () => "OldPw1!aa"); + ks.import({ secret: MNEMONIC, type: "seed", label: "seed" }); + const keyRef = ks.import({ + secret: "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + type: "privateKey", + label: "hot", + }).accountId; + const source = ks.resolveAccount(keyRef).wallet.source; + const keyId = source.type === "privateKey" ? source.keyId : ""; + + const receipt = ks.changePassword("OldPw1!aa", "NewPw2@bb"); + expect(receipt.count).toBe(2); + expect(receipt.wallets).toHaveLength(2); + expect(ks.verifyPassword("OldPw1!aa")).toBe(false); + expect(ks.verifyPassword("NewPw2@bb")).toBe(true); + const ks2 = new Keystore(root, store, () => "NewPw2@bb"); + expect(() => ks2.decryptKey(keyId)).not.toThrow(); + }, 15_000); + + it("rejects a wrong old password without touching any file", () => { + const root = mkdtempSync(join(tmpdir(), "ks-change-password-")); + const ks = new Keystore(root, new AtomicFileStore(), () => "OldPw1!aa"); + ks.import({ secret: MNEMONIC, type: "seed" }); + expect(() => ks.changePassword("WrongPw1!x", "NewPw2@bb")).toThrow(/incorrect master password/); + expect(ks.verifyPassword("OldPw1!aa")).toBe(true); + }); + + it("throws no_software_wallet when only watch/ledger wallets exist", () => { + const root = mkdtempSync(join(tmpdir(), "ks-change-password-")); + const ksWatchOnly = new Keystore(root, new AtomicFileStore(), () => "OldPw1!aa"); + ksWatchOnly.registerWatch({ family: "tron", address: "Twatch-only" }); + expect(() => ksWatchOnly.changePassword("OldPw1!aa", "NewPw2@bb")).toThrow(/no software wallet/); + }); + + it("maps a write failure to io_error and leaves the keystore usable under the old password", () => { + const root = mkdtempSync(join(tmpdir(), "ks-change-password-")); + const store = new AtomicFileStore(); + const ks = new Keystore(root, store, () => "OldPw1!aa"); + ks.import({ secret: MNEMONIC, type: "seed" }); + store.writeJsonAll = () => { throw new Error("disk full"); }; + expect(() => ks.changePassword("OldPw1!aa", "NewPw2@bb")).toThrowError( + expect.objectContaining({ code: "io_error" }), + ); + expect(ks.verifyPassword("OldPw1!aa")).toBe(true); + }, 15_000); +}); diff --git a/ts/src/adapters/outbound/persistence/fs/index.ts b/ts/src/adapters/outbound/persistence/fs/index.ts index 95f2bfb8..6724cd17 100644 --- a/ts/src/adapters/outbound/persistence/fs/index.ts +++ b/ts/src/adapters/outbound/persistence/fs/index.ts @@ -51,6 +51,24 @@ export class AtomicFileStore { renameSync(tmp, path); // atomic replace on same filesystem } + /** transactional-ish multi-file write: stage every temp first, then rename all into place. + * A failure while staging unlinks the temps and leaves every target untouched. */ + writeJsonAll(entries: Array<{ path: string; value: unknown }>): void { + const staged: Array<{ tmp: string; path: string }> = []; + try { + for (const { path, value } of entries) { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${this.#counter++}.tmp`; + staged.push({ tmp, path }); + writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + } + } catch (e) { + for (const { tmp } of staged) { try { unlinkSync(tmp); } catch { /* best-effort */ } } + throw e; + } + for (const { tmp, path } of staged) renameSync(tmp, path); // atomic per file + } + writeText(path: string, text: string): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.${process.pid}.${this.#counter++}.tmp`; diff --git a/ts/src/application/ports/chain/tron-gateway.ts b/ts/src/application/ports/chain/tron-gateway.ts index 6b3a599a..6ac8bb19 100644 --- a/ts/src/application/ports/chain/tron-gateway.ts +++ b/ts/src/application/ports/chain/tron-gateway.ts @@ -120,6 +120,26 @@ export interface TronFeeEstimate extends FeeReport { energy: number; } +/** one V2 delegation record; addresses base58, SUN quantities strings, expiry epoch-ms or null. */ +export interface TronDelegatedResource { + from: string; + to: string; + balanceForEnergySun: string; + balanceForBandwidthSun: string; + expireTimeForEnergy: number | null; + expireTimeForBandwidth: number | null; +} + +/** getnodeinfo subset the CLI consumes; best-effort — public gateways may omit fields. */ +export interface TronNodeInfo { + block?: string; // "Num:84120345,ID:…" + solidityBlock?: string; + currentConnectCount?: number; + activeConnectCount?: number; + configNodeInfo?: { codeVersion?: string; p2pVersion?: string; [key: string]: unknown }; + [key: string]: unknown; +} + /** TRON-specific application boundary; chain-specific capabilities remain explicit. */ export interface TronGateway extends Broadcaster { getNativeBalance(address: string): Promise; @@ -128,6 +148,15 @@ export interface TronGateway extends Broadcaster { getBlock(number?: string): Promise; getTransactionById(txid: string): Promise; getTransactionInfoById(txid: string): Promise; + getChainParameters(): Promise>; + getEnergyPrices(): Promise; + getBandwidthPrices(): Promise; + getNodeInfo(): Promise; + getDelegatedResourceV2(from: string, to: string): Promise; + getDelegatedIndexV2(address: string): Promise<{ fromAccounts: string[]; toAccounts: string[] }>; + getCanDelegatedMaxSize(address: string, resource: RpcResourceCode): Promise; + getCanWithdrawUnfreezeAmount(address: string): Promise; + getAvailableUnfreezeCount(address: string): Promise; decodeTransaction(transaction: TronTx): DecodedTronTransaction; getTrc20Balance(contract: string, address: string): Promise; getTokenInfo(contract: string): Promise; diff --git a/ts/src/application/ports/wallet-repository.ts b/ts/src/application/ports/wallet-repository.ts index f2363002..55e4ecbb 100644 --- a/ts/src/application/ports/wallet-repository.ts +++ b/ts/src/application/ports/wallet-repository.ts @@ -29,6 +29,7 @@ export interface WalletRepository extends AccountStore { label: string; }; setActive(refOrLabel: string): { accountId: AccountRef; previous: AccountRef | null }; + changePassword(oldPassword: string, newPassword: string): { wallets: string[]; count: number }; delete(refOrWallet: string): { accountId: AccountRef; scope: "account" | "wallet"; @@ -37,4 +38,3 @@ export interface WalletRepository extends AccountStore { }; revealMnemonic(vaultId: string): { mnemonic: string; passphraseSet: boolean }; } - diff --git a/ts/src/application/use-cases/config-service.test.ts b/ts/src/application/use-cases/config-service.test.ts index e8b9d52d..af7b1591 100644 --- a/ts/src/application/use-cases/config-service.test.ts +++ b/ts/src/application/use-cases/config-service.test.ts @@ -4,7 +4,7 @@ import type { ConfigDocumentRepository } from "../ports/config-document-reposito import type { NetworkRegistry } from "../ports/network-registry.js"; import type { Config } from "../../domain/types/index.js"; -const effective = { timeoutMs: 60_000, networks: {} } as unknown as Config; +const effective = { timeoutMs: 60_000, waitTimeoutMs: 60_000, networks: {} } as unknown as Config; const networks = {} as NetworkRegistry; function service(): { svc: ConfigService; update: ReturnType } { @@ -29,3 +29,29 @@ describe("ConfigService timeoutMs validation", () => { }); }); }); + +describe("ConfigService waitTimeoutMs", () => { + it("shows waitTimeoutMs in the full view and single-key read", () => { + const { svc } = service(); + expect(svc.execute({}, effective, networks)).toMatchObject({ waitTimeoutMs: 60_000 }); + expect(svc.execute({ key: "waitTimeoutMs" }, effective, networks)).toMatchObject({ + key: "waitTimeoutMs", + value: 60_000, + }); + }); + + it("accepts 0 and positive integers, rejects negatives and non-numbers", () => { + const { svc, update } = service(); + expect(svc.execute({ key: "waitTimeoutMs", value: "0" }, effective, networks)).toMatchObject({ + key: "waitTimeoutMs", + value: 0, + }); + expect(svc.execute({ key: "waitTimeoutMs", value: "120000" }, effective, networks)).toMatchObject({ + key: "waitTimeoutMs", + value: 120000, + }); + expect(() => svc.execute({ key: "waitTimeoutMs", value: "-1" }, effective, networks)).toThrow(/non-negative/); + expect(() => svc.execute({ key: "waitTimeoutMs", value: "abc" }, effective, networks)).toThrow(/non-negative/); + expect(update).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ts/src/application/use-cases/config-service.ts b/ts/src/application/use-cases/config-service.ts index 7c1e10cf..1b9aabbb 100644 --- a/ts/src/application/use-cases/config-service.ts +++ b/ts/src/application/use-cases/config-service.ts @@ -3,8 +3,8 @@ import type { NetworkRegistry } from "../ports/network-registry.js"; import { UsageError } from "../../domain/errors/index.js"; import type { ConfigDocumentRepository } from "../ports/config-document-repository.js"; -export const CONFIG_KEYS = ["defaultNetwork", "defaultOutput", "timeoutMs", "networks"] as const; -export const WRITABLE_CONFIG_KEYS = ["defaultNetwork", "defaultOutput", "timeoutMs"] as const; +export const CONFIG_KEYS = ["defaultNetwork", "defaultOutput", "timeoutMs", "waitTimeoutMs", "networks"] as const; +export const WRITABLE_CONFIG_KEYS = ["defaultNetwork", "defaultOutput", "timeoutMs", "waitTimeoutMs"] as const; export type ConfigKey = (typeof CONFIG_KEYS)[number]; export type WritableConfigKey = (typeof WRITABLE_CONFIG_KEYS)[number]; @@ -25,6 +25,7 @@ export class ConfigService { defaultNetwork: effective.defaultNetwork, defaultOutput: effective.defaultOutput, timeoutMs: effective.timeoutMs, + waitTimeoutMs: effective.waitTimeoutMs, networks: Object.keys(effective.networks), }; if (input.key === undefined) return view; @@ -53,6 +54,13 @@ export class ConfigService { } return value; } + if (key === "waitTimeoutMs") { + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) { + throw new UsageError("invalid_value", "waitTimeoutMs must be a non-negative integer"); + } + return value; + } if (key === "defaultOutput") { if (raw !== "text" && raw !== "json") { throw new UsageError("invalid_value", "defaultOutput must be 'text' or 'json'"); diff --git a/ts/src/application/use-cases/tron/chain-service.test.ts b/ts/src/application/use-cases/tron/chain-service.test.ts new file mode 100644 index 00000000..a4f83044 --- /dev/null +++ b/ts/src/application/use-cases/tron/chain-service.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { TronChainService } from "./chain-service.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; + +const net = { + id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [], + httpEndpoint: "https://nile.trongrid.io", +} as NetworkDescriptor; + +function svc(gateway: Record) { + return new TronChainService({ get: () => gateway } as unknown as ChainGatewayProvider); +} + +describe("TronChainService.params", () => { + const gateway = { getChainParameters: async () => [{ key: "getEnergyFee", value: 210 }, { key: "getMemoFee", value: 1000000 }] }; + it("lists all parameters", async () => { + expect(await svc(gateway).params(net)).toEqual({ params: [{ key: "getEnergyFee", value: 210 }, { key: "getMemoFee", value: 1000000 }] }); + }); + it("returns a single key, and not_found for unknown keys", async () => { + expect(await svc(gateway).params(net, "getEnergyFee")).toEqual({ key: "getEnergyFee", value: 210 }); + await expect(svc(gateway).params(net, "getNope")).rejects.toMatchObject({ code: "not_found" }); + }); +}); + +describe("TronChainService.prices", () => { + it("parses price history strings; current = last segment; memo fee from params", async () => { + const gateway = { + getEnergyPrices: async () => "0:100,1670515200000:210", + getBandwidthPrices: async () => "0:10,1614456000000:1000", + getChainParameters: async () => [{ key: "getMemoFee", value: 1000000 }], + }; + expect(await svc(gateway).prices(net)).toEqual({ + energy: { currentSunPerUnit: 210, history: [{ since: 0, price: 100 }, { since: 1670515200000, price: 210 }] }, + bandwidth: { currentSunPerUnit: 1000, history: [{ since: 0, price: 10 }, { since: 1614456000000, price: 1000 }] }, + memoFeeSun: "1000000", + }); + }); +}); + +describe("TronChainService.node", () => { + it("derives head/solid/lag/inSync; missing best-effort fields become null", async () => { + const now = Date.now(); + const gateway = { + getNodeInfo: async () => ({ + block: "Num:84120345,ID:abc", solidityBlock: "Num:84120326,ID:def", + currentConnectCount: 30, activeConnectCount: 27, + configNodeInfo: { codeVersion: "4.7.7", p2pVersion: "11111" }, + }), + getBlock: async () => ({ block_header: { raw_data: { number: 84120345, timestamp: now - 2000 } } }), + }; + const view = await svc(gateway).node(net); + expect(view).toMatchObject({ + endpoint: "https://nile.trongrid.io", + version: "java-tron 4.7.7", + p2pVersion: "11111", + headBlock: { number: 84120345, timestamp: now - 2000 }, + solidBlock: { number: 84120326 }, + lagBlocks: 19, + inSync: true, + peers: { connected: 30, active: 27 }, + }); + }); + it("nulls unexposed fields (public gateway)", async () => { + const gateway = { + getNodeInfo: async () => ({}), + getBlock: async () => ({ block_header: { raw_data: { number: 100, timestamp: Date.now() - 60_000 } } }), + }; + const view = await svc(gateway).node(net); + expect(view.version).toBeNull(); + expect(view.peers).toBeNull(); + expect(view.inSync).toBe(false); + }); +}); diff --git a/ts/src/application/use-cases/tron/chain-service.ts b/ts/src/application/use-cases/tron/chain-service.ts new file mode 100644 index 00000000..cbddb34d --- /dev/null +++ b/ts/src/application/use-cases/tron/chain-service.ts @@ -0,0 +1,71 @@ +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import { UsageError } from "../../../domain/errors/index.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; + +/** TRON blocks every ~3s; a head older than 3 intervals means the node lags the chain. */ +const IN_SYNC_WINDOW_MS = 9_000; + +/** "ts:price,ts:price,…" (node price timeline) → structured history; current = last segment. */ +function parsePriceTimeline(raw: string): { currentSunPerUnit: number; history: Array<{ since: number; price: number }> } { + const history = raw + .split(",") + .map((seg) => seg.split(":")) + .filter((p) => p.length === 2) + .map(([since, price]) => ({ since: Number(since), price: Number(price) })); + return { currentSunPerUnit: history.at(-1)?.price ?? 0, history }; +} + +/** getnodeinfo "Num:84120345,ID:…" → 84120345 (null if unparsable). */ +function blockNum(s: unknown): number | null { + const m = /Num:(\d+)/.exec(String(s ?? "")); + return m ? Number(m[1]) : null; +} + +export class TronChainService { + constructor(private readonly gateways: ChainGatewayProvider) {} + + async params(network: NetworkDescriptor, key?: string) { + const params = await this.gateways.get(network, "tron").getChainParameters(); + if (key === undefined) return { params }; + const hit = params.find((p) => p.key === key); + if (!hit) throw new UsageError("not_found", `unknown chain parameter: ${key}`); + return { key: hit.key, value: hit.value }; + } + + async prices(network: NetworkDescriptor) { + const gateway = this.gateways.get(network, "tron"); + const [energyRaw, bandwidthRaw, params] = await Promise.all([ + gateway.getEnergyPrices(), + gateway.getBandwidthPrices(), + gateway.getChainParameters(), + ]); + const memo = params.find((p) => p.key === "getMemoFee")?.value; + return { + energy: parsePriceTimeline(energyRaw), + bandwidth: parsePriceTimeline(bandwidthRaw), + memoFeeSun: String(memo ?? 0), + }; + } + + async node(network: NetworkDescriptor) { + const gateway = this.gateways.get(network, "tron"); + const [info, head] = await Promise.all([gateway.getNodeInfo(), gateway.getBlock()]); + const header = ((head as Record)?.block_header?.raw_data ?? {}) as { number?: number; timestamp?: number }; + const headNumber = Number(header.number ?? 0); + const headTimestamp = Number(header.timestamp ?? 0); + const solidNumber = blockNum(info.solidityBlock); + const codeVersion = info.configNodeInfo?.codeVersion; + return { + endpoint: network.httpEndpoint ?? null, + version: codeVersion ? `java-tron ${codeVersion}` : null, + p2pVersion: info.configNodeInfo?.p2pVersion ?? null, + headBlock: { number: headNumber, timestamp: headTimestamp }, + solidBlock: solidNumber === null ? null : { number: solidNumber }, + lagBlocks: solidNumber === null ? null : headNumber - solidNumber, + inSync: headTimestamp > 0 && Date.now() - headTimestamp <= IN_SYNC_WINDOW_MS, + peers: info.currentConnectCount === undefined + ? null + : { connected: Number(info.currentConnectCount), active: Number(info.activeConnectCount ?? 0) }, + }; + } +} diff --git a/ts/src/application/use-cases/tron/stake-service.query.test.ts b/ts/src/application/use-cases/tron/stake-service.query.test.ts new file mode 100644 index 00000000..aa3b4555 --- /dev/null +++ b/ts/src/application/use-cases/tron/stake-service.query.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { TronStakeService } from "./stake-service.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; + +const net = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] } as NetworkDescriptor; +const OWNER = "TQkDWJimyBEhkFcqEfCWNbb6tMDwmH1234"; + +function svc(gateway: Record) { + const gateways = { get: () => gateway } as unknown as ChainGatewayProvider; + return new TronStakeService(gateways, {} as TxPipeline); +} +const scope = (addr: string) => ({ resolveAddress: () => addr }) as never; + +describe("TronStakeService.info", () => { + it("aggregates staked split, voting power, resources, unfreezing and slots", async () => { + const gateway = { + getAccount: async () => ({ + frozenV2: [{ type: "ENERGY", amount: "1000000000" }, { amount: "500000000" }], + unfrozenV2: [ + { unfreeze_amount: "500000000", unfreeze_expire_time: 1784073600000 }, + { type: "ENERGY", unfreeze_amount: "300000000", unfreeze_expire_time: 1784160000000 }, + ], + }), + getAccountResources: async () => ({ + EnergyUsed: 12000, EnergyLimit: 65000, NetUsed: 100, NetLimit: 1000, freeNetUsed: 500, freeNetLimit: 500, + tronPowerLimit: 1500, tronPowerUsed: 1000, + }), + getCanWithdrawUnfreezeAmount: async () => "0", + getAvailableUnfreezeCount: async () => 30, + }; + const view = await svc(gateway).info(scope(OWNER), net); + expect(view).toEqual({ + address: OWNER, + 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 }, + }); + }); +}); + +describe("TronStakeService.delegated", () => { + const record = { + from: OWNER, to: "TBy6mQ7Y3nJ8sD2fWpXk4LhVc9Ra1Zt5Ub", + balanceForEnergySun: "500000000", balanceForBandwidthSun: "0", + expireTimeForEnergy: 1783468800000, expireTimeForBandwidth: null, + }; + it("out: lists per-receiver rows with canDelegateMaxSun", async () => { + const gateway = { + getDelegatedIndexV2: async () => ({ fromAccounts: [], toAccounts: [record.to] }), + getDelegatedResourceV2: async () => [record], + getCanDelegatedMaxSize: async (_a: string, r: string) => (r === "ENERGY" ? "900000000" : "300000000"), + }; + const view = await svc(gateway).delegated(scope(OWNER), net, { direction: "out" }); + expect(view).toEqual({ + address: OWNER, + direction: "out", + canDelegateMaxSun: { energy: "900000000", bandwidth: "300000000" }, + delegations: [{ receiver: record.to, resource: "energy", amountSun: "500000000", lockedUntil: 1783468800000 }], + }); + }); + it("in: rows keyed by `from`, no canDelegateMaxSun, resource filter applies", async () => { + const gateway = { + getDelegatedIndexV2: async () => ({ fromAccounts: [record.from], toAccounts: [] }), + getDelegatedResourceV2: async () => [{ ...record, balanceForBandwidthSun: "50000000" }], + }; + const view = await svc(gateway).delegated(scope(record.to), net, { direction: "in", resource: "bandwidth" }); + expect(view).toEqual({ + address: record.to, + direction: "in", + delegations: [{ from: OWNER, resource: "bandwidth", amountSun: "50000000", lockedUntil: null }], + }); + }); +}); + +describe("TronStakeService.votingPower", () => { + it("derives available from limit - used", async () => { + const gateway = { getAccountResources: async () => ({ tronPowerLimit: 1500, tronPowerUsed: 1000 }) }; + expect(await svc(gateway).votingPower(net, OWNER)).toEqual({ total: 1500, used: 1000, available: 500 }); + }); +}); diff --git a/ts/src/application/use-cases/tron/stake-service.ts b/ts/src/application/use-cases/tron/stake-service.ts index 277dc647..5e431a28 100644 --- a/ts/src/application/use-cases/tron/stake-service.ts +++ b/ts/src/application/use-cases/tron/stake-service.ts @@ -1,7 +1,7 @@ import type { NetworkDescriptor, TxReceiptKind, UnsignedTx } from "../../../domain/types/index.js"; import { UsageError } from "../../../domain/errors/index.js"; import { toRpcCode, type Resource } from "../../../domain/resources/index.js"; -import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { AccountScope, TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { TronGateway } from "../../ports/chain/tron-gateway.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; @@ -19,12 +19,122 @@ export interface StakeDelegateInput extends StakeAmountInput { lockPeriod?: string; } +export interface StakeInfoView { + address: string; + staked: { energySun: string; bandwidthSun: string }; + votingPower: { total: number; used: number; available: number }; + resource: { + energy: { used: number; limit: number }; + bandwidth: { used: number; limit: number }; + }; + unfreezing: Array<{ amountSun: string; withdrawableAt: number }>; + withdrawableSun: string; + unfreeze: { used: number; max: number; remaining: number }; +} + +export interface StakeDelegatedView { + address: string; + direction: "out" | "in"; + canDelegateMaxSun?: { energy: string; bandwidth: string }; + delegations: Array<{ + receiver?: string; + from?: string; + resource: Resource; + amountSun: string; + lockedUntil: number | null; + }>; +} + export class TronStakeService { constructor( private readonly gateways: ChainGatewayProvider, private readonly pipeline: TxPipeline, ) {} + /** voting power (TP) — 1 TP = 1 staked TRX. Public: vote status (Leon) consumes this. */ + async votingPower(network: NetworkDescriptor, address: string): Promise<{ total: number; used: number; available: number }> { + const res = await this.gateways.get(network, "tron").getAccountResources(address); + const total = Number((res as Record).tronPowerLimit ?? 0); + const used = Number((res as Record).tronPowerUsed ?? 0); + return { total, used, available: total - used }; + } + + async info(scope: AccountScope, network: NetworkDescriptor): Promise { + const address = scope.resolveAddress("tron"); + const gateway = this.gateways.get(network, "tron"); + const [account, resources, withdrawableSun, remaining] = await Promise.all([ + gateway.getAccount(address), + gateway.getAccountResources(address), + gateway.getCanWithdrawUnfreezeAmount(address), + gateway.getAvailableUnfreezeCount(address), + ]); + // frozenV2: `type` absent = BANDWIDTH (node omits default-enum fields). + const sum = (entries: Array<{ type?: unknown; amount?: string }>, type: "ENERGY" | "BANDWIDTH") => + entries + .filter((f) => (f.type ?? "BANDWIDTH") === type) + .reduce((acc, f) => acc + BigInt(f.amount ?? "0"), 0n) + .toString(); + const frozen = account.frozenV2 ?? []; + const unfrozen = account.unfrozenV2 ?? []; + const r = resources as Record; + const total = Number(r.tronPowerLimit ?? 0); + const used = Number(r.tronPowerUsed ?? 0); + return { + address, + staked: { energySun: sum(frozen, "ENERGY"), bandwidthSun: sum(frozen, "BANDWIDTH") }, + votingPower: { total, used, available: total - used }, + resource: { + energy: { used: Number(r.EnergyUsed ?? 0), limit: Number(r.EnergyLimit ?? 0) }, + // bandwidth = free allowance + staked allowance (node reports them separately). + bandwidth: { + used: Number(r.NetUsed ?? 0) + Number(r.freeNetUsed ?? 0), + limit: Number(r.NetLimit ?? 0) + Number(r.freeNetLimit ?? 0), + }, + }, + unfreezing: unfrozen.map((u) => ({ + amountSun: String((u as Record).unfreeze_amount ?? "0"), + withdrawableAt: Number((u as Record).unfreeze_expire_time ?? 0), + })), + withdrawableSun, + unfreeze: { used: unfrozen.length, max: 32, remaining }, + }; + } + + async delegated( + scope: AccountScope, + network: NetworkDescriptor, + input: { direction: "out" | "in"; resource?: Resource; to?: string }, + ): Promise { + const address = scope.resolveAddress("tron"); + const gateway = this.gateways.get(network, "tron"); + const index = await gateway.getDelegatedIndexV2(address); + const out = input.direction === "out"; + const counterparties = out ? (input.to ? [input.to] : index.toAccounts) : index.fromAccounts; + const records = ( + await Promise.all( + counterparties.map((cp) => gateway.getDelegatedResourceV2(out ? address : cp, out ? cp : address)), + ) + ).flat(); + // one record carries both directions' balances; emit one row per resource with balance > 0. + const rows = records.flatMap((rec) => { + const counterparty = out ? rec.to : rec.from; + const key = out ? "receiver" : "from"; + const both = [ + { resource: "energy" as const, amountSun: rec.balanceForEnergySun, lockedUntil: rec.expireTimeForEnergy }, + { resource: "bandwidth" as const, amountSun: rec.balanceForBandwidthSun, lockedUntil: rec.expireTimeForBandwidth }, + ]; + return both + .filter((b) => b.amountSun !== "0" && (!input.resource || b.resource === input.resource)) + .map((b) => ({ [key]: counterparty, ...b })); + }); + if (!out) return { address, direction: input.direction, delegations: rows }; + const [energy, bandwidth] = await Promise.all([ + gateway.getCanDelegatedMaxSize(address, "ENERGY"), + gateway.getCanDelegatedMaxSize(address, "BANDWIDTH"), + ]); + return { address, direction: input.direction, canDelegateMaxSun: { energy, bandwidth }, delegations: rows }; + } + freeze(scope: TransactionScope, network: NetworkDescriptor, input: StakeAmountInput) { return this.transact("stake-freeze", scope, network, input, (gateway, owner) => gateway.buildFreezeV2(owner, input.amountSun, toRpcCode(input.resource))); diff --git a/ts/src/application/use-cases/tron/vote-service.test.ts b/ts/src/application/use-cases/tron/vote-service.test.ts index 39695298..6d10a604 100644 --- a/ts/src/application/use-cases/tron/vote-service.test.ts +++ b/ts/src/application/use-cases/tron/vote-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { TronVoteService } from "./vote-service.js"; +import { TronStakeService } from "./stake-service.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; @@ -23,16 +24,16 @@ const scope: TransactionScope = { function service(gateway: Partial, pipeline?: Partial) { const g = gateway as TronGateway; - return new TronVoteService( - { get: () => g } as unknown as ChainGatewayProvider, - { run: async () => ({ stage: "submitted", txId: "txid" }), ...pipeline } as unknown as TxPipeline, - ); + const gateways = { get: () => g } as unknown as ChainGatewayProvider; + const pipe = { run: async () => ({ stage: "submitted", txId: "txid" }), ...pipeline } as unknown as TxPipeline; + // voting power now comes from the injected stake service (reads getAccountResources). + return new TronVoteService(gateways, pipe, new TronStakeService(gateways, pipe)); } describe("TronVoteService.cast", () => { it("rejects a full vote allocation above voting power", async () => { const svc = service({ - getAccount: async () => ({ frozenV2: [{ amount: "100000000" }] }), + getAccountResources: async () => ({ tronPowerLimit: 100, tronPowerUsed: 0 } as never), }); await expect(svc.cast(scope, NET, { for: [`${SR1}=101`] })).rejects.toMatchObject({ code: "insufficient_voting_power", @@ -41,7 +42,7 @@ describe("TronVoteService.cast", () => { it("echoes parsed votes and total votes after pipeline submission", async () => { const svc = service({ - getAccount: async () => ({ frozenV2: [{ amount: "1000000000" }] }), + getAccountResources: async () => ({ tronPowerLimit: 1000, tronPowerUsed: 0 } as never), buildVoteWitness: async () => ({}), }); await expect(svc.cast(scope, NET, { for: [`${SR1}=6`, `${SR2}=4`] })).resolves.toMatchObject({ @@ -74,22 +75,25 @@ describe("TronVoteService.list/status", () => { }); }); - it("reports current voting power, reward, votes, and zero-ratio warning", async () => { + it("reports current voting power, reward, votes, and warns via scope.warn on a zero reward ratio", async () => { + const warnings: string[] = []; + const warnScope = { ...scope, warn: (m: string) => warnings.push(m) }; const svc = service({ - getAccount: async () => ({ - frozenV2: [{ amount: "1500000000" }], - votes: [{ vote_address: SR2, vote_count: "400" }], - }), + getAccount: async () => ({ votes: [{ vote_address: SR2, vote_count: "400" }] }), + getAccountResources: async () => ({ tronPowerLimit: 1500, tronPowerUsed: 400 } as never), getReward: async () => "12345678", getWitnesses: async () => [{ address: SR2, voteCount: "200", url: "https://beta.example" }], getBrokerage: async () => 100, }); - await expect(svc.status(scope, NET)).resolves.toMatchObject({ + const result = await svc.status(warnScope, NET); + expect(result).toMatchObject({ address: OWNER, votingPower: { total: 1500, used: 400, available: 1100 }, claimableRewardSun: "12345678", votes: [{ witness: SR2, name: "beta.example", count: 400, rewardRatioPct: 0, brokeragePct: 100, aprPct: null }], - __walletCliWarnings: [`zero_reward_ratio: 400 votes on ${SR2} (beta.example) earn nothing: reward ratio is 0%`], }); + // zero-ratio warning now goes through the standard warn channel, not a data key. + expect(result).not.toHaveProperty("__walletCliWarnings"); + expect(warnings).toEqual([`400 votes on ${SR2} (beta.example) earn nothing: reward ratio is 0%`]); }); }); diff --git a/ts/src/application/use-cases/tron/vote-service.ts b/ts/src/application/use-cases/tron/vote-service.ts index 7a65fd48..59b77461 100644 --- a/ts/src/application/use-cases/tron/vote-service.ts +++ b/ts/src/application/use-cases/tron/vote-service.ts @@ -6,7 +6,6 @@ import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { TronAccount, - TronFrozenBalance, TronGateway, TronVote, TronVoteAllocation, @@ -15,12 +14,13 @@ import type { import type { TxPipeline } from "../../services/pipeline/index.js"; import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { TronStakeService } from "./stake-service.js"; const MAX_VOTE_ITEMS = 30; const MAX_REWARDED_RANK = 127; const ELECTED_SR_COUNT = 27; -const SUN_PER_TP = 1_000_000n; -const COMMAND_WARNINGS_KEY = "__walletCliWarnings"; +/** cap on concurrent getBrokerage RPCs so `vote list --limit 127` doesn't open 127 sockets at once. */ +const BROKERAGE_CONCURRENCY = 8; export interface VoteCastInput extends TransactionModeInput { for: string[]; @@ -34,8 +34,13 @@ export interface VoteListInput { interface VoteReadScope { readonly activeAccount: TransactionScope["activeAccount"]; resolveAddress(family: "tron"): string; + /** surface a non-fatal warning (→ meta.warnings + stderr), same channel as --wait warnings. */ + warn(message: string): void; } +/** per-request brokerage cache: dedupes repeat addresses (a current vote's SR also in the list). */ +type BrokerageCache = Map>; + interface WitnessView { rank: number; name: string; @@ -59,6 +64,9 @@ export class TronVoteService { constructor( private readonly gateways: ChainGatewayProvider, private readonly pipeline: TxPipeline, + /** voting power (TP) is read authoritatively from the node via the stake service — + * the same source `stake info` uses — rather than recomputed from raw account balances. */ + private readonly stake: TronStakeService, ) {} async cast(scope: TransactionScope, network: NetworkDescriptor, input: VoteCastInput) { @@ -69,12 +77,11 @@ export class TronVoteService { throw new UsageError("invalid_value", "total votes exceed the safe-integer limit for this client"); } const owner = scope.resolveAddress("tron"); - const account = await gateway.getAccount(owner); - const votingPower = votingPowerOf(account); - if (totalVotes > votingPower.total) { + const votingPower = await this.stake.votingPower(network, owner); + if (totalVotes > BigInt(votingPower.total)) { throw new UsageError( "insufficient_voting_power", - `total votes ${totalVotes.toString()} exceed voting power ${votingPower.total.toString()} TP`, + `total votes ${totalVotes.toString()} exceed voting power ${votingPower.total} TP`, ); } const outcome = await this.pipeline.run({ @@ -101,28 +108,29 @@ export class TronVoteService { const witnesses = (await gateway.getWitnesses(limit)) .sort((a, b) => compareUnsigned(b.voteCount, a.voteCount)) .slice(0, limit); + const cache: BrokerageCache = new Map(); return { - witnesses: await Promise.all(witnesses.map((witness, index) => - this.witnessView(gateway, witness, index + 1), - )), + witnesses: await mapWithLimit(witnesses, BROKERAGE_CONCURRENCY, (witness, index) => + this.witnessView(gateway, witness, index + 1, cache), + ), }; } async status(scope: VoteReadScope, network: NetworkDescriptor) { const gateway = this.gateways.get(network, "tron"); const address = scope.resolveAddress("tron"); - const [account, claimableRewardSun, witnesses] = await Promise.all([ + const [account, claimableRewardSun, witnesses, votingPower] = await Promise.all([ gateway.getAccount(address), gateway.getReward(address), gateway.getWitnesses(MAX_REWARDED_RANK).catch((): TronWitness[] => []), + this.stake.votingPower(network, address), ]); const witnessMap = new Map(witnesses.map((witness, index) => [witness.address, { witness, rank: index + 1 }])); const currentVotes = normalizeAccountVotes(account); - const votingPower = votingPowerOf(account); - const used = currentVotes.reduce((sum, vote) => sum + BigInt(vote.count), 0n); - const votes = await Promise.all(currentVotes.map(async (vote): Promise => { + const cache: BrokerageCache = new Map(); + const votes = await mapWithLimit(currentVotes, BROKERAGE_CONCURRENCY, async (vote): Promise => { const known = witnessMap.get(vote.witness); - const brokeragePct = await gateway.getBrokerage(vote.witness).catch(() => null); + const brokeragePct = await brokerageOf(gateway, vote.witness, cache); return { witness: vote.witness, name: known ? witnessName(known.witness) : vote.witness, @@ -131,23 +139,21 @@ export class TronVoteService { rewardRatioPct: rewardRatioOf(brokeragePct), aprPct: null, }; - })); - const warnings = zeroRewardWarnings(votes); + }); + // zero-reward-ratio votes earn nothing: warn via the standard channel (→ meta.warnings + stderr). + for (const vote of votes) { + if (vote.rewardRatioPct === 0) scope.warn(zeroRatioWarning(vote)); + } return { address, - votingPower: { - total: Number(votingPower.total), - used: Number(used), - available: Number(votingPower.total > used ? votingPower.total - used : 0n), - }, + votingPower, claimableRewardSun, votes, - ...(warnings.length ? { [COMMAND_WARNINGS_KEY]: warnings } : {}), }; } - private async witnessView(gateway: TronGateway, witness: TronWitness, rank: number): Promise { - const brokeragePct = await gateway.getBrokerage(witness.address).catch(() => null); + private async witnessView(gateway: TronGateway, witness: TronWitness, rank: number, cache: BrokerageCache): Promise { + const brokeragePct = await brokerageOf(gateway, witness.address, cache); return { rank, name: witnessName(witness), @@ -185,26 +191,6 @@ function parseVoteInputs(values: string[]): TronVote[] { }); } -function votingPowerOf(account: TronAccount): { total: bigint } { - let totalSun = 0n; - for (const frozen of account.frozen ?? []) totalSun += quantity(frozen.frozen_balance); - const resource = objectOf(account.account_resource); - totalSun += nestedQuantity(resource.frozen_balance_for_energy, "frozen_balance"); - totalSun += quantity(account.delegated_frozen_balance_for_bandwidth); - totalSun += quantity(resource.delegated_frozen_balance_for_energy); - for (const frozen of account.frozenV2 ?? []) { - if (!isTronPowerOnlyStake(frozen)) totalSun += quantity(frozen.amount); - } - totalSun += quantity(account.delegated_frozenV2_balance_for_bandwidth); - totalSun += quantity(resource.delegated_frozenV2_balance_for_energy); - return { total: totalSun / SUN_PER_TP }; -} - -function isTronPowerOnlyStake(frozen: TronFrozenBalance): boolean { - const type = String(frozen.type ?? "").toUpperCase(); - return type === "TRON_POWER" || type === "2"; -} - function normalizeAccountVotes(account: TronAccount): TronVote[] { return (account.votes ?? []) .map(normalizeAccountVote) @@ -218,10 +204,34 @@ function normalizeAccountVote(vote: TronVoteAllocation): TronVote | null { return { witness, count }; } -function zeroRewardWarnings(votes: CurrentVoteView[]): string[] { - return votes - .filter((vote) => vote.rewardRatioPct === 0) - .map((vote) => `zero_reward_ratio: ${vote.count} votes on ${vote.witness} (${vote.name}) earn nothing: reward ratio is 0%`); +function zeroRatioWarning(vote: CurrentVoteView): string { + return `${vote.count} votes on ${vote.witness} (${vote.name}) earn nothing: reward ratio is 0%`; +} + +/** getBrokerage with a per-request cache — a given SR is fetched at most once, and failures + * degrade to null (unknown reward ratio). */ +function brokerageOf(gateway: TronGateway, address: string, cache: BrokerageCache): Promise { + let pending = cache.get(address); + if (!pending) { + pending = gateway.getBrokerage(address).catch(() => null); + cache.set(address, pending); + } + return pending; +} + +/** map with bounded concurrency (results keep input order), so a large witness list doesn't + * fan out one socket per item. */ +async function mapWithLimit(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise { + const results = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const index = next++; + results[index] = await fn(items[index]!, index); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; } function rewardRatioOf(brokeragePct: number | null): number | null { @@ -258,11 +268,3 @@ function quantity(value: unknown): bigint { if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value); return 0n; } - -function nestedQuantity(value: unknown, key: string): bigint { - return quantity(objectOf(value)[key]); -} - -function objectOf(value: unknown): Record { - return value && typeof value === "object" ? value as Record : {}; -} diff --git a/ts/src/application/use-cases/wallet-service.ts b/ts/src/application/use-cases/wallet-service.ts index 222eca52..61607cc7 100644 --- a/ts/src/application/use-cases/wallet-service.ts +++ b/ts/src/application/use-cases/wallet-service.ts @@ -63,6 +63,10 @@ export class WalletService { return { previousLabel: result.previousLabel, ...this.wallets.describe(result.accountId) }; } + changePassword(oldPassword: string, newPassword: string) { + return this.wallets.changePassword(oldPassword, newPassword); + } + derive(seedId: string, index?: number, label?: string) { // --seed-id is strictly the seed id (wlt_…) — the HD group header in `list`. No labels, no // sub-account refs: labels/refs point at an account, and the seed (not an account) is the root. diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 062544e5..f9c4cb72 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -37,6 +37,7 @@ import { txStatusTronBinding, } from "../../adapters/inbound/cli/commands/tx.js"; import { stakeDefinitions } from "../../adapters/inbound/cli/commands/stake.js"; +import { chainDefinitions } from "../../adapters/inbound/cli/commands/chain.js"; import { voteCastSpec, voteCastTronBinding, @@ -69,6 +70,7 @@ import { TronContractService } from "../../application/use-cases/tron/contract-s import { TronStakeService } from "../../application/use-cases/tron/stake-service.js"; import { TronVoteService } from "../../application/use-cases/tron/vote-service.js"; import { TronRewardService } from "../../application/use-cases/tron/reward-service.js"; +import { TronChainService } from "../../application/use-cases/tron/chain-service.js"; import { TronBlockService } from "../../application/use-cases/tron/block-service.js"; import { MessageService } from "../../application/use-cases/message-service.js"; import type { ChainGatewayProvider } from "../../application/ports/chain/gateway-provider.js"; @@ -104,8 +106,9 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC const message = new MessageService(deps.signers); const transaction = new TronTransactionService(deps.gateways, deps.tokens, deps.transactions); const stake = new TronStakeService(deps.gateways, deps.transactions); - const vote = new TronVoteService(deps.gateways, deps.transactions); + const vote = new TronVoteService(deps.gateways, deps.transactions, stake); const reward = new TronRewardService(deps.gateways, deps.transactions); + const chain = new TronChainService(deps.gateways); const contract = new TronContractService(deps.gateways, deps.transactions); reg.addChain(blockSpec, "tron", blockTronBinding(new TronBlockService(deps.gateways))); @@ -131,6 +134,9 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC reg.addChain(voteStatusSpec, "tron", voteStatusTronBinding(vote)); reg.addChain(rewardBalanceSpec, "tron", rewardBalanceTronBinding(reward)); reg.addChain(rewardWithdrawSpec, "tron", rewardWithdrawTronBinding(reward)); + for (const definition of chainDefinitions(chain)) { + reg.addChain(definition.spec, "tron", definition.binding); + } reg.addChain(contractCallSpec, "tron", contractCallTronBinding(contract)); reg.addChain(contractSendSpec, "tron", contractSendTronBinding(contract)); reg.addChain(contractDeploySpec, "tron", contractDeployTronBinding(contract)); diff --git a/ts/src/bootstrap/runner.test.ts b/ts/src/bootstrap/runner.test.ts index 3496d618..aa58bfbc 100644 --- a/ts/src/bootstrap/runner.test.ts +++ b/ts/src/bootstrap/runner.test.ts @@ -47,10 +47,9 @@ describe("parseGlobals", () => { }); it("maps ---stdin to a '-' path (the only secret source)", () => { - const { secretPaths } = parseGlobals(["import", "mnemonic", "--mnemonic-stdin"]); - expect(secretPaths.mnemonic).toBe("-"); + const { secretPaths } = parseGlobals(["tx", "broadcast", "--tx-stdin"]); + expect(secretPaths.tx).toBe("-"); expect(secretPaths.password).toBeUndefined(); - expect(secretPaths.privateKey).toBeUndefined(); }); it("ignores a value flag with no following token at end of argv", () => { diff --git a/ts/src/domain/types/network.ts b/ts/src/domain/types/network.ts index 389f3bee..b83dc51b 100644 --- a/ts/src/domain/types/network.ts +++ b/ts/src/domain/types/network.ts @@ -38,6 +38,8 @@ export interface Config { defaultNetwork?: string; defaultOutput: OutputMode; timeoutMs: number; + /** default polling cap for broadcast commands' --wait, in ms (overridden by --wait-timeout). */ + waitTimeoutMs: number; networks: Record; /** USD-valuation source for `account portfolio`. Missing → builtin CoinGecko. */ price?: PriceConfig; diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index c423e11d..17d1f1e0 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -123,7 +123,8 @@ describe("golden CLI — meta & introspection", () => { expect(cmd.requires).toMatchObject({ network: "optional", auth: "required", wallet: "optional" }); expect(cmd.inputSchema.properties.to).toBeDefined(); const importMnemonic = r.json.commands.find((c: { id: string }) => c.id === "import.mnemonic"); - expect(importMnemonic.inputFlags.map((g: { flag: string }) => g.flag)).toContain("--mnemonic-stdin"); + // TTY-only setup op: the mnemonic is entered interactively, so there is no --*-stdin input flag. + expect(importMnemonic.inputFlags).toBeUndefined(); const broadcast = r.json.commands.find((c: { id: string }) => c.id === "tx.broadcast"); expect(broadcast.inputFlags.map((g: { flag: string }) => g.flag)).toContain("--tx-stdin"); const importWatch = r.json.commands.find((c: { id: string }) => c.id === "import.watch"); @@ -382,6 +383,29 @@ describe("golden CLI — error contract (exit codes)", () => { expect(r.status).toBe(1); expect(r.json.error.code).toBe("auth_required"); }); + + it("vote cast collects a repeated --for into an array (same SR twice → duplicate, exit 2)", () => { + seedWallet(); + // Proves the CLI collects a repeated flag into an array. If --for were last-wins, only one + // entry would reach the service and there'd be no duplicate; the duplicate error confirms both + // repeated flags arrived. `parseVoteInputs` runs before any RPC, so this needs no network. + const r = run(["--output", "json", "vote", "cast", "--network", "tron:nile", + "--for", `${TRON1}=5`, "--for", `${TRON1}=5`, "--dry-run"]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_value"); + expect(r.json.error.message).toMatch(/duplicate/i); + }); + + it("vote cast delivers a SINGLE --for as a one-element array, not a split string", () => { + seedWallet(); + // A lone `--for foo` (no '='): as a one-element array the whole "foo" is one bad entry; as a + // bare string the service would iterate its characters and complain about 'f'. An error naming + // the whole 'foo' proves yargs `array: true` delivered [ "foo" ]. No RPC (parse fails first). + const r = run(["--output", "json", "vote", "cast", "--network", "tron:nile", "--for", "foo", "--dry-run"]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_value"); + expect(r.json.error.message).toContain("'foo'"); + }); }); describe("golden CLI — token address-book (local, no RPC)", () => { From 4a8dd5a22ca68af91c0f9e63ec144257412c20c3 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 9 Jul 2026 18:58:39 +0800 Subject: [PATCH 05/16] docs: update source of truth and remove others deprecated file --- ...wallet-cli-architecture-source-of-truth.md | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md index dae13d5c..2689b02d 100644 --- a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md +++ b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md @@ -276,13 +276,14 @@ interface FamilyPlugin { | --- | --- | | `path` | Neutral commands use the full path; chain commands use a cross-family logical path. | | `family` | Omitted for neutral commands; when present, the resolved network selects the family implementation. | -| `stdin` | A dedicated stdin channel for `privateKey`, `mnemonic`, `tx`, `message`. | +| `stdin` | A dedicated stdin channel, one of `tx` or `message` (signed-tx JSON / message to sign). Wallet secrets (`mnemonic`, `privateKey`) and the master password are **not** stdin-backed — see `secretsTtyOnly`. | | `network` | `none`, `optional`, `required`; today both optional/required can fall back to the default network. | | `wallet` | `none` or `optional`; optional can override the active account with `--account`. | | `auth` | An unlock declaration for help/catalog; actual software signing uses lazy decrypt. | | `broadcasts` | Controls whether help reveals `--wait`. | | `passwordMode` | `establish` or `verify`, controls interactive master-password priming. | | `interactive` | Only commands that explicitly opt in may open a TTY prompt. | +| `secretsTtyOnly` | The command's secrets (import mnemonic/private-key, `change-password`) are TTY-only; no `--*-stdin` source exists, and dispatch fails fast when not on a TTY. | | `capability` | A per-network capability that must pass before execution. | | `fields` / `input` | Zod field metadata and the complete validation schema. | | `run` | Translates CLI input/context into a use-case call and returns structured data. | @@ -292,7 +293,7 @@ The stable command id is derived from metadata as `path.join(".")` for every com ### 4.2 The Two Command Classes and Routing -A chain command is one `ChainCommandDefinition` — a service-free `ChainSpec` plus a `families` table of per-family `FamilyBinding`s (`run` + optional option delta). The registry keys it by logical path; dispatch resolves the network, then selects the binding by `network.family`. +A chain command is one `ChainCommandDefinition` — a service-free `ChainSpec` plus a `families` table of per-family `FamilyBinding`s (`run` + optional `fields`/`refine` delta). The registry keys it by logical path; dispatch resolves the network, then selects the binding by `network.family`. When the resolved network's family has no binding for that command, dispatch returns `network_family_mismatch`. The merged input schema is `baseFields` plus each binding's `fields`, and validation composes `baseRefine` then each binding's `refine`. `isChainCommand` (presence of `families`) is the discriminator between the two command kinds. ```mermaid flowchart LR @@ -337,16 +338,22 @@ wallet-cli ├── create ├── import mnemonic | private-key | ledger | watch ├── list | use | current | rename | derive | backup | delete +├── change-password ├── config | networks ├── account balance | info | history | portfolio ├── token balance | info | add | list | remove ├── tx send | broadcast | status | info ├── contract call | send | deploy | info -├── stake freeze | unfreeze | withdraw | cancel-unfreeze | delegate | undelegate +├── stake freeze | unfreeze | withdraw | cancel-unfreeze | delegate | undelegate | info | delegated +├── vote cast | list | status +├── reward balance | withdraw +├── chain params | prices | node ├── message sign └── block [number] ``` +`create`, `import`, `list`, `use`, `current`, `rename`, `derive`, `backup`, `delete`, `change-password`, `config`, and `networks` are neutral. `change-password` re-encrypts every software keystore under a new master password; both the old and new passwords are entered interactively (TTY-only). `stake info`/`delegated`, `vote status`, `reward balance`, and `chain *` are read-only chain queries; `vote cast` and `reward withdraw` are transaction-creating. + Neutral commands do not touch a chain. Chain commands are currently all provided by the TRON plugin. All transaction-creating commands jointly support: - `--dry-run`: build + estimate, no decrypt, no sign, no broadcast. @@ -364,7 +371,7 @@ Neutral commands do not touch a chain. Chain commands are currently all provided | `--timeout` | Timeout for a single RPC/device operation. | | `--verbose` / `-v` | Additional diagnostics. | | `--wait` | Poll for confirmation after broadcast. | -| `--wait-timeout` | Upper bound for confirmation polling, default 60000 ms. | +| `--wait-timeout` | Upper bound for confirmation polling; defaults to `config.waitTimeoutMs` (built-in 60000 ms). | | `--password-stdin` | Read the master password from fd 0. | | `--help` / `--version` / `--json-schema` | Meta requests. | @@ -436,7 +443,7 @@ Application defines capabilities, not concrete technologies: | `NetworkRegistry` | canonical network id/default resolution | outbound config registry | | `LedgerDevice` | address, tx/message signing, app config | `Ledger` | | `ChainGatewayProvider` | obtain a gateway by network/family | `ChainGatewayRegistry` | -| `TronGateway` | TRON reads/build/estimate/broadcast | `TronRpcClient` | +| `TronGateway` | TRON reads/build/estimate/broadcast, plus stake/delegation/vote/reward and chain (params/prices/node) queries | `TronRpcClient` | | `TronHistoryReader` | TronGrid transaction history | `TronGridHistoryReader` | | `TokenRepository` | official/user token book | `TokenBook` | | `PriceProvider` | best-effort USD price | CoinGecko/Null provider | @@ -446,10 +453,10 @@ Application defines capabilities, not concrete technologies: ### 7.2 Use Cases -- `WalletService`: create/import/list/use/current/rename/derive/delete/backup, with no knowledge of JSON/Zod/yargs. -- `ConfigService`: effective config view, key validation, canonical network normalization, and document update. +- `WalletService`: create/import/list/use/current/rename/derive/delete/backup/change-password, with no knowledge of JSON/Zod/yargs. `changePassword` re-encrypts every software keystore under a new master password. +- `ConfigService`: effective config view, key validation, canonical network normalization, and document update. Writable keys are `defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`. - `MessageService`: sign a message via the signer port. -- TRON use cases: account, token, transaction, contract, stake, block; they use only the TRON gateway and the necessary shared ports. +- TRON use cases: account, token, transaction, contract, stake, vote, reward, chain, block; they use only the TRON gateway and the necessary shared ports. `TronVoteService` reads voting power authoritatively from `TronStakeService.votingPower` (injected), not from raw balances; its witness/brokerage fan-out is bounded and per-request cached. `TronChainService` exposes governance params, energy/bandwidth prices, and node sync status. An inbound command's responsibility is to turn argv/Zod input and `ExecutionContext` into use-case input and then choose a stable output view; it must not do persistence or provider transport itself. @@ -490,7 +497,9 @@ Canonical-id resolution is case-insensitive. Aliases remain descriptor metadata `ChainGatewayRegistry` is injected with the family factory by Bootstrap and caches the client by network id. Its generic `client()` may only use the truly common minimal capabilities; a family use case obtains the `TronGateway` via the guarded `get(net, "tron")`. TRON staking and the future EVM gas/nonce must not be forced into a universal gateway. -Capabilities consist of two parts: the command-backed keys declared by registered commands, plus the network traits in `NetworkDescriptor.capabilities`. The gate must happen before the use case. +Capabilities consist of two parts: the command-backed keys declared by registered commands (e.g. `vote.cast`, `vote.list`, `vote.status`, `reward.balance`, `reward.withdraw`, `staking.freeze`, `staking.delegate`), plus the network traits in `NetworkDescriptor.capabilities`. The gate must happen before the use case. + +The TRON gateway reads account/resource/stake/vote/reward state and confirms transactions against the **full node's unconfirmed view** (`getUnconfirmedTransactionInfo`, `/wallet/getaccount`, `/wallet/getReward`, `/wallet/getBrokerage`, etc.), not the solidified node. Unconfirmed info is available roughly one block after inclusion (~3s) rather than after solidification (~19 blocks / ~60s), so `--wait` confirms at "mined in a block" quickly and all reads are fresh and mutually consistent. --- @@ -529,7 +538,7 @@ flowchart LR CONFIRM --> FINAL[confirmed / failed
or timeout → submitted] ``` -The pipeline knows only the signer and the `Broadcaster` port; the family use case provides build, estimate, and confirm callbacks. `timeoutMs` limits a single operation; `waitTimeoutMs` limits confirmation polling. Once a transaction has been broadcast, a polling error/timeout must not reclassify the command as not-broadcast — it returns `submitted`. +The pipeline knows only the signer and the `Broadcaster` port; the family use case provides build, estimate, and confirm callbacks. `timeoutMs` limits a single operation; `waitTimeoutMs` (from `config.waitTimeoutMs`, overridable by `--wait-timeout`) limits confirmation polling. TRON's `tronConfirmation` polls the full node's unconfirmed transaction info, so confirmation resolves in a few seconds rather than after solidification. Once a transaction has been broadcast, a polling error/timeout must not reclassify the command as not-broadcast — it returns `submitted`. ### 9.3 Ledger @@ -583,7 +592,7 @@ IDs are random 5-byte Crockford base32 lowercase strings. Labels are case-insens The user entries in `tokens.json` are partitioned by `|`; the effective list is official first, then user-only, deduplicated by `(kind,id)`. Official entries cannot be deleted/overwritten. -`config.yaml` is shallow-merged with the builtin config. The only writable keys are `defaultNetwork`, `defaultOutput`, `timeoutMs`; `networks` is a CLI read-only view. Runtime globals are not written back to config. +`config.yaml` is shallow-merged with the builtin config. The only writable keys are `defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`; `networks` is a CLI read-only view. `waitTimeoutMs` must be a non-negative integer and supplies the default confirmation-polling cap (built-in 60000 ms). Runtime globals are not written back to config. ### 10.4 Encrypted Blobs @@ -605,9 +614,10 @@ flowchart LR - A handler must not read `process.stdin` directly; `StreamManager.readStdinOnce()` reads at most once per execution. - A single invocation may use fd 0 through only one `--*-stdin` channel. +- Only `password` (`--password-stdin`, a global) and the command-scoped `tx` / `message` channels are stdin-backed. Wallet secrets (`mnemonic`, `privateKey`) and the master-password change have **no** stdin flag — they are TTY-only (`secretsTtyOnly`): a hidden interactive prompt, or fail fast with `tty_required` off a TTY. Importing an existing secret and re-keying the vault are deliberately human moments. - Secret argv, `MASTER_PASSWORD`, `--*-file`, and ordinary env secrets are not supported. - A secret must not enter logs, diagnostics, error details, or the result envelope. -- Interactive allowlist: create, the four imports, delete, backup; the order is password → field gap-fill/account selection → command confirm. +- Interactive allowlist: create, the four imports, delete, backup, change-password; the order is password → field gap-fill/account selection → command confirm. --- @@ -641,7 +651,7 @@ flowchart LR GLOBAL[GLOBAL_FLAG_SPECS] --> ARITY & HELP & CATALOG ``` -A hand-maintained command flag table must not be created separately. The public help/output is a stable contract; when it changes, automated tests must verify root, group, leaf, JSON Schema, and functional scenarios. +Arity is derived from the field's Zod type: a `z.array(...)` field projects to a first-class repeatable yargs flag (`array: true`), so `--for a --for b` arrives as a `string[]` with no preprocess/pipe patch, and its help renders the element type (``), not the container. A hand-maintained command flag table must not be created separately. The public help/output is a stable contract; when it changes, automated tests must verify root, group, leaf, JSON Schema, and functional scenarios. --- From 0ee77df7f28b84638cd62b56194cca563e077448 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 14 Jul 2026 11:04:41 +0800 Subject: [PATCH 06/16] feat(ts): add fuller leaf-help descriptions and tree-style unstake list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional multi-line `description` field to command specs so leaf help (` --help`) can carry the doc's user-value semantics (overwrite behavior, TP math, per-call limits, warnings) instead of collapsing to the one-line `summary` used in the parent group's listing. Populate it for vote/reward/ stake/chain/import/change-password commands, extend the group description for vote, and render the stake-info unfreezing list as an aligned tree (├─/└─). Add golden + formatter tests covering the new help copy and layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...wallet-cli-architecture-source-of-truth.md | 2 + ts/src/adapters/inbound/cli/commands/chain.ts | 8 ++ .../adapters/inbound/cli/commands/reward.ts | 7 ++ ts/src/adapters/inbound/cli/commands/stake.ts | 7 ++ .../cli/commands/text-formatters.test.ts | 33 +++++++++ ts/src/adapters/inbound/cli/commands/vote.ts | 11 ++- .../adapters/inbound/cli/commands/wallet.ts | 10 +++ .../adapters/inbound/cli/contracts/command.ts | 9 +++ ts/src/adapters/inbound/cli/help/index.ts | 13 +++- ts/src/adapters/inbound/cli/render/stake.ts | 10 ++- ts/test/golden.test.ts | 73 +++++++++++++++++++ 11 files changed, 178 insertions(+), 5 deletions(-) diff --git a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md index 2689b02d..c4c7e623 100644 --- a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md +++ b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md @@ -653,6 +653,8 @@ flowchart LR Arity is derived from the field's Zod type: a `z.array(...)` field projects to a first-class repeatable yargs flag (`array: true`), so `--for a --for b` arrives as a `string[]` with no preprocess/pipe patch, and its help renders the element type (``), not the container. A hand-maintained command flag table must not be created separately. The public help/output is a stable contract; when it changes, automated tests must verify root, group, leaf, JSON Schema, and functional scenarios. +Leaf-help description text: a command's `summary` is the one-line row shown in its parent group's listing and must stay a single terse line. Leaf help (` --help`) renders `description ?? summary`: a command whose behavior needs more than a headline (overwrite semantics, per-call limits, warnings, frequency caps) SHOULD declare a fuller multi-line `description`; there is no requirement that leaf help collapse to one line. Group descriptions (`GROUP_DESCRIPTIONS`) may likewise span multiple lines. Keep the wording in sync with the product command doc, the source of truth for the exact copy. + --- ## 14. Rules for Adding Features diff --git a/ts/src/adapters/inbound/cli/commands/chain.ts b/ts/src/adapters/inbound/cli/commands/chain.ts index 7f1f0984..11d93bd5 100644 --- a/ts/src/adapters/inbound/cli/commands/chain.ts +++ b/ts/src/adapters/inbound/cli/commands/chain.ts @@ -10,6 +10,7 @@ export function chainDefinitions(service: TronChainService): Array<{ spec: Chain path: ["chain", "params"], network: "optional", wallet: "none", auth: "none", summary: "On-chain governance parameters", + description: "Show on-chain governance parameters. Use --key for one value.", baseFields: z.object({ key: z.string().optional().describe("return only this parameter (e.g. getEnergyFee); omit to list all"), }), @@ -23,6 +24,9 @@ export function chainDefinitions(service: TronChainService): Array<{ spec: Chain path: ["chain", "prices"], network: "optional", wallet: "none", auth: "none", summary: "Energy/bandwidth unit price and memo fee", + description: + "Show current energy/bandwidth unit price (in SUN; 1 TRX = 1,000,000 SUN)\n" + + "and the memo fee.", baseFields: z.object({}), examples: [{ cmd: "wallet-cli chain prices" }], formatText: TextFormatters.chainPrices, @@ -34,6 +38,10 @@ export function chainDefinitions(service: TronChainService): Array<{ spec: Chain path: ["chain", "node"], network: "optional", wallet: "none", auth: "none", summary: "Connected node status (version / sync / peers)", + description: + "Show the connected node's status: version, head/solid block height, sync state,\n" + + "and peer connections. Useful to tell \"node out of sync\" from \"problem with my\n" + + "transaction\". Fields the endpoint does not expose are shown as \"—\" (null in json).", baseFields: z.object({}), examples: [{ cmd: "wallet-cli chain node" }], formatText: TextFormatters.chainNode, diff --git a/ts/src/adapters/inbound/cli/commands/reward.ts b/ts/src/adapters/inbound/cli/commands/reward.ts index 97961b53..75bd1e8d 100644 --- a/ts/src/adapters/inbound/cli/commands/reward.ts +++ b/ts/src/adapters/inbound/cli/commands/reward.ts @@ -9,6 +9,10 @@ export const rewardBalanceSpec: ChainSpec = { network: "optional", wallet: "optional", auth: "none", capability: "reward.balance", summary: "Show claimable voting/block reward and withdraw status", + description: + "Show the currently claimable voting/block reward and whether it can be\n" + + "withdrawn now (rewards accrue continuously but can be withdrawn at most\n" + + "once every 24 hours).", baseFields: z.object({}), examples: [{ cmd: "wallet-cli reward balance" }], formatText: TextFormatters.rewardBalance, @@ -24,6 +28,9 @@ export const rewardWithdrawSpec: ChainSpec = { broadcasts: true, capability: "reward.withdraw", summary: "Withdraw accrued voting/block rewards", + description: + "Withdraw accrued voting/block rewards into your available balance.\n" + + "Rewards can be withdrawn at most once every 24 hours.", baseFields: z.object({ ...txModeFields }), examples: [ { cmd: "wallet-cli reward withdraw" }, diff --git a/ts/src/adapters/inbound/cli/commands/stake.ts b/ts/src/adapters/inbound/cli/commands/stake.ts index 532ba244..a991e6aa 100644 --- a/ts/src/adapters/inbound/cli/commands/stake.ts +++ b/ts/src/adapters/inbound/cli/commands/stake.ts @@ -134,6 +134,9 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain path: ["stake", "info"], network: "optional", wallet: "optional", auth: "none", summary: "Staking & resource overview (staked / voting power / resource / unfreezing / withdrawable)", + description: + "Staking & resource overview: staked amounts, voting power (TP), energy/bandwidth\n" + + "usage, pending unstakes, currently withdrawable TRX, and available unfreeze slots.", baseFields: z.object({}), examples: [{ cmd: "wallet-cli stake info" }, { cmd: "wallet-cli stake info --account main -o json" }], formatText: TextFormatters.stakeInfo, @@ -145,6 +148,10 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain path: ["stake", "delegated"], network: "optional", wallet: "optional", auth: "none", summary: "Delegation details and max delegatable size", + description: + "Delegation details (outbound/inbound) plus the maximum size you can still delegate.\n" + + "Outbound shows \"Locked until\" (you cannot reclaim before then); inbound shows\n" + + "\"Guaranteed until\" (the delegator cannot reclaim before then).", baseFields: z.object({ direction: ciEnum(["out", "in"]).default("out") .describe("out = delegated to others; in = delegated to me"), 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 43855755..780be718 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -63,6 +63,39 @@ describe("stake/chain TRX amount formatting", () => { }); }); +describe("stakeInfo unfreezing list", () => { + const data = { + 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 }, + }; + + it("renders each pending unstake as a tree branch (├─ / └─), last one └─", () => { + const out = TextFormatters.stakeInfo(data, ctx({ accountLabel: "main" })); + const lines = out.split("\n"); + const branch = lines.filter((l) => l.includes("─")); + 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("aligns the branch under the value column", () => { + const out = TextFormatters.stakeInfo(data, ctx({ accountLabel: "main" })); + const lines = out.split("\n"); + const valueCol = lines.find((l) => l.startsWith("Unfreezing"))!.indexOf("2 pending"); + const branchLine = lines.find((l) => l.includes("├─"))!; + expect(branchLine.indexOf("├─")).toBe(valueCol); + }); +}); + describe("tokenBalance formatter", () => { it("formats balance with decimals and symbol when metadata is present", () => { const out = TextFormatters.tokenBalance({ address: "TXaddress", token: "TR7token", balance: "1204560000", symbol: "USDT", decimals: 6 }, ctx()); diff --git a/ts/src/adapters/inbound/cli/commands/vote.ts b/ts/src/adapters/inbound/cli/commands/vote.ts index 7dc0be3c..806d8be2 100644 --- a/ts/src/adapters/inbound/cli/commands/vote.ts +++ b/ts/src/adapters/inbound/cli/commands/vote.ts @@ -7,7 +7,7 @@ import { TextFormatters } from "../render/index.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) - .describe("witness address = vote count, as SR=votes; repeatable; replaces all prior votes"); + .describe("witness address = vote count (positive integer); repeatable; the set replaces all prior votes (at least 1, at most 30 entries)"); export const voteCastSpec: ChainSpec = { path: ["vote", "cast"], @@ -15,6 +15,10 @@ export const voteCastSpec: ChainSpec = { broadcasts: true, capability: "vote.cast", summary: "Cast or replace your full SR vote allocation", + description: + "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.", baseFields: z.object({ for: voteForField, ...txModeFields, @@ -32,6 +36,7 @@ export const voteListSpec: ChainSpec = { network: "optional", wallet: "none", auth: "none", capability: "vote.list", summary: "List super representatives and candidates", + description: "List super representatives (elected by default) with votes, APR, and reward ratio.", baseFields: z.object({ limit: z.coerce.number().int().positive().max(127).default(27) .describe("number of ranks to return; max 127"), @@ -54,6 +59,10 @@ export const voteStatusSpec: ChainSpec = { network: "optional", wallet: "optional", auth: "none", capability: "vote.status", summary: "Show current votes, voting power, and reward overview", + description: + "Show your current vote distribution (with each SR's APR and reward ratio),\n" + + "your voting power (TP), and the currently claimable reward. Warns when votes\n" + + "sit on an SR with a 0% reward ratio (they earn nothing).", baseFields: z.object({}), examples: [{ cmd: "wallet-cli vote status" }], formatText: TextFormatters.voteStatus, diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index 505770fa..050c3c6b 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -83,6 +83,9 @@ export function registerWalletCommands(reg: CommandRegistry, services: { walletS secretsTtyOnly: true, promptHints: { label: "default-label" }, summary: "Import a BIP39 mnemonic phrase", + description: + "Import a BIP39 mnemonic phrase. The recovery phrase and master password are read\n" + + "interactively from the TTY (hidden input); they never touch argv or stdin.", fields: importMnemonicFields, input: importMnemonicFields, examples: [{ cmd: "wallet-cli import mnemonic --label main" }], @@ -109,6 +112,9 @@ export function registerWalletCommands(reg: CommandRegistry, services: { walletS secretsTtyOnly: true, promptHints: { label: "default-label" }, summary: "Import a raw private key", + description: + "Import a raw private key. The private key and master password are read\n" + + "interactively from the TTY (hidden input); they never touch argv or stdin.", fields: importPrivateKeyFields, input: importPrivateKeyFields, examples: [{ cmd: "wallet-cli import private-key --label hot" }], @@ -333,6 +339,10 @@ export function registerWalletCommands(reg: CommandRegistry, services: { walletS secretsTtyOnly: true, requires: ["the new master password — entered interactively in a TTY"], summary: "Change the master password (re-encrypt keystores)", + description: + "Change the master password. Re-encrypts every software wallet keystore with the\n" + + "new password (Ledger / watch-only accounts are unaffected). Passwords are read\n" + + "interactively from the TTY (hidden input); they never touch argv or stdin.", fields: changePasswordFields, input: changePasswordFields, examples: [{ cmd: "wallet-cli change-password" }], diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index 7cbceb33..482d5f8f 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -57,7 +57,12 @@ interface CommandDefinitionBase { /** gap-fill prompt hints, by field name: "skip" = never prompt this optional field; "default-label" = offer a generated default. */ promptHints?: Record; capability?: string; + /** one-line command listing text (parent group's verb list). Keep it terse — a single line. */ summary?: string; + /** optional fuller leaf-help description (may span multiple lines); shown on ` --help` + * instead of `summary` when present. Use it for commands whose behavior needs more than a + * headline (semantics, limits, warnings). Absent ⇒ leaf help falls back to `summary`. */ + description?: string; /** extra command-specific preconditions rendered in the help "Requires:" block, ahead of the * auto-derived network/auth/account lines (e.g. a connected Ledger for `import ledger`). */ requires?: string[]; @@ -102,7 +107,11 @@ export interface ChainSpec { positionals?: { field: string; placeholder?: string }[]; promptHints?: Record; requires?: string[]; + /** one-line command listing text (parent group's verb list). Keep it terse — a single line. */ summary?: string; + /** optional fuller leaf-help description (may span multiple lines); shown on ` --help` + * instead of `summary` when present. Absent ⇒ leaf help falls back to `summary`. */ + description?: string; examples: Example[]; baseFields: ZodObject; baseRefine?: (value: any, ctx: import("zod").RefinementCtx) => void; diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index 2512504f..bbe6b208 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -196,6 +196,7 @@ export class HelpService { return this.#renderLeaf({ path: cmd.path, summary: cmd.summary, + description: cmd.description, network: cmd.network, auth: cmd.auth, wallet: cmd.wallet, @@ -214,6 +215,7 @@ export class HelpService { return this.#renderLeaf({ path: spec.path, summary: spec.summary, + description: spec.description, network: spec.network, auth: spec.auth, wallet: spec.wallet, @@ -230,6 +232,7 @@ export class HelpService { #renderLeaf(c: { path: string[] summary?: string + description?: string network: ChainSpec["network"] | "none" auth: CommandDefinition["auth"] wallet: CommandDefinition["wallet"] @@ -249,7 +252,10 @@ export class HelpService { }) const usagePositional = positionals.map((p) => (p.required ? ` <${p.name}>` : ` [<${p.name}>]`)).join("") const lines = ["Usage:", ` wallet-cli ${c.path.join(" ")}${usagePositional} [options]`] - if (c.summary) lines.push("", c.summary) + // leaf description: prefer the fuller multi-line `description` when a command declares one, + // else fall back to the one-line `summary` used in the parent group's listing. + const description = c.description ?? c.summary + if (description) lines.push("", description) if (positionals.length) { lines.push("", "Args:") @@ -408,7 +414,8 @@ function globalFlagLine(g: GlobalFlag): string { return ` ${globalFlagHead(g).padEnd(26)} ${g.description}${g.description && tag ? " " : ""}${tag}`.trimEnd() } -// Group (群组层) one-line descriptions, keyed by the registry group head. Only groups that surface a +// Group (群组层) descriptions, keyed by the registry group head. Usually one line; a group whose +// behavior warrants it may span multiple lines (embed "\n"). Only groups that surface a // ` --help` page need an entry; absent → the description line is omitted. const GROUP_DESCRIPTIONS: Record = { import: "Import a wallet from an existing secret or device.", @@ -417,7 +424,7 @@ const GROUP_DESCRIPTIONS: Record = { tx: "Build, send, broadcast, and inspect transactions.", contract: "Call, send, deploy, and inspect smart contracts.", stake: "Stake / delegate resources & query state (TRON Stake 2.0).", - vote: "Vote for super representatives (SR).", + vote: "Vote for super representatives (SR).\nVoting accrues rewards — query and claim them with 'wallet-cli reward'.", reward: "Query and withdraw voting/block rewards.", chain: "Query on-chain parameters, resource prices, and node status.", message: "Sign arbitrary messages.", diff --git a/ts/src/adapters/inbound/cli/render/stake.ts b/ts/src/adapters/inbound/cli/render/stake.ts index a75929b2..ec2e6324 100644 --- a/ts/src/adapters/inbound/cli/render/stake.ts +++ b/ts/src/adapters/inbound/cli/render/stake.ts @@ -21,7 +21,15 @@ export const StakeFormatters = { ["Unfreezing", `${unfreezing.length} pending (max ${formatInt(unfreeze.max)} at a time, ${formatInt(unfreeze.remaining)} more allowed)`], ] const body = query(lines) - const pendings = unfreezing.map((u, i) => ` ${i + 1}) ${trx(u.amountSun)} withdrawable ${formatAtWithRelative(u.withdrawableAt)}`) + // Tree-style unstake list, indented to the value column (label width + 2) and branched + // ├─ / └─; amounts are padded so the withdrawable column lines up across rows. + const branchPad = " ".repeat(Math.max(...lines.map(([l]) => l.length)) + 2) + const amounts = unfreezing.map((u) => trx(u.amountSun)) + const amtWidth = Math.max(0, ...amounts.map((a) => a.length)) + const pendings = unfreezing.map((u, i) => { + const branch = i === unfreezing.length - 1 ? "└─" : "├─" + return `${branchPad}${branch} ${amounts[i]!.padEnd(amtWidth)} withdrawable ${formatAtWithRelative(u.withdrawableAt)}` + }) const tail = kv([["Withdrawable", `${trx(d.withdrawableSun)} now`]], "") return [body, ...pendings, tail].join("\n") }) satisfies TextFormatter, diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 17d1f1e0..e6bd7d54 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -406,6 +406,79 @@ describe("golden CLI — error contract (exit codes)", () => { expect(r.json.error.code).toBe("invalid_value"); expect(r.json.error.message).toContain("'foo'"); }); + + // Leaf/group help must carry the doc's user-value semantics, not a compressed one-liner: overwrite + // semantics + TP math (vote cast), the 30-entry cap on --for, the 24h withdraw cap (reward + // withdraw), the 0% reward-ratio warning (vote status), and the reward pointer (vote group). + it("vote --help keeps the reward pointer (group 2nd line)", () => { + const r = run(["vote", "--help"], { password: null }); + expect(r.stdout).toContain("Vote for super representatives (SR)."); + expect(r.stdout).toContain("Voting accrues rewards — query and claim them with 'wallet-cli reward'."); + }); + + it("vote cast --help spells out overwrite semantics, the 30-entry cap, and TP math", () => { + const r = run(["vote", "cast", "--help"], { password: null }); + expect(r.stdout).toContain("any previous SR not listed is set to zero"); + expect(r.stdout).toContain("1 vote = 1 Tron Power (TP) = 1 staked TRX"); + expect(r.stdout).toContain("at least 1, at most 30 entries"); + }); + + it("vote status --help warns about the 0% reward-ratio case", () => { + const r = run(["vote", "status", "--help"], { password: null }); + expect(r.stdout).toContain("0% reward ratio"); + }); + + it("reward withdraw --help states the 24h withdrawal cap", () => { + const r = run(["reward", "withdraw", "--help"], { password: null }); + expect(r.stdout).toContain("at most once every 24 hours"); + }); + + // stake-query / chain / interactive-import leaf help must also carry the doc's fuller description, + // not the compressed one-line summary (same fix as vote/reward above). + it("stake info --help lists the overview fields", () => { + const r = run(["stake", "info", "--help"], { password: null }); + expect(r.stdout).toContain("pending unstakes, currently withdrawable TRX, and available unfreeze slots"); + }); + + it("stake delegated --help explains outbound/inbound lock semantics", () => { + const r = run(["stake", "delegated", "--help"], { password: null }); + expect(r.stdout).toContain('Outbound shows "Locked until"'); + expect(r.stdout).toContain('inbound shows'); + expect(r.stdout).toContain('"Guaranteed until"'); + }); + + it("chain params --help mentions --key for a single value", () => { + const r = run(["chain", "params", "--help"], { password: null }); + expect(r.stdout).toContain("Use --key for one value"); + }); + + it("chain prices --help states the SUN unit basis", () => { + const r = run(["chain", "prices", "--help"], { password: null }); + expect(r.stdout).toContain("in SUN; 1 TRX = 1,000,000 SUN"); + }); + + it("chain node --help explains the sync-vs-tx diagnostic use", () => { + const r = run(["chain", "node", "--help"], { password: null }); + expect(r.stdout).toContain('node out of sync'); + }); + + it("change-password --help notes Ledger/watch are unaffected and TTY-only secrets", () => { + const r = run(["change-password", "--help"], { password: null }); + expect(r.stdout).toContain("Ledger / watch-only accounts are unaffected"); + expect(r.stdout).toContain("they never touch argv or stdin"); + }); + + it("import mnemonic --help documents hidden TTY-only entry", () => { + const r = run(["import", "mnemonic", "--help"], { password: null }); + expect(r.stdout).toContain("The recovery phrase and master password are read"); + expect(r.stdout).toContain("they never touch argv or stdin"); + }); + + it("import private-key --help documents hidden TTY-only entry", () => { + const r = run(["import", "private-key", "--help"], { password: null }); + expect(r.stdout).toContain("The private key and master password are read"); + expect(r.stdout).toContain("they never touch argv or stdin"); + }); }); describe("golden CLI — token address-book (local, no RPC)", () => { From d257b2464a026accf6da91b8afa63c99446dcb6b Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 14 Jul 2026 13:01:11 +0800 Subject: [PATCH 07/16] feat(ts): fail-fast watch-only guard on write ops; rename backup error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SignerResolver.assertCanSign — a cheap capability gate (no RPC, no keystore decrypt) that rejects watch-only accounts before any business validation. Call it as the first line of every write entry (send, stake, vote cast, reward withdraw, contract call/deploy) via a thin TxPipeline delegate, so "watch-only can't sign" wins over rule errors like insufficient_voting_power and even --dry-run is refused for watch accounts. Also rename backup()'s error from watch_only_no_signer to not_exportable: that branch covers watch and ledger accounts and is an export failure, not a signing one. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/src/application/services/pipeline/index.ts | 8 +++++++- ts/src/application/services/signer/index.ts | 15 +++++++++++++++ .../services/signer/resolver.test.ts | 17 +++++++++++++++++ .../tron/contract-service.deploy.test.ts | 1 + .../use-cases/tron/contract-service.ts | 2 ++ .../use-cases/tron/reward-service.test.ts | 2 +- .../use-cases/tron/reward-service.ts | 1 + .../application/use-cases/tron/stake-service.ts | 1 + .../use-cases/tron/transaction-service.ts | 1 + .../use-cases/tron/vote-service.test.ts | 16 +++++++++++++++- .../application/use-cases/tron/vote-service.ts | 1 + ts/src/application/use-cases/wallet-service.ts | 2 +- 12 files changed, 63 insertions(+), 4 deletions(-) diff --git a/ts/src/application/services/pipeline/index.ts b/ts/src/application/services/pipeline/index.ts index 0e6a032e..9e07596c 100644 --- a/ts/src/application/services/pipeline/index.ts +++ b/ts/src/application/services/pipeline/index.ts @@ -4,7 +4,7 @@ * Ledger wait, timeout, and abort behavior lives here, not in each command. * Chain-specific build/estimate come in as callbacks. */ -import type { AccountRef, FeeReport, NetworkDescriptor, SignedTx, TxOutcome, UnsignedTx } from "../../../domain/types/index.js"; +import type { AccountRef, ChainFamily, FeeReport, NetworkDescriptor, SignedTx, TxOutcome, UnsignedTx } from "../../../domain/types/index.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; import { SignerResolver } from "../signer/index.js"; import { UsageError } from "../../../domain/errors/index.js"; @@ -30,6 +30,12 @@ export interface TxPipelineParams { export class TxPipeline { constructor(private readonly signers: SignerResolver) {} + /** Pre-flight capability gate for write commands: fail fast (before any RPC) when the active + * account can't sign. Delegates to the resolver so the watch-only rule lives in one place. */ + assertCanSign(account: AccountRef, family: ChainFamily): void { + this.signers.assertCanSign(account, family); + } + async run(p: TxPipelineParams): Promise { const { timeoutMs } = p.ctx; // --wait only makes sense when we actually broadcast (dry-run/sign-only never reach the chain). diff --git a/ts/src/application/services/signer/index.ts b/ts/src/application/services/signer/index.ts index b9a43c97..35192162 100644 --- a/ts/src/application/services/signer/index.ts +++ b/ts/src/application/services/signer/index.ts @@ -19,6 +19,21 @@ export class SignerResolver { private readonly signStrategies: Record, ) {} + /** + * Cheap pre-flight capability gate: throws before any RPC or keystore decrypt when the active + * account cannot produce a signature (watch-only). Write commands call this FIRST so a + * "can't sign" failure wins over business-rule errors (e.g. insufficient voting power) — and so + * even --dry-run refuses a watch-only account rather than simulating a tx it could never send. + */ + assertCanSign(refOrLabel: string, family: ChainFamily): void { + const { wallet, index } = this.keystore.resolveAccount(refOrLabel); + const address = walletAddress(wallet, family, index); + if (!address) throw new WalletError("missing_wallet_address", `account has no ${family} address`); + if (wallet.source.type === "watch") { + throw new WalletError("watch_only_no_signer", "watch-only account cannot sign; import its secret to sign"); + } + } + resolve(refOrLabel: string, family: ChainFamily): Signer { const { wallet, index } = this.keystore.resolveAccount(refOrLabel); const address = walletAddress(wallet, family, index); diff --git a/ts/src/application/services/signer/resolver.test.ts b/ts/src/application/services/signer/resolver.test.ts index fb75b586..a7329aea 100644 --- a/ts/src/application/services/signer/resolver.test.ts +++ b/ts/src/application/services/signer/resolver.test.ts @@ -32,4 +32,21 @@ describe("SignerResolver — watch accounts", () => { } expect(err?.code).toBe("watch_only_no_signer"); }); + + it("assertCanSign rejects a watch-only account before any RPC/decrypt", () => { + const ref = ks.registerWatch({ family: "tron", address: "Twatch1" }).accountId; + let err: { code?: string } | undefined; + try { + resolver.assertCanSign(ref, "tron"); + } catch (e) { + err = e as { code?: string }; + } + expect(err?.code).toBe("watch_only_no_signer"); + }); + + it("assertCanSign passes for a signable (private-key) account", () => { + // 32-byte test key → deterministic tron address; assertCanSign must not throw. + const ref = ks.import({ type: "privateKey", secret: "0x".padEnd(66, "1") }).accountId; + expect(() => resolver.assertCanSign(ref, "tron")).not.toThrow(); + }); }); diff --git a/ts/src/application/use-cases/tron/contract-service.deploy.test.ts b/ts/src/application/use-cases/tron/contract-service.deploy.test.ts index 364e1a01..51f21fd8 100644 --- a/ts/src/application/use-cases/tron/contract-service.deploy.test.ts +++ b/ts/src/application/use-cases/tron/contract-service.deploy.test.ts @@ -22,6 +22,7 @@ function service(opts: { deployTx: Record; outcome: Record gateway } as unknown as ChainGatewayProvider; const pipeline = { + assertCanSign() {}, async run(p: { build: (from: string) => Promise }) { await p.build("Towner"); return opts.outcome; diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index bd1d2c8a..5c3a2364 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -38,6 +38,7 @@ export class TronContractService { feeLimit: string; }, ) { + this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); const outcome = await this.pipeline.run({ ctx: scope, @@ -78,6 +79,7 @@ export class TronContractService { parameters: unknown[]; }, ) { + this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); let contractAddress: string | undefined; const outcome = await this.pipeline.run({ diff --git a/ts/src/application/use-cases/tron/reward-service.test.ts b/ts/src/application/use-cases/tron/reward-service.test.ts index 7a5316e9..61e6d354 100644 --- a/ts/src/application/use-cases/tron/reward-service.test.ts +++ b/ts/src/application/use-cases/tron/reward-service.test.ts @@ -23,7 +23,7 @@ function service(gateway: Partial, pipeline?: Partial, const g = gateway as TronGateway; return new TronRewardService( { get: () => g } as unknown as ChainGatewayProvider, - { run: async () => ({ stage: "submitted", txId: "txid" }), ...pipeline } as unknown as TxPipeline, + { assertCanSign: () => {}, run: async () => ({ stage: "submitted", txId: "txid" }), ...pipeline } as unknown as TxPipeline, () => now, ); } diff --git a/ts/src/application/use-cases/tron/reward-service.ts b/ts/src/application/use-cases/tron/reward-service.ts index 10f9404a..c46656ee 100644 --- a/ts/src/application/use-cases/tron/reward-service.ts +++ b/ts/src/application/use-cases/tron/reward-service.ts @@ -31,6 +31,7 @@ export class TronRewardService { } async withdraw(scope: TransactionScope, network: NetworkDescriptor, input: TransactionModeInput) { + this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); const address = scope.resolveAddress("tron"); const [rewardSun, account] = await Promise.all([ diff --git a/ts/src/application/use-cases/tron/stake-service.ts b/ts/src/application/use-cases/tron/stake-service.ts index 5e431a28..519951d3 100644 --- a/ts/src/application/use-cases/tron/stake-service.ts +++ b/ts/src/application/use-cases/tron/stake-service.ts @@ -192,6 +192,7 @@ export class TronStakeService { input: TransactionModeInput & Partial, build: (gateway: TronGateway, owner: string) => Promise, ) { + this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); const outcome = await this.pipeline.run({ ctx: scope, diff --git a/ts/src/application/use-cases/tron/transaction-service.ts b/ts/src/application/use-cases/tron/transaction-service.ts index dcd40108..20b20256 100644 --- a/ts/src/application/use-cases/tron/transaction-service.ts +++ b/ts/src/application/use-cases/tron/transaction-service.ts @@ -27,6 +27,7 @@ export class TronTransactionService { ) {} async send(scope: TransactionScope, network: NetworkDescriptor, input: TronSendInput) { + this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); const resolved = await this.resolveTransfer( gateway, diff --git a/ts/src/application/use-cases/tron/vote-service.test.ts b/ts/src/application/use-cases/tron/vote-service.test.ts index 6d10a604..eb433ece 100644 --- a/ts/src/application/use-cases/tron/vote-service.test.ts +++ b/ts/src/application/use-cases/tron/vote-service.test.ts @@ -6,6 +6,7 @@ import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { TronGateway } from "../../ports/chain/tron-gateway.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; +import { WalletError } from "../../../domain/errors/index.js"; const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] }; const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; @@ -25,7 +26,7 @@ const scope: TransactionScope = { function service(gateway: Partial, pipeline?: Partial) { const g = gateway as TronGateway; const gateways = { get: () => g } as unknown as ChainGatewayProvider; - const pipe = { run: async () => ({ stage: "submitted", txId: "txid" }), ...pipeline } as unknown as TxPipeline; + const pipe = { assertCanSign: () => {}, run: async () => ({ stage: "submitted", txId: "txid" }), ...pipeline } as unknown as TxPipeline; // voting power now comes from the injected stake service (reads getAccountResources). return new TronVoteService(gateways, pipe, new TronStakeService(gateways, pipe)); } @@ -40,6 +41,19 @@ describe("TronVoteService.cast", () => { }); }); + it("capability gate wins over the voting-power rule: watch-only fails before any RPC", async () => { + const svc = service( + { + // must never be reached — a watch-only account can't sign, so no voting-power lookup runs. + getAccountResources: async () => { throw new Error("voting power must not be read for a watch-only account"); }, + }, + { assertCanSign: () => { throw new WalletError("watch_only_no_signer", "watch-only account cannot sign; import its secret to sign"); } }, + ); + await expect(svc.cast(scope, NET, { for: [`${SR1}=1`] })).rejects.toMatchObject({ + code: "watch_only_no_signer", + }); + }); + it("echoes parsed votes and total votes after pipeline submission", async () => { const svc = service({ getAccountResources: async () => ({ tronPowerLimit: 1000, tronPowerUsed: 0 } as never), diff --git a/ts/src/application/use-cases/tron/vote-service.ts b/ts/src/application/use-cases/tron/vote-service.ts index 59b77461..eaf5af06 100644 --- a/ts/src/application/use-cases/tron/vote-service.ts +++ b/ts/src/application/use-cases/tron/vote-service.ts @@ -70,6 +70,7 @@ export class TronVoteService { ) {} async cast(scope: TransactionScope, network: NetworkDescriptor, input: VoteCastInput) { + this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); const votes = parseVoteInputs(input.for); const totalVotes = votes.reduce((sum, vote) => sum + BigInt(vote.count), 0n); diff --git a/ts/src/application/use-cases/wallet-service.ts b/ts/src/application/use-cases/wallet-service.ts index 61607cc7..a33d783c 100644 --- a/ts/src/application/use-cases/wallet-service.ts +++ b/ts/src/application/use-cases/wallet-service.ts @@ -127,7 +127,7 @@ export class WalletService { }; } else { throw new WalletError( - "watch_only_no_signer", + "not_exportable", `${source.type} accounts hold no exportable secret`, ); } From 94a2b536c6a0a7bcd61afabac5e55c686712e915 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 14 Jul 2026 18:35:56 +0800 Subject: [PATCH 08/16] fix: package verison & document wording & comment wording & error category & ledger specific not-allow command --- ...wallet-cli-architecture-source-of-truth.md | 2 +- ts/package-lock.json | 10 +-- ts/package.json | 2 +- .../persistence/backup-writer.test.ts | 63 +++++++++++++++++++ ts/src/application/services/pipeline/index.ts | 4 +- ts/src/application/services/signer/index.ts | 8 ++- .../services/signer/resolver.test.ts | 16 +++++ .../use-cases/tron/chain-service.test.ts | 29 +++++++++ .../use-cases/tron/chain-service.ts | 5 +- .../use-cases/tron/contract-service.ts | 3 +- .../use-cases/tron/reward-service.test.ts | 4 +- .../use-cases/tron/reward-service.ts | 6 +- .../use-cases/tron/stake-service.ts | 7 ++- .../use-cases/tron/transaction-service.ts | 4 +- ts/src/bootstrap/runner.ts | 2 +- ts/test/golden.test.ts | 2 +- 16 files changed, 144 insertions(+), 23 deletions(-) create mode 100644 ts/src/adapters/outbound/persistence/backup-writer.test.ts diff --git a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md index c4c7e623..4d706a46 100644 --- a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md +++ b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md @@ -276,7 +276,7 @@ interface FamilyPlugin { | --- | --- | | `path` | Neutral commands use the full path; chain commands use a cross-family logical path. | | `family` | Omitted for neutral commands; when present, the resolved network selects the family implementation. | -| `stdin` | A dedicated stdin channel, one of `tx` or `message` (signed-tx JSON / message to sign). Wallet secrets (`mnemonic`, `privateKey`) and the master password are **not** stdin-backed — see `secretsTtyOnly`. | +| `stdin` | A dedicated **command-scoped** stdin channel, one of `tx` or `message` (signed-tx JSON / message to sign). This field does not cover the master password, which is fed by the **global** `--password-stdin` (see the CLI surface section). Wallet secrets (`mnemonic`, `privateKey`) and the master-password *change* are TTY-only and have no stdin flag — see `secretsTtyOnly`. | | `network` | `none`, `optional`, `required`; today both optional/required can fall back to the default network. | | `wallet` | `none` or `optional`; optional can override the active account with `--account`. | | `auth` | An unlock declaration for help/catalog; actual software signing uses lazy decrypt. | diff --git a/ts/package-lock.json b/ts/package-lock.json index 43826727..b53207dc 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,13 +1,13 @@ { - "name": "wallet-cli", - "version": "0.1.0", + "name": "@tron-walletcli/wallet-cli", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "wallet-cli", - "version": "0.1.0", - "license": "MIT", + "name": "@tron-walletcli/wallet-cli", + "version": "0.1.1", + "license": "LGPL-3.0-or-later", "dependencies": { "@ledgerhq/hw-app-trx": "^6.36.3", "@ledgerhq/hw-transport-node-hid": "^6.33.4", diff --git a/ts/package.json b/ts/package.json index 064ac244..eb1d0dcf 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "0.1.0", + "version": "0.1.1", "description": "Agent-first TypeScript CLI wallet for TRON — deterministic commands, JSON output, and discoverable schemas", "type": "module", "bin": { diff --git a/ts/src/adapters/outbound/persistence/backup-writer.test.ts b/ts/src/adapters/outbound/persistence/backup-writer.test.ts new file mode 100644 index 00000000..b6fba4cf --- /dev/null +++ b/ts/src/adapters/outbound/persistence/backup-writer.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SecureBackupWriter } from "./backup-writer.js"; + +function freshRoot() { + return mkdtempSync(join(tmpdir(), "bw-")); +} + +describe("SecureBackupWriter", () => { + let root: string; + let writer: SecureBackupWriter; + beforeEach(() => { + root = freshRoot(); + writer = new SecureBackupWriter(root, () => 1_700_000_000_000); + }); + + it("writes the payload with 0600 perms and reports the path/size", () => { + const res = writer.write("acct-1", undefined, { secret: "hunter2" }); + expect(res.out).toBe(join(root, "backups", "acct-1-1700000000000.json")); + expect(res.fileMode).toBe("0600"); + const onDisk = readFileSync(res.out, "utf8"); + expect(JSON.parse(onDisk)).toEqual({ secret: "hunter2" }); + expect(res.bytes).toBe(Buffer.byteLength(onDisk)); + expect(statSync(res.out).mode & 0o777).toBe(0o600); + }); + + it("refuses to overwrite an existing regular file (output_exists)", () => { + const target = join(root, "existing.json"); + writeFileSync(target, "keep-me"); + let err: { code?: string } | undefined; + try { + writer.write("acct-1", target, { secret: "x" }); + } catch (e) { + err = e as { code?: string }; + } + expect(err?.code).toBe("output_exists"); + // original file untouched + expect(readFileSync(target, "utf8")).toBe("keep-me"); + }); + + it("does not follow a symlink at the target path (exclusive create fails)", () => { + // A symlink pointing at a sensitive file the backup must never clobber. + const sensitive = join(root, "sensitive.txt"); + writeFileSync(sensitive, "DO-NOT-OVERWRITE"); + const link = join(root, "link.json"); + symlinkSync(sensitive, link); + + // `wx` (O_CREAT|O_EXCL) refuses to write through the symlink — it throws rather than + // following it. The sensitive target must be left exactly as it was. + expect(() => writer.write("acct-1", link, { secret: "x" })).toThrow(); + expect(readFileSync(sensitive, "utf8")).toBe("DO-NOT-OVERWRITE"); + }); + + it("does not follow a dangling symlink at the target path", () => { + const link = join(root, "dangling.json"); + symlinkSync(join(root, "nonexistent-target"), link); + expect(() => writer.write("acct-1", link, { secret: "x" })).toThrow(); + // the symlink was not resolved into a real file + expect(existsSync(join(root, "nonexistent-target"))).toBe(false); + }); +}); diff --git a/ts/src/application/services/pipeline/index.ts b/ts/src/application/services/pipeline/index.ts index 9e07596c..436229dd 100644 --- a/ts/src/application/services/pipeline/index.ts +++ b/ts/src/application/services/pipeline/index.ts @@ -32,8 +32,8 @@ export class TxPipeline { /** Pre-flight capability gate for write commands: fail fast (before any RPC) when the active * account can't sign. Delegates to the resolver so the watch-only rule lives in one place. */ - assertCanSign(account: AccountRef, family: ChainFamily): void { - this.signers.assertCanSign(account, family); + assertCanSign(account: AccountRef, family: ChainFamily, opts?: { requireSoftware?: boolean }): void { + this.signers.assertCanSign(account, family, opts); } async run(p: TxPipelineParams): Promise { diff --git a/ts/src/application/services/signer/index.ts b/ts/src/application/services/signer/index.ts index 35192162..08bc7c80 100644 --- a/ts/src/application/services/signer/index.ts +++ b/ts/src/application/services/signer/index.ts @@ -24,14 +24,20 @@ export class SignerResolver { * account cannot produce a signature (watch-only). Write commands call this FIRST so a * "can't sign" failure wins over business-rule errors (e.g. insufficient voting power) — and so * even --dry-run refuses a watch-only account rather than simulating a tx it could never send. + * + * `requireSoftware` additionally rejects Ledger accounts before any device interaction, for tx + * types the Ledger TRON app firmware cannot sign (e.g. contract deploy, cancel-all-unfreeze). */ - assertCanSign(refOrLabel: string, family: ChainFamily): void { + assertCanSign(refOrLabel: string, family: ChainFamily, opts?: { requireSoftware?: boolean }): void { const { wallet, index } = this.keystore.resolveAccount(refOrLabel); const address = walletAddress(wallet, family, index); if (!address) throw new WalletError("missing_wallet_address", `account has no ${family} address`); if (wallet.source.type === "watch") { throw new WalletError("watch_only_no_signer", "watch-only account cannot sign; import its secret to sign"); } + if (opts?.requireSoftware && wallet.source.type === "ledger") { + throw new WalletError("ledger_unsupported", "this transaction type cannot be signed by the Ledger TRON app; use a software account"); + } } resolve(refOrLabel: string, family: ChainFamily): Signer { diff --git a/ts/src/application/services/signer/resolver.test.ts b/ts/src/application/services/signer/resolver.test.ts index a7329aea..fe6823f7 100644 --- a/ts/src/application/services/signer/resolver.test.ts +++ b/ts/src/application/services/signer/resolver.test.ts @@ -49,4 +49,20 @@ describe("SignerResolver — watch accounts", () => { const ref = ks.import({ type: "privateKey", secret: "0x".padEnd(66, "1") }).accountId; expect(() => resolver.assertCanSign(ref, "tron")).not.toThrow(); }); + + it("assertCanSign with requireSoftware rejects a Ledger account (ledger_unsupported)", () => { + const ref = ks.registerLedger({ family: "tron", path: "m/44'/195'/0'/0/0", address: "Tledger1" }).accountId; + let err: { code?: string } | undefined; + try { + resolver.assertCanSign(ref, "tron", { requireSoftware: true }); + } catch (e) { + err = e as { code?: string }; + } + expect(err?.code).toBe("ledger_unsupported"); + }); + + it("assertCanSign without requireSoftware still allows a Ledger account", () => { + const ref = ks.registerLedger({ family: "tron", path: "m/44'/195'/0'/0/0", address: "Tledger2" }).accountId; + expect(() => resolver.assertCanSign(ref, "tron")).not.toThrow(); + }); }); diff --git a/ts/src/application/use-cases/tron/chain-service.test.ts b/ts/src/application/use-cases/tron/chain-service.test.ts index a4f83044..4b4740e3 100644 --- a/ts/src/application/use-cases/tron/chain-service.test.ts +++ b/ts/src/application/use-cases/tron/chain-service.test.ts @@ -36,6 +36,35 @@ describe("TronChainService.prices", () => { memoFeeSun: "1000000", }); }); + + it("drops malformed segments so NaN never reaches the output", async () => { + const gateway = { + // energy: middle segment is non-numeric; bandwidth: entirely malformed; both must exclude NaN. + getEnergyPrices: async () => "0:100,1670515200000:oops,1670515300000:230", + getBandwidthPrices: async () => "abc:def,x:y", + getChainParameters: async () => [{ key: "getMemoFee", value: 1000000 }], + }; + const view = await svc(gateway).prices(net); + expect(view.energy).toEqual({ + currentSunPerUnit: 230, + history: [{ since: 0, price: 100 }, { since: 1670515300000, price: 230 }], + }); + // all-invalid → empty history, current falls back to 0 (no NaN) + expect(view.bandwidth).toEqual({ currentSunPerUnit: 0, history: [] }); + expect(Number.isNaN(view.energy.currentSunPerUnit)).toBe(false); + expect(view.energy.history.every((h) => Number.isFinite(h.since) && Number.isFinite(h.price))).toBe(true); + }); + + it("handles an empty price string without producing NaN", async () => { + const gateway = { + getEnergyPrices: async () => "", + getBandwidthPrices: async () => "", + getChainParameters: async () => [], + }; + const view = await svc(gateway).prices(net); + expect(view.energy).toEqual({ currentSunPerUnit: 0, history: [] }); + expect(view.bandwidth).toEqual({ currentSunPerUnit: 0, history: [] }); + }); }); describe("TronChainService.node", () => { diff --git a/ts/src/application/use-cases/tron/chain-service.ts b/ts/src/application/use-cases/tron/chain-service.ts index cbddb34d..c88238de 100644 --- a/ts/src/application/use-cases/tron/chain-service.ts +++ b/ts/src/application/use-cases/tron/chain-service.ts @@ -11,7 +11,10 @@ function parsePriceTimeline(raw: string): { currentSunPerUnit: number; history: .split(",") .map((seg) => seg.split(":")) .filter((p) => p.length === 2) - .map(([since, price]) => ({ since: Number(since), price: Number(price) })); + .map(([since, price]) => ({ since: Number(since), price: Number(price) })) + // Drop malformed segments so a bad node fragment can't leak NaN into the output; `?? 0` + // only guards `undefined`, not NaN. + .filter((p) => Number.isFinite(p.since) && Number.isFinite(p.price)); return { currentSunPerUnit: history.at(-1)?.price ?? 0, history }; } diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index 5c3a2364..8472b4aa 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -79,7 +79,8 @@ export class TronContractService { parameters: unknown[]; }, ) { - this.pipeline.assertCanSign(scope.activeAccount, "tron"); + // Ledger TRON app firmware cannot sign a CreateSmartContract tx — reject before any device I/O. + this.pipeline.assertCanSign(scope.activeAccount, "tron", { requireSoftware: true }); const gateway = this.gateways.get(network, "tron"); let contractAddress: string | undefined; const outcome = await this.pipeline.run({ diff --git a/ts/src/application/use-cases/tron/reward-service.test.ts b/ts/src/application/use-cases/tron/reward-service.test.ts index 61e6d354..3512fd88 100644 --- a/ts/src/application/use-cases/tron/reward-service.test.ts +++ b/ts/src/application/use-cases/tron/reward-service.test.ts @@ -62,7 +62,7 @@ describe("TronRewardService.withdraw", () => { getReward: async () => "0", getAccount: async () => ({}), }); - await expect(svc.withdraw(scope, NET, {})).rejects.toMatchObject({ code: "no_reward" }); + await expect(svc.withdraw(scope, NET, {})).rejects.toMatchObject({ code: "no_reward", kind: "execution" }); }); it("rejects inside the 24h withdraw interval", async () => { @@ -71,7 +71,7 @@ describe("TronRewardService.withdraw", () => { getReward: async () => "10", getAccount: async () => ({ latest_withdraw_time: String(now - 1_000) }), }, undefined, now); - await expect(svc.withdraw(scope, NET, {})).rejects.toMatchObject({ code: "withdraw_too_frequent" }); + await expect(svc.withdraw(scope, NET, {})).rejects.toMatchObject({ code: "withdraw_too_frequent", kind: "execution" }); }); it("echoes submitted rewardSun and confirmed withdrawnSun as rewardSun", async () => { diff --git a/ts/src/application/use-cases/tron/reward-service.ts b/ts/src/application/use-cases/tron/reward-service.ts index c46656ee..58228d59 100644 --- a/ts/src/application/use-cases/tron/reward-service.ts +++ b/ts/src/application/use-cases/tron/reward-service.ts @@ -1,5 +1,5 @@ import type { NetworkDescriptor } from "../../../domain/types/index.js"; -import { UsageError } from "../../../domain/errors/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { TronAccount } from "../../ports/chain/tron-gateway.js"; @@ -39,11 +39,11 @@ export class TronRewardService { gateway.getAccount(address), ]); if (toUnsignedBigInt(rewardSun) === 0n) { - throw new UsageError("no_reward", "no voting/block reward is currently claimable"); + throw new ChainError("no_reward", "no voting/block reward is currently claimable"); } const status = withdrawStatus(account, this.now()); if (!status.withdrawableNow) { - throw new UsageError("withdraw_too_frequent", `reward can be withdrawn after ${new Date(status.withdrawableAt!).toISOString()}`); + throw new ChainError("withdraw_too_frequent", `reward can be withdrawn after ${new Date(status.withdrawableAt!).toISOString()}`); } const outcome = await this.pipeline.run({ ctx: scope, diff --git a/ts/src/application/use-cases/tron/stake-service.ts b/ts/src/application/use-cases/tron/stake-service.ts index 519951d3..b2398273 100644 --- a/ts/src/application/use-cases/tron/stake-service.ts +++ b/ts/src/application/use-cases/tron/stake-service.ts @@ -151,8 +151,10 @@ export class TronStakeService { } cancelUnfreeze(scope: TransactionScope, network: NetworkDescriptor, input: TransactionModeInput) { + // Ledger TRON app firmware cannot sign CancelAllUnfreezeV2 — reject before any device I/O. return this.transact("stake-cancel", scope, network, input, - (gateway, owner) => gateway.buildCancelAllUnfreezeV2(owner)); + (gateway, owner) => gateway.buildCancelAllUnfreezeV2(owner), + { requireSoftware: true }); } delegate(scope: TransactionScope, network: NetworkDescriptor, input: StakeDelegateInput) { @@ -191,8 +193,9 @@ export class TronStakeService { network: NetworkDescriptor, input: TransactionModeInput & Partial, build: (gateway: TronGateway, owner: string) => Promise, + opts?: { requireSoftware?: boolean }, ) { - this.pipeline.assertCanSign(scope.activeAccount, "tron"); + this.pipeline.assertCanSign(scope.activeAccount, "tron", opts); const gateway = this.gateways.get(network, "tron"); const outcome = await this.pipeline.run({ ctx: scope, diff --git a/ts/src/application/use-cases/tron/transaction-service.ts b/ts/src/application/use-cases/tron/transaction-service.ts index 20b20256..27f0d9b9 100644 --- a/ts/src/application/use-cases/tron/transaction-service.ts +++ b/ts/src/application/use-cases/tron/transaction-service.ts @@ -82,8 +82,8 @@ export class TronTransactionService { // Two endpoints, in parallel, because getTransactionInfo alone can't tell "unconfirmed" from // "never existed" — it returns {} for both. getTransactionById distinguishes them: it knows a // broadcast tx immediately (mempool), and throws "Transaction not found" for an unknown hash. - // getTransactionInfo only fills in once the tx is in a (solidified) block — that's what promotes - // pending → confirmed/failed. + // getTransactionInfo (full-node unconfirmed view) fills in ~one block after inclusion (~3s), + // not after solidification — that's what promotes pending → confirmed/failed. const [exists, info] = await Promise.all([ gateway.getTransactionById(txid).then((tx) => tx?.txID !== undefined, () => false), gateway.getTransactionInfoById(txid).catch((): TronTxInfo => ({})), diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index 69b3f8c2..a2e0aac1 100644 --- a/ts/src/bootstrap/runner.ts +++ b/ts/src/bootstrap/runner.ts @@ -6,7 +6,7 @@ import { buildCli } from "../adapters/inbound/cli/shell/index.js"; import { hasCommand, parseGlobals } from "./argv.js"; import { composeCliRuntime } from "./composition.js"; -export const VERSION = "0.1.0"; +export const VERSION = "0.1.1"; /** Execute one CLI invocation. Dependency construction is delegated to the composition root. */ export async function main(argv: string[]): Promise { diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index e6bd7d54..79703167 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -60,7 +60,7 @@ describe("golden CLI — meta & introspection", () => { it("--version prints the version, exit 0", () => { const r = run(["--version"]); expect(r.status).toBe(0); - expect(r.stdout.trim()).toBe("0.1.0"); + expect(r.stdout.trim()).toBe("0.1.1"); }); it("root --help shows the TRON first-release command surface", () => { From a5a5e12594fa793dd49fe8c072609f622d919ac5 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 15 Jul 2026 15:03:13 +0800 Subject: [PATCH 09/16] fix(ts): recoverable keystore password rotation; validate waitTimeoutMs on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the two review comments on PR #950. [blocker] change-password could leave the keystore half-migrated: writeJsonAll staged all temps then renamed each into place, atomic per file only. A rename failing mid-commit left some blobs (and possibly the verifier) on the new password and others on the old, while changePassword still reported a clean rollback. Make the commit recoverable — back up each existing target, install the new blob, and on any failure restore every already-committed target from its backup. If automatic restore itself fails, throw io_error("manual recovery needed") and leave the backups on disk instead of falsely claiming rollback. changePassword now lets that explicit error propagate unmasked. [suggestion] ConfigLoader accepted negative/fractional waitTimeoutMs from a hand-edited config.yaml even though ConfigService rejects them on write. Apply the same non-negative-integer rule on load so the effective config stays valid. Tests: fault-injection coverage for the mid-commit and failed-restore paths (fs.test.ts) via a commitRename seam; waitTimeoutMs load-validation cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adapters/outbound/config/config.test.ts | 13 +++ ts/src/adapters/outbound/config/index.ts | 4 +- ts/src/adapters/outbound/keystore/index.ts | 9 +- .../outbound/persistence/fs/fs.test.ts | 88 +++++++++++++++++++ .../adapters/outbound/persistence/fs/index.ts | 43 ++++++++- 5 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 ts/src/adapters/outbound/persistence/fs/fs.test.ts diff --git a/ts/src/adapters/outbound/config/config.test.ts b/ts/src/adapters/outbound/config/config.test.ts index 3a587e58..3d967681 100644 --- a/ts/src/adapters/outbound/config/config.test.ts +++ b/ts/src/adapters/outbound/config/config.test.ts @@ -28,6 +28,19 @@ describe("ConfigLoader defaultNetwork", () => { }); }); +describe("ConfigLoader waitTimeoutMs validation", () => { + it("accepts a valid non-negative integer", () => { + expect(ConfigLoader.load(envWithConfig("waitTimeoutMs: 5000\n")).waitTimeoutMs).toBe(5000); + expect(ConfigLoader.load(envWithConfig("waitTimeoutMs: 0\n")).waitTimeoutMs).toBe(0); + }); + + it("ignores negative or fractional waitTimeoutMs and keeps the default", () => { + // ConfigService rejects these on write; the loader must not accept them from a hand-edited file. + expect(ConfigLoader.load(envWithConfig("waitTimeoutMs: -1\n")).waitTimeoutMs).toBe(60000); + expect(ConfigLoader.load(envWithConfig("waitTimeoutMs: 1.5\n")).waitTimeoutMs).toBe(60000); + }); +}); + describe("NetworkRegistry.resolve case-insensitivity", () => { const registry = () => new NetworkRegistry(ConfigLoader.load(envWithConfig(""))); diff --git a/ts/src/adapters/outbound/config/index.ts b/ts/src/adapters/outbound/config/index.ts index da2fa2fd..09bd7a91 100644 --- a/ts/src/adapters/outbound/config/index.ts +++ b/ts/src/adapters/outbound/config/index.ts @@ -44,7 +44,9 @@ export class ConfigLoader { } if (raw.defaultOutput === "json" || raw.defaultOutput === "text") defaultOutput = raw.defaultOutput; if (typeof raw.timeoutMs === "number") timeoutMs = raw.timeoutMs; - if (typeof raw.waitTimeoutMs === "number") waitTimeoutMs = raw.waitTimeoutMs; + // Same rule ConfigService enforces on write — a hand-edited file must not slip through + // negative or fractional values into the effective config. + if (Number.isInteger(raw.waitTimeoutMs) && raw.waitTimeoutMs >= 0) waitTimeoutMs = raw.waitTimeoutMs; if (raw.price && typeof raw.price === "object") { const p = raw.price as Record; const provider = p.provider === "none" ? "none" : "coingecko"; diff --git a/ts/src/adapters/outbound/keystore/index.ts b/ts/src/adapters/outbound/keystore/index.ts index 2c5b63f1..91d2497d 100644 --- a/ts/src/adapters/outbound/keystore/index.ts +++ b/ts/src/adapters/outbound/keystore/index.ts @@ -409,9 +409,12 @@ export class Keystore { }); try { this.store.writeJsonAll(entries); - } catch { - // staged temps were unlinked by writeJsonAll; every keystore file is unchanged. - throw new ExecutionError("io_error", "failed to write re-encrypted keystores; rolled back, the old password remains in effect"); + } catch (e) { + // An explicit ExecutionError (e.g. automatic rollback failed → manual recovery needed) + // is already accurate; let it through unmasked. + if (e instanceof ExecutionError) throw e; + // Otherwise writeJsonAll rolled back cleanly: every keystore file is unchanged. + throw new ExecutionError("io_error", "failed to write re-encrypted keystores; rolled back, the old password remains in effect", { error: String(e) }); } return { wallets: labels, count: labels.length }; }); diff --git a/ts/src/adapters/outbound/persistence/fs/fs.test.ts b/ts/src/adapters/outbound/persistence/fs/fs.test.ts new file mode 100644 index 00000000..c376e66c --- /dev/null +++ b/ts/src/adapters/outbound/persistence/fs/fs.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AtomicFileStore } from "./index.js"; + +const EIO = () => Object.assign(new Error("EIO"), { code: "EIO" }); + +describe("AtomicFileStore.writeJsonAll", () => { + it("writes every file and leaves no temp/backup residue on success", () => { + const root = mkdtempSync(join(tmpdir(), "fs-")); + const a = join(root, "a.json"); + const b = join(root, "b.json"); + writeFileSync(a, '"old-A"\n'); + + const store = new AtomicFileStore(); + store.writeJsonAll([ + { path: a, value: "new-A" }, + { path: b, value: "new-B" }, // b did not exist before + ]); + + expect(JSON.parse(readFileSync(a, "utf8"))).toBe("new-A"); + expect(JSON.parse(readFileSync(b, "utf8"))).toBe("new-B"); + // no leftover .tmp / .bak files + expect(readdirSync(root).filter((f) => f.endsWith(".tmp") || f.includes(".bak"))).toEqual([]); + }); + + it("restores every already-committed file when a later rename fails mid-commit", () => { + const root = mkdtempSync(join(tmpdir(), "fs-")); + const a = join(root, "a.json"); + const b = join(root, "b.json"); + writeFileSync(a, '"old-A"\n'); + writeFileSync(b, '"old-B"\n'); + + const store = new AtomicFileStore(); + let installs = 0; + store.commitRename = (from: string, to: string) => { + // installs move a staged temp into place; fail the 2nd so A commits, then B fails + if (from.includes(".tmp")) { + installs++; + if (installs === 2) throw EIO(); + } + renameSync(from, to); + }; + + expect(() => + store.writeJsonAll([ + { path: a, value: "new-A" }, + { path: b, value: "new-B" }, + ]), + ).toThrow(); + + // both files must be back on their OLD contents, and no residue left behind + expect(JSON.parse(readFileSync(a, "utf8"))).toBe("old-A"); + expect(JSON.parse(readFileSync(b, "utf8"))).toBe("old-B"); + expect(readdirSync(root).filter((f) => f.endsWith(".tmp") || f.includes(".bak"))).toEqual([]); + }); + + it("reports a manual-recovery io_error and keeps backups when automatic restore also fails", () => { + const root = mkdtempSync(join(tmpdir(), "fs-")); + const a = join(root, "a.json"); + const b = join(root, "b.json"); + writeFileSync(a, '"old-A"\n'); + writeFileSync(b, '"old-B"\n'); + + const store = new AtomicFileStore(); + let installs = 0; + store.commitRename = (from: string, to: string) => { + if (from.includes(".tmp")) { + installs++; + if (installs === 2) throw EIO(); // B install fails + } + if (from.includes(".bak")) throw EIO(); // restore of A from its backup fails too + renameSync(from, to); + }; + + expect(() => + store.writeJsonAll([ + { path: a, value: "new-A" }, + { path: b, value: "new-B" }, + ]), + ).toThrowError(expect.objectContaining({ code: "io_error" })); + + // A's old blob survives as a .bak for manual recovery + expect(readdirSync(root).some((f) => f.includes(".bak"))).toBe(true); + expect(existsSync(a)).toBe(true); + }); +}); diff --git a/ts/src/adapters/outbound/persistence/fs/index.ts b/ts/src/adapters/outbound/persistence/fs/index.ts index 6724cd17..8509ec2e 100644 --- a/ts/src/adapters/outbound/persistence/fs/index.ts +++ b/ts/src/adapters/outbound/persistence/fs/index.ts @@ -51,8 +51,11 @@ export class AtomicFileStore { renameSync(tmp, path); // atomic replace on same filesystem } - /** transactional-ish multi-file write: stage every temp first, then rename all into place. - * A failure while staging unlinks the temps and leaves every target untouched. */ + /** transactional multi-file write: stage every temp first, then commit each into place while + * backing up the prior blob. A failure while staging unlinks the temps and leaves every target + * untouched. A failure mid-commit restores every already-committed target from its backup, so + * callers are never left with a half-migrated set; if that automatic restore itself fails, an + * ExecutionError("io_error") is thrown and the backups are left on disk for manual recovery. */ writeJsonAll(entries: Array<{ path: string; value: unknown }>): void { const staged: Array<{ tmp: string; path: string }> = []; try { @@ -66,7 +69,41 @@ export class AtomicFileStore { for (const { tmp } of staged) { try { unlinkSync(tmp); } catch { /* best-effort */ } } throw e; } - for (const { tmp, path } of staged) renameSync(tmp, path); // atomic per file + + // commit phase: back up each existing target, then move its temp into place. + const committed: Array<{ path: string; bak: string | null }> = []; + try { + for (const { tmp, path } of staged) { + const bak = existsSync(path) ? `${path}.${process.pid}.${this.#counter++}.bak` : null; + if (bak) this.commitRename(path, bak); // set aside the old blob + committed.push({ path, bak }); // record BEFORE the tmp rename so restore covers this file + this.commitRename(tmp, path); // install the new blob + } + } catch (e) { + let restoreFailed = false; + for (const { path, bak } of committed.reverse()) { + try { + if (bak) this.commitRename(bak, path); // put the old blob back (replaces new if present) + else { try { unlinkSync(path); } catch { /* ignore */ } } // was a newly created file + } catch { restoreFailed = true; } + } + for (const { tmp } of staged) { try { unlinkSync(tmp); } catch { /* best-effort */ } } + if (restoreFailed) { + throw new ExecutionError( + "io_error", + "partial keystore write; automatic rollback FAILED — manual recovery needed", + { error: String(e), files: committed.map((c) => c.path).join(", ") }, + ); + } + throw e; // clean rollback: every target restored to its prior state + } + for (const { bak } of committed) { if (bak) { try { unlinkSync(bak); } catch { /* ignore */ } } } + } + + /** rename seam used by the commit/restore phase of writeJsonAll — overridable in tests to + * inject a mid-commit failure. */ + commitRename(from: string, to: string): void { + renameSync(from, to); } writeText(path: string, text: string): void { From f3a4a860ea6f4e3b0d87170b574403be4434b108 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 15 Jul 2026 16:08:03 +0800 Subject: [PATCH 10/16] feat(docs): updated README with adding badge & updated CLAUDE.md development rules --- CLAUDE.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++------ README.md | 16 ++++++++++++-- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a5caf10b..c579946e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,11 +6,63 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co The repository holds two independent implementations: -- `java/` — the Java implementation, described by the rest of this file. -- `ts/` — the TypeScript implementation, a self-contained npm package with its own `README.md`. +- `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 refers to the Java implementation. **All paths are relative to `java/`, and all -commands are run from that directory** (`cd java` first). +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 20+, 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 +npm ci # install +npm run build # bundle to dist/ via tsup (bin: wallet-cli -> dist/index.js) +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 +``` + +### Architecture (hexagonal / ports & adapters) + +Dependencies point inward. The source of truth is +`ts/docs/typescript-wallet-cli-architecture-source-of-truth.md` — read it before changing +boundaries, ports, command routing, or the JSON contract. `depcruise` enforces these rules in CI. + +| 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). +- **Chain-family differences** are isolated in the `tron` family — `application/use-cases/tron/`, + `adapters/outbound/chain/tron/`, and the family plugin under `bootstrap/families/`. EVM is planned, + not yet public. +- **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 + +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`. +4. Add co-located `*.test.ts` and run `npm run depcruise && npm run typecheck && npm test`. ## Build & Run @@ -59,7 +111,7 @@ This is a **TRON blockchain CLI wallet** built on the [Trident SDK](https://gith ### Two CLI Modes -1. **REPL 交互模式** (human-friendly) — `Client` class with JCommander `@Parameters` inner classes. Entry point: `org.tron.walletcli.Client`. Features tab completion, interactive prompts, and conversational output. This is the largest file (~4700 lines). Best for manual exploration and day-to-day wallet management by humans. +1. **REPL 交互模式** (human-friendly) — `Client` class with JCommander `@Parameters` inner classes. Entry point: `org.tron.walletcli.Client`. Features tab completion, interactive prompts, and conversational output. This is the largest file (~4900 lines). Best for manual exploration and day-to-day wallet management by humans. 2. **Standard CLI 模式** (AI-agent-friendly) — `StandardCliRunner` with `CommandRegistry`/`CommandDefinition` pattern in `org.tron.walletcli.cli.*`. Supports `--output json`, `--network`, `--quiet` flags. Commands are registered in `cli/commands/` classes (e.g., `WalletCommands`, `TransactionCommands`, `QueryCommands`). Designed for automation: deterministic exit codes, structured JSON output, no interactive prompts, and env-var-based authentication — ideal for AI agents, scripts, and CI/CD pipelines. The standard CLI suppresses all stray stdout/stderr in JSON mode to ensure machine-parseable output. Authentication is automatic via `MASTER_PASSWORD` env var + keystore files in `Wallet/`. @@ -126,9 +178,9 @@ User Input → Client (JCommander) → WalletApiWrapper → WalletApi → Triden ### Key Frameworks & Libraries -- **Trident SDK 0.10.0** — All gRPC API calls to TRON nodes +- **Trident SDK 0.11.0** — All gRPC API calls to TRON nodes - **JCommander 1.82** — CLI argument parsing (REPL 交互模式) - **JLine 3.25.0** — Interactive terminal/readline - **BouncyCastle** — Cryptographic operations -- **Protobuf 3.25.5 / gRPC 1.60.0** — Protocol definitions and transport +- **Protobuf 3.25.8 / gRPC 1.75.0** — Protocol definitions and transport - **Lombok** — `@Getter`, `@Setter`, `@Slf4j` etc. (annotation processing) diff --git a/README.md b/README.md index 471bd207..5330fafc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,18 @@ -# wallet-cli +

wallet-cli

-A command-line wallet for the [TRON](https://tron.network) network. This repository holds **two independent implementations** that share the same purpose but target different users: +

+ A command-line wallet for the TRON network — interactive in Java, agent-first in TypeScript +

+ +

+ + + + + +

+ +This repository holds **two independent implementations** that share the same purpose but target different users: - **[Java](java/README.md)** — the original, full-featured reference CLI. An interactive prompt (REPL) for people who want the complete TRON feature surface. - **[TypeScript](ts/README.md)** — an agent-first rewrite for automation. Standard subcommands with a stable JSON envelope, built for scripts, CI, and AI agents. From 41dc0f04fe6417b4559f23703fa73f695e4780bf Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 15 Jul 2026 17:37:48 +0800 Subject: [PATCH 11/16] feat: fsync ensure write in file & clear cache secret & avoid backup id conflict --- .../inbound/cli/input/secret/index.ts | 11 +++ .../inbound/cli/input/secret/secret.test.ts | 14 ++++ ts/src/adapters/outbound/keystore/index.ts | 6 +- .../outbound/keystore/keystore.test.ts | 77 ++++++++++++++++++- .../outbound/persistence/fs/fs.test.ts | 38 +++++++++ .../adapters/outbound/persistence/fs/index.ts | 40 +++++++++- ts/src/bootstrap/runner.ts | 1 + 7 files changed, 182 insertions(+), 5 deletions(-) diff --git a/ts/src/adapters/inbound/cli/input/secret/index.ts b/ts/src/adapters/inbound/cli/input/secret/index.ts index 8b471c99..582e6377 100644 --- a/ts/src/adapters/inbound/cli/input/secret/index.ts +++ b/ts/src/adapters/inbound/cli/input/secret/index.ts @@ -30,6 +30,17 @@ export class SecretResolver implements ISecretResolver { private readonly prompter?: Prompter, ) {} + /** + * Drop every cached secret once the command is done (called from the runner's finally). + * NOTE: JavaScript strings are immutable and cannot be overwritten, so this only releases the + * references for GC — it is not a guaranteed memory wipe (see the CP-08 caveat in the docs). + */ + clearPrimed(): void { + this.#primed.clear(); + this.#byPath.clear(); + this.#stdinUsedBy = undefined; + } + /** whether a master-password source is configured, WITHOUT consuming it. */ hasMasterPassword(): boolean { return this.paths.password !== undefined || this.#primed.has("password"); diff --git a/ts/src/adapters/inbound/cli/input/secret/secret.test.ts b/ts/src/adapters/inbound/cli/input/secret/secret.test.ts index bb99b96f..9361b9e6 100644 --- a/ts/src/adapters/inbound/cli/input/secret/secret.test.ts +++ b/ts/src/adapters/inbound/cli/input/secret/secret.test.ts @@ -77,3 +77,17 @@ describe("hasMasterPassword", () => { expect(r.hasMasterPassword()).toBe(false); }); }); + +describe("clearPrimed (CP-08)", () => { + it("drops the cached password so it is no longer readable", async () => { + // prime via TTY (no --password source), so a cleared cache leaves nothing behind + const r = new SecretResolver(streams(), {}, new Prompter(new Backend([PW, PW]))); + await r.primePassword({ mode: "set" }); + expect(r.masterPassword()).toBe(PW); + + r.clearPrimed(); + + expect(r.hasMasterPassword()).toBe(false); + expect(() => r.masterPassword()).toThrow(); // cache gone, no source → auth_required + }); +}); diff --git a/ts/src/adapters/outbound/keystore/index.ts b/ts/src/adapters/outbound/keystore/index.ts index 91d2497d..416b1ec9 100644 --- a/ts/src/adapters/outbound/keystore/index.ts +++ b/ts/src/adapters/outbound/keystore/index.ts @@ -397,7 +397,11 @@ export class Keystore { ); } const plaintext = CryptoEnvelope.decrypt(blob, oldPassword); - entries.push({ path, value: CryptoEnvelope.encrypt(plaintext, newPassword, id, blob.type) }); + try { + entries.push({ path, value: CryptoEnvelope.encrypt(plaintext, newPassword, id, blob.type) }); + } finally { + plaintext.fill(0); // scrub the decrypted secret from memory as soon as it is re-wrapped + } labels.push(file.labels[accountRefOf(w, s.type === "seed" ? 0 : null)] ?? w.id); } if (entries.length === 0) { diff --git a/ts/src/adapters/outbound/keystore/keystore.test.ts b/ts/src/adapters/outbound/keystore/keystore.test.ts index ff48037f..7424ec66 100644 --- a/ts/src/adapters/outbound/keystore/keystore.test.ts +++ b/ts/src/adapters/outbound/keystore/keystore.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readdirSync, renameSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { bytesToHex } from "@noble/hashes/utils.js"; @@ -391,4 +391,79 @@ describe("changePassword", () => { ); expect(ks.verifyPassword("OldPw1!aa")).toBe(true); }, 15_000); + + // ── production crash-safety: the real backup/rollback/fsync commit loop ────── + // Unlike the mock above (which throws before any file moves), these drive the ACTUAL + // AtomicFileStore commit path a rotation uses — the code touched by the CP-03/CP-04 change. + + function residue(root: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (p.endsWith(".tmp") || p.includes(".bak")) out.push(p); + } + }; + walk(root); + return out; + } + + it("a partial mid-commit crash rolls back: every secret still opens with the OLD password, none with the new", () => { + const root = mkdtempSync(join(tmpdir(), "ks-change-password-")); + const store = new AtomicFileStore(); + const ks = new Keystore(root, store, () => "OldPw1!aa"); + const seedRef = ks.import({ secret: MNEMONIC, type: "seed", label: "seed" }).accountId; + const keyRef = ks.import({ + secret: "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + type: "privateKey", + label: "hot", + }).accountId; + const vaultId = (ks.resolveAccount(seedRef).wallet.source as any).vaultId; + const keyId = (ks.resolveAccount(keyRef).wallet.source as any).keyId; + + // simulate a crash partway through the commit: let the first blob install, fail the next + let installs = 0; + store.commitRename = (from: string, to: string) => { + if (from.includes(".tmp")) { + installs++; + if (installs === 2) throw Object.assign(new Error("EIO"), { code: "EIO" }); + } + renameSync(from, to); + }; + + expect(() => ks.changePassword("OldPw1!aa", "NewPw2@bb")).toThrow(); + + // rollback must leave a consistent OLD-password keystore — never a mixed set + expect(ks.verifyPassword("OldPw1!aa")).toBe(true); + expect(ks.verifyPassword("NewPw2@bb")).toBe(false); + // both secrets must still decrypt under the old password via a fresh keystore reading from disk + const reopened = new Keystore(root, new AtomicFileStore(), () => "OldPw1!aa"); + expect(() => reopened.decryptSeed(vaultId)).not.toThrow(); + expect(() => reopened.decryptKey(keyId)).not.toThrow(); + // clean rollback leaves no half-written temps or stray backups + expect(residue(root)).toEqual([]); + }, 15_000); + + it("a successful rotation lands durably with no .tmp/.bak residue (fsync + backup cleanup)", () => { + const root = mkdtempSync(join(tmpdir(), "ks-change-password-")); + const store = new AtomicFileStore(); + const ks = new Keystore(root, store, () => "OldPw1!aa"); + const seedRef = ks.import({ secret: MNEMONIC, type: "seed", label: "seed" }).accountId; + const keyRef = ks.import({ + secret: "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + type: "privateKey", + label: "hot", + }).accountId; + const vaultId = (ks.resolveAccount(seedRef).wallet.source as any).vaultId; + const keyId = (ks.resolveAccount(keyRef).wallet.source as any).keyId; + + ks.changePassword("OldPw1!aa", "NewPw2@bb"); + + // new password opens every blob through a fresh on-disk read; the backups are gone + const reopened = new Keystore(root, new AtomicFileStore(), () => "NewPw2@bb"); + expect(() => reopened.decryptSeed(vaultId)).not.toThrow(); + expect(() => reopened.decryptKey(keyId)).not.toThrow(); + expect(residue(root)).toEqual([]); + }, 15_000); }); diff --git a/ts/src/adapters/outbound/persistence/fs/fs.test.ts b/ts/src/adapters/outbound/persistence/fs/fs.test.ts index c376e66c..b3b7f8b9 100644 --- a/ts/src/adapters/outbound/persistence/fs/fs.test.ts +++ b/ts/src/adapters/outbound/persistence/fs/fs.test.ts @@ -85,4 +85,42 @@ describe("AtomicFileStore.writeJsonAll", () => { expect(readdirSync(root).some((f) => f.includes(".bak"))).toBe(true); expect(existsSync(a)).toBe(true); }); + + it("fsyncs every staged temp before commit and the target directory after (CP-03)", () => { + const root = mkdtempSync(join(tmpdir(), "fs-")); + const a = join(root, "a.json"); + const b = join(root, "b.json"); + + const store = new AtomicFileStore(); + const calls: string[] = []; + store.fsyncFile = (p: string) => calls.push(`file:${p.endsWith(".tmp") ? "tmp" : "other"}`); + store.fsyncDir = (d: string) => calls.push(`dir:${d === root ? "root" : "other"}`); + + store.writeJsonAll([ + { path: a, value: "A" }, + { path: b, value: "B" }, + ]); + + // both temps land before the single directory barrier that publishes the renames + expect(calls).toEqual(["file:tmp", "file:tmp", "dir:root"]); + }); + + it("backs up an existing target under a high-entropy name, never a resettable counter (CP-04)", () => { + const root = mkdtempSync(join(tmpdir(), "fs-")); + const a = join(root, "a.json"); + writeFileSync(a, '"old-A"\n'); + + const store = new AtomicFileStore(); + const bakTargets: string[] = []; + store.commitRename = (from: string, to: string) => { + if (to.endsWith(".bak")) bakTargets.push(to); + renameSync(from, to); + }; + + store.writeJsonAll([{ path: a, value: "new-A" }]); + + expect(bakTargets).toHaveLength(1); + // 128-bit hex suffix, not `..bak` — so a leftover .bak can never be reused/overwritten + expect(bakTargets[0]).toMatch(/\.[0-9a-f]{32}\.bak$/); + }); }); diff --git a/ts/src/adapters/outbound/persistence/fs/index.ts b/ts/src/adapters/outbound/persistence/fs/index.ts index 8509ec2e..f56ce9d9 100644 --- a/ts/src/adapters/outbound/persistence/fs/index.ts +++ b/ts/src/adapters/outbound/persistence/fs/index.ts @@ -3,7 +3,8 @@ * Writes go to a temp file then rename into place; concurrent processes coordinate * via an O_EXCL lockfile so they don't clobber each other (wallets.json/config.yaml). */ -import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; +import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; +import { randomBytes } from "node:crypto"; import { dirname } from "node:path"; import { ExecutionError } from "../../../../domain/errors/index.js"; @@ -48,7 +49,9 @@ export class AtomicFileStore { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.${process.pid}.${this.#counter++}.tmp`; writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + this.fsyncFile(tmp); // durably land the content before the rename that publishes it renameSync(tmp, path); // atomic replace on same filesystem + this.fsyncDir(dirname(path)); // durably land the rename (the directory entry) } /** transactional multi-file write: stage every temp first, then commit each into place while @@ -64,6 +67,7 @@ export class AtomicFileStore { const tmp = `${path}.${process.pid}.${this.#counter++}.tmp`; staged.push({ tmp, path }); writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + this.fsyncFile(tmp); // land every staged blob before any commit rename runs } } catch (e) { for (const { tmp } of staged) { try { unlinkSync(tmp); } catch { /* best-effort */ } } @@ -74,8 +78,13 @@ export class AtomicFileStore { const committed: Array<{ path: string; bak: string | null }> = []; try { for (const { tmp, path } of staged) { - const bak = existsSync(path) ? `${path}.${process.pid}.${this.#counter++}.bak` : null; - if (bak) this.commitRename(path, bak); // set aside the old blob + // high-entropy backup name: a 128-bit random suffix can't collide with a leftover .bak + // from a prior crashed run, so the commit never overwrites an existing recovery copy. + const bak = existsSync(path) ? `${path}.${randomBytes(16).toString("hex")}.bak` : null; + if (bak) { + if (existsSync(bak)) throw new ExecutionError("io_error", `backup path already exists, refusing to overwrite: ${bak}`); + this.commitRename(path, bak); // set aside the old blob + } committed.push({ path, bak }); // record BEFORE the tmp rename so restore covers this file this.commitRename(tmp, path); // install the new blob } @@ -97,6 +106,9 @@ export class AtomicFileStore { } throw e; // clean rollback: every target restored to its prior state } + // durably land every committed rename before reporting success (still not multi-file atomic — + // that needs the journal in CP-01 — but each installed blob now survives power loss). + for (const dir of new Set(staged.map((s) => dirname(s.path)))) this.fsyncDir(dir); for (const { bak } of committed) { if (bak) { try { unlinkSync(bak); } catch { /* ignore */ } } } } @@ -106,11 +118,33 @@ export class AtomicFileStore { renameSync(from, to); } + /** fsync a file's bytes to stable storage. Separate seam so tests can observe the barrier. */ + fsyncFile(path: string): void { + const fd = openSync(path, "r"); + try { fsyncSync(fd); } finally { closeSync(fd); } + } + + /** fsync a directory so a rename into it survives power loss. Best-effort: some platforms + * (e.g. Windows) reject a directory fsync — there durability falls back to the OS. */ + fsyncDir(dir: string): void { + let fd: number | undefined; + try { + fd = openSync(dir, "r"); + fsyncSync(fd); + } catch { + /* directory fsync unsupported — best-effort */ + } finally { + if (fd !== undefined) closeSync(fd); + } + } + writeText(path: string, text: string): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.${process.pid}.${this.#counter++}.tmp`; writeFileSync(tmp, text, { mode: 0o600 }); + this.fsyncFile(tmp); renameSync(tmp, path); + this.fsyncDir(dirname(path)); } withLock(path: string, fn: () => T, opts: { timeoutMs?: number; staleMs?: number } = {}): T { diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index a2e0aac1..fa48dc20 100644 --- a/ts/src/bootstrap/runner.ts +++ b/ts/src/bootstrap/runner.ts @@ -44,6 +44,7 @@ export async function main(argv: string[]): Promise { }); return normalized.exitCode(); } finally { + runtime.deps.secrets.clearPrimed(); // release cached secrets at end of the invocation runtime.prompter.close(); } } From 9f34014a34e920213b9dd82718b5b7a37b629583 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 15 Jul 2026 19:31:59 +0800 Subject: [PATCH 12/16] fix(ts): fsync durability on rollback path & propagate real dir-fsync errors - writeJsonAll clean-rollback now fsyncs affected dirs before rethrowing, so a power loss right after rollback can't resurrect a half-committed state - fsyncDir no longer swallows all errors: skip on Windows (can't open a dir handle), tolerate only POSIX not-applicable codes (EINVAL/ENOTSUP/ EOPNOTSUPP/ENOSYS), and propagate real faults (EIO/ENOSPC/EACCES/EBADF) - split raw syscall into overridable rawFsyncDir seam for fault injection - add tests: rollback dir-fsync, real-error propagation, unsupported-code swallow Co-Authored-By: Claude Opus 4.8 (1M context) --- .../outbound/persistence/fs/fs.test.ts | 45 +++++++++++++++++++ .../adapters/outbound/persistence/fs/index.ts | 34 ++++++++++---- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/ts/src/adapters/outbound/persistence/fs/fs.test.ts b/ts/src/adapters/outbound/persistence/fs/fs.test.ts index b3b7f8b9..da20cd0d 100644 --- a/ts/src/adapters/outbound/persistence/fs/fs.test.ts +++ b/ts/src/adapters/outbound/persistence/fs/fs.test.ts @@ -105,6 +105,37 @@ describe("AtomicFileStore.writeJsonAll", () => { expect(calls).toEqual(["file:tmp", "file:tmp", "dir:root"]); }); + it("fsyncs the affected directory during a clean rollback, before rethrowing", () => { + const root = mkdtempSync(join(tmpdir(), "fs-")); + const a = join(root, "a.json"); + const b = join(root, "b.json"); + writeFileSync(a, '"old-A"\n'); + writeFileSync(b, '"old-B"\n'); + + const store = new AtomicFileStore(); + const dirSyncs: string[] = []; + store.fsyncDir = (d: string) => dirSyncs.push(d); + let installs = 0; + store.commitRename = (from: string, to: string) => { + if (from.includes(".tmp")) { + installs++; + if (installs === 2) throw EIO(); // A commits, then B install fails → clean rollback + } + renameSync(from, to); + }; + + expect(() => + store.writeJsonAll([ + { path: a, value: "new-A" }, + { path: b, value: "new-B" }, + ]), + ).toThrow(); + + // the restore renames must be durably landed (dir fsynced) before the error surfaces + expect(dirSyncs).toContain(root); + expect(JSON.parse(readFileSync(a, "utf8"))).toBe("old-A"); + }); + it("backs up an existing target under a high-entropy name, never a resettable counter (CP-04)", () => { const root = mkdtempSync(join(tmpdir(), "fs-")); const a = join(root, "a.json"); @@ -124,3 +155,17 @@ describe("AtomicFileStore.writeJsonAll", () => { expect(bakTargets[0]).toMatch(/\.[0-9a-f]{32}\.bak$/); }); }); + +describe("AtomicFileStore.fsyncDir", () => { + it("propagates a real I/O error instead of silently reporting a durable write", () => { + const store = new AtomicFileStore(); + store.rawFsyncDir = () => { throw EIO(); }; + expect(() => store.fsyncDir("/anything")).toThrowError(expect.objectContaining({ code: "EIO" })); + }); + + it("swallows an unsupported-directory-fsync error (best-effort)", () => { + const store = new AtomicFileStore(); + store.rawFsyncDir = () => { throw Object.assign(new Error("EINVAL"), { code: "EINVAL" }); }; + expect(() => store.fsyncDir("/anything")).not.toThrow(); + }); +}); diff --git a/ts/src/adapters/outbound/persistence/fs/index.ts b/ts/src/adapters/outbound/persistence/fs/index.ts index f56ce9d9..26a7a155 100644 --- a/ts/src/adapters/outbound/persistence/fs/index.ts +++ b/ts/src/adapters/outbound/persistence/fs/index.ts @@ -33,6 +33,11 @@ function isStaleLock(lock: string, staleMs: number): boolean { return Date.now() - st.mtimeMs > staleMs; // no readable owner → fall back to age } +/** Whether this platform can fsync a directory handle at all. Windows cannot open a directory as a + * file handle, so directory fsync is skipped there entirely and rename durability falls back to + * the OS. On POSIX we attempt it and only tolerate "not applicable" errors (see fsyncDir). */ +const DIR_FSYNC_SUPPORTED = process.platform !== "win32"; + export class AtomicFileStore { readJson(path: string): T | null { if (!existsSync(path)) return null; @@ -104,6 +109,9 @@ export class AtomicFileStore { { error: String(e), files: committed.map((c) => c.path).join(", ") }, ); } + // durably land the restore renames before surfacing the failure, so a power loss right + // after a clean rollback can't resurrect a half-committed state on restart. + for (const dir of new Set(committed.map((c) => dirname(c.path)))) this.fsyncDir(dir); throw e; // clean rollback: every target restored to its prior state } // durably land every committed rename before reporting success (still not multi-file atomic — @@ -124,17 +132,25 @@ export class AtomicFileStore { try { fsyncSync(fd); } finally { closeSync(fd); } } - /** fsync a directory so a rename into it survives power loss. Best-effort: some platforms - * (e.g. Windows) reject a directory fsync — there durability falls back to the OS. */ + /** raw directory fsync syscall — overridable seam so tests can inject a failure. */ + rawFsyncDir(dir: string): void { + const fd = openSync(dir, "r"); + try { fsyncSync(fd); } finally { closeSync(fd); } + } + + /** fsync a directory so a rename into it survives power loss. Skipped on platforms that can't + * fsync a directory handle (Windows). On POSIX only "not applicable" errors are tolerated; a + * real I/O/permission fault propagates rather than being reported as a durable write. */ fsyncDir(dir: string): void { - let fd: number | undefined; + if (!DIR_FSYNC_SUPPORTED) return; // Windows: can't open a dir as a handle; OS handles metadata durability try { - fd = openSync(dir, "r"); - fsyncSync(fd); - } catch { - /* directory fsync unsupported — best-effort */ - } finally { - if (fd !== undefined) closeSync(fd); + this.rawFsyncDir(dir); + } catch (e) { + // some POSIX filesystems (FAT, network/FUSE mounts) reject fsync on a directory fd — tolerate + // only those "not applicable" codes; EIO/ENOSPC/EACCES/EBADF stay real faults and propagate. + const code = (e as NodeJS.ErrnoException)?.code; + if (code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "ENOSYS") return; + throw e; } } From 4bebaab6a723114ec0834437c1e9d9ad0d3b4a2c Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 16 Jul 2026 01:10:37 +0800 Subject: [PATCH 13/16] fix(ts): don't mislabel a post-fsync failure as a clean rollback Making fsyncDir propagate real errors exposed two paths where writeJsonAll threw a bare error that changePassword then mislabeled as "rolled back": - post-commit success path: the new blobs are already installed and readable, so a dir-fsync failure there is NOT a rollback. Throw a distinct io_error ("committed but durability unconfirmed; new state in effect") and retain the backups instead of cleaning them up, so callers stop reporting it as reverted. - clean-rollback path: restores already succeeded, but their fsync failing let a bare error escape and masked the original write failure. Throw a precise io_error ("rolled back, durability of restore unconfirmed") preserving the original cause. Both now surface as ExecutionError, which changePassword passes through unmasked, so the error message always matches the real on-disk state. Add tests for both paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../outbound/persistence/fs/fs.test.ts | 46 +++++++++++++++++++ .../adapters/outbound/persistence/fs/index.ts | 27 ++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/ts/src/adapters/outbound/persistence/fs/fs.test.ts b/ts/src/adapters/outbound/persistence/fs/fs.test.ts index da20cd0d..f6fda4e2 100644 --- a/ts/src/adapters/outbound/persistence/fs/fs.test.ts +++ b/ts/src/adapters/outbound/persistence/fs/fs.test.ts @@ -105,6 +105,24 @@ describe("AtomicFileStore.writeJsonAll", () => { expect(calls).toEqual(["file:tmp", "file:tmp", "dir:root"]); }); + it("keeps committed data and retains backups when the post-commit dir fsync fails (not a rollback)", () => { + const root = mkdtempSync(join(tmpdir(), "fs-")); + const a = join(root, "a.json"); + writeFileSync(a, '"old-A"\n'); + + const store = new AtomicFileStore(); + store.fsyncDir = () => { throw EIO(); }; // the post-commit durability barrier fails + + // the commit already succeeded, so this must NOT be masked as a clean rollback + expect(() => store.writeJsonAll([{ path: a, value: "new-A" }])) + .toThrowError(expect.objectContaining({ code: "io_error" })); + + // new blob is installed and readable — the state was NOT reverted + expect(JSON.parse(readFileSync(a, "utf8"))).toBe("new-A"); + // the old blob is retained as a .bak for recovery instead of being cleaned up + expect(readdirSync(root).some((f) => f.includes(".bak"))).toBe(true); + }); + it("fsyncs the affected directory during a clean rollback, before rethrowing", () => { const root = mkdtempSync(join(tmpdir(), "fs-")); const a = join(root, "a.json"); @@ -136,6 +154,34 @@ describe("AtomicFileStore.writeJsonAll", () => { expect(JSON.parse(readFileSync(a, "utf8"))).toBe("old-A"); }); + it("reports rollback-durability-uncertain (not a bare error) when the rollback fsync fails", () => { + const root = mkdtempSync(join(tmpdir(), "fs-")); + const a = join(root, "a.json"); + const b = join(root, "b.json"); + writeFileSync(a, '"old-A"\n'); + writeFileSync(b, '"old-B"\n'); + + const store = new AtomicFileStore(); + store.fsyncDir = () => { throw EIO(); }; // durability barrier fails on the rollback path too + let installs = 0; + store.commitRename = (from: string, to: string) => { + if (from.includes(".tmp")) { + installs++; + if (installs === 2) throw EIO(); // A commits, then B install fails → clean rollback + } + renameSync(from, to); + }; + + // rollback succeeded (A restored) but its fsync failed → a precise io_error, not a bare EIO + expect(() => + store.writeJsonAll([ + { path: a, value: "new-A" }, + { path: b, value: "new-B" }, + ]), + ).toThrowError(expect.objectContaining({ code: "io_error" })); + expect(JSON.parse(readFileSync(a, "utf8"))).toBe("old-A"); + }); + it("backs up an existing target under a high-entropy name, never a resettable counter (CP-04)", () => { const root = mkdtempSync(join(tmpdir(), "fs-")); const a = join(root, "a.json"); diff --git a/ts/src/adapters/outbound/persistence/fs/index.ts b/ts/src/adapters/outbound/persistence/fs/index.ts index 26a7a155..97dc8455 100644 --- a/ts/src/adapters/outbound/persistence/fs/index.ts +++ b/ts/src/adapters/outbound/persistence/fs/index.ts @@ -111,12 +111,35 @@ export class AtomicFileStore { } // durably land the restore renames before surfacing the failure, so a power loss right // after a clean rollback can't resurrect a half-committed state on restart. - for (const dir of new Set(committed.map((c) => dirname(c.path)))) this.fsyncDir(dir); + try { + for (const dir of new Set(committed.map((c) => dirname(c.path)))) this.fsyncDir(dir); + } catch (fsyncErr) { + // Restores already succeeded (prior state is readable), but their durability is unconfirmed. + // Surface that precisely and keep the original write failure as the cause, instead of + // letting a bare fsync error escape and masking why we rolled back in the first place. + throw new ExecutionError( + "io_error", + "keystore write failed and was rolled back, but the rollback's durability could not be confirmed; the prior state is in effect — verify and retry.", + { error: String(e), fsyncError: String(fsyncErr), files: committed.map((c) => c.path).join(", ") }, + ); + } throw e; // clean rollback: every target restored to its prior state } // durably land every committed rename before reporting success (still not multi-file atomic — // that needs the journal in CP-01 — but each installed blob now survives power loss). - for (const dir of new Set(staged.map((s) => dirname(s.path)))) this.fsyncDir(dir); + try { + for (const dir of new Set(staged.map((s) => dirname(s.path)))) this.fsyncDir(dir); + } catch (e) { + // The commit already succeeded here: every new blob is installed and readable. This is NOT a + // rollback — the new state is in effect — so DON'T undo it (a rollback rename would need the + // very dir fsync that just failed). Surface a distinct, accurate error and KEEP the backups + // for recovery rather than cleaning them up. Callers must not report this as "rolled back". + throw new ExecutionError( + "io_error", + "keystore write committed but durability could not be confirmed; the new state is in effect — verify and retry. Backups retained.", + { error: String(e), files: committed.map((c) => c.path).join(", ") }, + ); + } for (const { bak } of committed) { if (bak) { try { unlinkSync(bak); } catch { /* ignore */ } } } } From 0b410edd52022603929a2ad78b0993af578405fd Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 16 Jul 2026 13:30:01 +0800 Subject: [PATCH 14/16] fix(ts): correct doc command ids to unprefixed form & ship docs+skills in npm package Docs showed family-qualified command ids (tron.account.balance) but the runtime emits the unprefixed logical id (account.balance); the family travels only in the envelope's chain.family. Rewrote all 35 user-facing reference/guide docs to match actual output. Historical superpowers specs left untouched. Also add docs/ and skills/ to package.json files[] so README-linked reference pages and the agent skill actually ship (tarball 9 -> 93 files). Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/docs/commands/account/balance.md | 2 +- ts/docs/commands/account/history.md | 2 +- ts/docs/commands/account/info.md | 2 +- ts/docs/commands/account/portfolio.md | 2 +- ts/docs/commands/chain/node.md | 2 +- ts/docs/commands/chain/params.md | 2 +- ts/docs/commands/chain/prices.md | 2 +- ts/docs/commands/contract/call.md | 2 +- ts/docs/commands/contract/deploy.md | 2 +- ts/docs/commands/contract/info.md | 2 +- ts/docs/commands/contract/send.md | 2 +- ts/docs/commands/reward/balance.md | 2 +- ts/docs/commands/reward/withdraw.md | 2 +- ts/docs/commands/stake/cancel-unfreeze.md | 2 +- ts/docs/commands/stake/delegate.md | 2 +- ts/docs/commands/stake/delegated.md | 2 +- ts/docs/commands/stake/freeze.md | 2 +- ts/docs/commands/stake/info.md | 2 +- ts/docs/commands/stake/undelegate.md | 2 +- ts/docs/commands/stake/unfreeze.md | 2 +- ts/docs/commands/stake/withdraw.md | 2 +- ts/docs/commands/token/add.md | 2 +- ts/docs/commands/token/balance.md | 2 +- ts/docs/commands/token/info.md | 2 +- ts/docs/commands/token/list.md | 2 +- ts/docs/commands/token/remove.md | 2 +- ts/docs/commands/tx/broadcast.md | 2 +- ts/docs/commands/tx/info.md | 4 ++-- ts/docs/commands/tx/send.md | 2 +- ts/docs/commands/tx/status.md | 4 ++-- ts/docs/commands/vote/cast.md | 2 +- ts/docs/commands/vote/list.md | 2 +- ts/docs/commands/vote/status.md | 2 +- ts/docs/guide/scripting.md | 2 +- ts/docs/machine-interface.md | 6 +++--- ts/package.json | 2 ++ 36 files changed, 41 insertions(+), 39 deletions(-) diff --git a/ts/docs/commands/account/balance.md b/ts/docs/commands/account/balance.md index 38c3bfe5..d937acb9 100644 --- a/ts/docs/commands/account/balance.md +++ b/ts/docs/commands/account/balance.md @@ -32,7 +32,7 @@ wallet-cli account balance --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.account.balance","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","balance":"1976489000","decimals":6,"symbol":"TRX"},"meta":{"durationMs":1114,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"account.balance","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","balance":"1976489000","decimals":6,"symbol":"TRX"},"meta":{"durationMs":1114,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/account/history.md b/ts/docs/commands/account/history.md index 6dc9dcd0..3ce273ab 100644 --- a/ts/docs/commands/account/history.md +++ b/ts/docs/commands/account/history.md @@ -41,7 +41,7 @@ wallet-cli account history --limit 2 --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.account.history","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","only":"all","count":2,"records":[{"txId":"fb7f8e6b44cd9100f6d1133acea341a2f3d53ab140a93c95b8f2bd74d3a2b366","time":1783780503000,"type":"Transfer","amount":"1","symbol":"TRX","from":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","to":"TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH","counterparty":"TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH","status":"ok"},…]},"meta":{"durationMs":1556,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"account.history","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","only":"all","count":2,"records":[{"txId":"fb7f8e6b44cd9100f6d1133acea341a2f3d53ab140a93c95b8f2bd74d3a2b366","time":1783780503000,"type":"Transfer","amount":"1","symbol":"TRX","from":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","to":"TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH","counterparty":"TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH","status":"ok"},…]},"meta":{"durationMs":1556,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/account/info.md b/ts/docs/commands/account/info.md index c459072b..21355759 100644 --- a/ts/docs/commands/account/info.md +++ b/ts/docs/commands/account/info.md @@ -38,7 +38,7 @@ wallet-cli account info --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.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:nile","chainId":"nile"}} +{"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:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/account/portfolio.md b/ts/docs/commands/account/portfolio.md index b74bf8fc..54342261 100644 --- a/ts/docs/commands/account/portfolio.md +++ b/ts/docs/commands/account/portfolio.md @@ -35,7 +35,7 @@ wallet-cli account portfolio --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.account.portfolio","data":{"network":"tron:nile","account":"wlt_4473p34m.0","address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","priceSource":"coingecko","holdings":[{"kind":"native","symbol":"TRX","decimals":6,"rawBalance":"1976489000","balance":"1976.489","priceUsd":null,"valueUsd":null}],"totalValueUsd":null},"meta":{"durationMs":11031,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"account.portfolio","data":{"network":"tron:nile","account":"wlt_4473p34m.0","address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","priceSource":"coingecko","holdings":[{"kind":"native","symbol":"TRX","decimals":6,"rawBalance":"1976489000","balance":"1976.489","priceUsd":null,"valueUsd":null}],"totalValueUsd":null},"meta":{"durationMs":11031,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/chain/node.md b/ts/docs/commands/chain/node.md index ebfe80b9..064bf06f 100644 --- a/ts/docs/commands/chain/node.md +++ b/ts/docs/commands/chain/node.md @@ -37,7 +37,7 @@ wallet-cli chain node --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.chain.node","data":{"endpoint":"https://nile.trongrid.io","version":"java-tron 4.7.7","p2pVersion":"11111","headBlock":{"number":69093315,"timestamp":1783783761000},"solidBlock":{"number":69093296},"lagBlocks":19,"inSync":true,"peers":{"connected":30,"active":27}},"meta":{"durationMs":24,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"chain.node","data":{"endpoint":"https://nile.trongrid.io","version":"java-tron 4.7.7","p2pVersion":"11111","headBlock":{"number":69093315,"timestamp":1783783761000},"solidBlock":{"number":69093296},"lagBlocks":19,"inSync":true,"peers":{"connected":30,"active":27}},"meta":{"durationMs":24,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/chain/params.md b/ts/docs/commands/chain/params.md index 872e05e4..40eb3d82 100644 --- a/ts/docs/commands/chain/params.md +++ b/ts/docs/commands/chain/params.md @@ -65,7 +65,7 @@ wallet-cli chain params --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.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:nile","chainId":"nile"}} +{"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:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/chain/prices.md b/ts/docs/commands/chain/prices.md index c9a9c7c6..a0df8ffe 100644 --- a/ts/docs/commands/chain/prices.md +++ b/ts/docs/commands/chain/prices.md @@ -37,7 +37,7 @@ wallet-cli chain prices --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.chain.prices","data":{"energy":{"currentSunPerUnit":210,"history":[{"since":1542607200000,"price":100},{"since":1670515200000,"price":210}]},"bandwidth":{"currentSunPerUnit":1000,"history":[{"since":1542607200000,"price":10},{"since":1614456000000,"price":1000}]},"memoFeeSun":"1000000"},"meta":{"durationMs":21,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"chain.prices","data":{"energy":{"currentSunPerUnit":210,"history":[{"since":1542607200000,"price":100},{"since":1670515200000,"price":210}]},"bandwidth":{"currentSunPerUnit":1000,"history":[{"since":1542607200000,"price":10},{"since":1614456000000,"price":1000}]},"memoFeeSun":"1000000"},"meta":{"durationMs":21,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/contract/call.md b/ts/docs/commands/contract/call.md index 3c9937e5..7ae73427 100644 --- a/ts/docs/commands/contract/call.md +++ b/ts/docs/commands/contract/call.md @@ -40,7 +40,7 @@ wallet-cli contract call --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --method ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.contract.call","data":{"contract":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","method":"balanceOf(address)","result":["0000000000000000000000000000000000000000000000000000000000000000"]},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"contract.call","data":{"contract":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","method":"balanceOf(address)","result":["0000000000000000000000000000000000000000000000000000000000000000"]},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/contract/deploy.md b/ts/docs/commands/contract/deploy.md index 698bd631..af1beaf2 100644 --- a/ts/docs/commands/contract/deploy.md +++ b/ts/docs/commands/contract/deploy.md @@ -55,7 +55,7 @@ echo "$PW" | wallet-cli contract deploy --abi "$(cat MyToken.abi.json)" --byteco ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.contract.deploy","data":{"kind":"contract-deploy","contractAddress":"TXg3jWThoa5AxuwRA4aRyFAhmRN9hjhQFU","stage":"submitted","txId":"b7c..."},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"contract.deploy","data":{"kind":"contract-deploy","contractAddress":"TXg3jWThoa5AxuwRA4aRyFAhmRN9hjhQFU","stage":"submitted","txId":"b7c..."},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/contract/info.md b/ts/docs/commands/contract/info.md index dc7e3ffa..3989c1ad 100644 --- a/ts/docs/commands/contract/info.md +++ b/ts/docs/commands/contract/info.md @@ -37,7 +37,7 @@ wallet-cli contract info --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --network ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.contract.info","data":{"address":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","name":"TetherToken","functionCount":33,"methods":["name","deprecate","approve","deprecated","addBlackList","totalSupply","transferFrom","…"],"contract":{"origin_address":"41…","contract_address":"41…","abi":{},"bytecode":"…","name":"TetherToken"},"info":{"smart_contract":{},"runtimecode":"…","contract_state":{}}},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"contract.info","data":{"address":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","name":"TetherToken","functionCount":33,"methods":["name","deprecate","approve","deprecated","addBlackList","totalSupply","transferFrom","…"],"contract":{"origin_address":"41…","contract_address":"41…","abi":{},"bytecode":"…","name":"TetherToken"},"info":{"smart_contract":{},"runtimecode":"…","contract_state":{}}},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/contract/send.md b/ts/docs/commands/contract/send.md index 08cdaec1..9dd68f26 100644 --- a/ts/docs/commands/contract/send.md +++ b/ts/docs/commands/contract/send.md @@ -58,7 +58,7 @@ echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkA ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.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:nile","chainId":"nile"}} +{"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:nile","chainId":"nile"}} ``` With `--wait`, blocks until confirmed — on success: diff --git a/ts/docs/commands/reward/balance.md b/ts/docs/commands/reward/balance.md index 97a8432c..b9599959 100644 --- a/ts/docs/commands/reward/balance.md +++ b/ts/docs/commands/reward/balance.md @@ -49,7 +49,7 @@ wallet-cli reward balance --account main --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.reward.balance","data":{"address":"TQk...","rewardSun":"123456789","withdrawableNow":true,"withdrawableAt":null},"meta":{"durationMs":14,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"reward.balance","data":{"address":"TQk...","rewardSun":"123456789","withdrawableNow":true,"withdrawableAt":null},"meta":{"durationMs":14,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/reward/withdraw.md b/ts/docs/commands/reward/withdraw.md index 0aa156e8..8a5a1bdb 100644 --- a/ts/docs/commands/reward/withdraw.md +++ b/ts/docs/commands/reward/withdraw.md @@ -50,7 +50,7 @@ echo "$PW" | wallet-cli reward withdraw --network tron:nile --password-stdin -o ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.reward.withdraw","data":{"kind":"reward-withdraw","stage":"submitted","txId":"a1b...","rewardSun":"123456789"},"meta":{"durationMs":17,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"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:nile","chainId":"nile"}} ``` Add `--wait` to block until confirmed (adds real block / fee): diff --git a/ts/docs/commands/stake/cancel-unfreeze.md b/ts/docs/commands/stake/cancel-unfreeze.md index f9dddc49..778d1c26 100644 --- a/ts/docs/commands/stake/cancel-unfreeze.md +++ b/ts/docs/commands/stake/cancel-unfreeze.md @@ -47,7 +47,7 @@ echo "$PW" | wallet-cli stake cancel-unfreeze --network tron:nile --password-std ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.stake.cancel-unfreeze","data":{"kind":"stake-cancel","stage":"submitted","txId":"9ec..."},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"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:nile","chainId":"nile"}} ``` Add `--wait` to block until confirmed: diff --git a/ts/docs/commands/stake/delegate.md b/ts/docs/commands/stake/delegate.md index 0612f029..faa5a328 100644 --- a/ts/docs/commands/stake/delegate.md +++ b/ts/docs/commands/stake/delegate.md @@ -59,7 +59,7 @@ echo "$PW" | wallet-cli stake delegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDKtj ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.stake.delegate","data":{"kind":"stake-delegate","stage":"submitted","txId":"b7c...","amountSun":"1000000000","resource":"energy","receiver":"TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx"},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"stake.delegate","data":{"kind":"stake-delegate","stage":"submitted","txId":"b7c...","amountSun":"1000000000","resource":"energy","receiver":"TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx"},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/stake/delegated.md b/ts/docs/commands/stake/delegated.md index 60d11fa0..4fd3c56a 100644 --- a/ts/docs/commands/stake/delegated.md +++ b/ts/docs/commands/stake/delegated.md @@ -57,7 +57,7 @@ wallet-cli stake delegated --direction out --account main --network tron:nile -o ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.stake.delegated","data":{"address":"TQk...","direction":"out","canDelegateMaxSun":{"energy":"900000000","bandwidth":"300000000"},"delegations":[{"receiver":"TBy6...","resource":"energy","amountSun":"500000000","lockedUntil":1783468800000},{"receiver":"TXe4...","resource":"bandwidth","amountSun":"100000000","lockedUntil":null}]},"meta":{"durationMs":28,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"stake.delegated","data":{"address":"TQk...","direction":"out","canDelegateMaxSun":{"energy":"900000000","bandwidth":"300000000"},"delegations":[{"receiver":"TBy6...","resource":"energy","amountSun":"500000000","lockedUntil":1783468800000},{"receiver":"TXe4...","resource":"bandwidth","amountSun":"100000000","lockedUntil":null}]},"meta":{"durationMs":28,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` Inbound (`--direction in`) — the lock column becomes `Guaranteed until`, no Max delegatable: diff --git a/ts/docs/commands/stake/freeze.md b/ts/docs/commands/stake/freeze.md index 194e02be..18c20b74 100644 --- a/ts/docs/commands/stake/freeze.md +++ b/ts/docs/commands/stake/freeze.md @@ -52,7 +52,7 @@ echo "$PW" | wallet-cli stake freeze --amount-sun 1000000000 --resource energy - ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.stake.freeze","data":{"kind":"stake-freeze","stage":"submitted","txId":"c3d...","amountSun":"1000000000","resource":"energy"},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"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:nile","chainId":"nile"}} ``` Add `--wait` to block until confirmed: diff --git a/ts/docs/commands/stake/info.md b/ts/docs/commands/stake/info.md index 7e239bac..90cd183f 100644 --- a/ts/docs/commands/stake/info.md +++ b/ts/docs/commands/stake/info.md @@ -46,7 +46,7 @@ wallet-cli stake info --account main --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.stake.info","data":{"address":"TQk...","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:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"stake.info","data":{"address":"TQk...","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:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/stake/undelegate.md b/ts/docs/commands/stake/undelegate.md index fcc4edd8..618c6059 100644 --- a/ts/docs/commands/stake/undelegate.md +++ b/ts/docs/commands/stake/undelegate.md @@ -55,7 +55,7 @@ echo "$PW" | wallet-cli stake undelegate --receiver TYzp9RbQmtAjCtyGeHb9W7GRwjDK ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.stake.undelegate","data":{"kind":"stake-undelegate","stage":"submitted","txId":"c8d...","amountSun":"1000000000","resource":"energy","receiver":"TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx"},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"stake.undelegate","data":{"kind":"stake-undelegate","stage":"submitted","txId":"c8d...","amountSun":"1000000000","resource":"energy","receiver":"TYzp9RbQmtAjCtyGeHb9W7GRwjDKtjUvvx"},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/stake/unfreeze.md b/ts/docs/commands/stake/unfreeze.md index aca26194..0f515ca7 100644 --- a/ts/docs/commands/stake/unfreeze.md +++ b/ts/docs/commands/stake/unfreeze.md @@ -52,7 +52,7 @@ echo "$PW" | wallet-cli stake unfreeze --amount-sun 1000000000 --resource energy ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.stake.unfreeze","data":{"kind":"stake-unfreeze","stage":"submitted","txId":"d4e...","amountSun":"1000000000","resource":"energy"},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"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:nile","chainId":"nile"}} ``` Add `--wait` to block until confirmed: diff --git a/ts/docs/commands/stake/withdraw.md b/ts/docs/commands/stake/withdraw.md index d271546a..1ec441d7 100644 --- a/ts/docs/commands/stake/withdraw.md +++ b/ts/docs/commands/stake/withdraw.md @@ -49,7 +49,7 @@ echo "$PW" | wallet-cli stake withdraw --network tron:nile --password-stdin -o j ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.stake.withdraw","data":{"kind":"stake-withdraw","stage":"submitted","txId":"e5f..."},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"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:nile","chainId":"nile"}} ``` Add `--wait` to block until confirmed: diff --git a/ts/docs/commands/token/add.md b/ts/docs/commands/token/add.md index 2238a3ce..4d214bb5 100644 --- a/ts/docs/commands/token/add.md +++ b/ts/docs/commands/token/add.md @@ -41,7 +41,7 @@ wallet-cli token add --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --network tro ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.token.add","data":{"network":"tron:nile","account":"wlt_b2.0","action":"added","token":{"kind":"trc20","id":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"name":"Tether USD"}},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"token.add","data":{"network":"tron:nile","account":"wlt_b2.0","action":"added","token":{"kind":"trc20","id":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"name":"Tether USD"}},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/token/balance.md b/ts/docs/commands/token/balance.md index 4e9f8236..4dbce7e3 100644 --- a/ts/docs/commands/token/balance.md +++ b/ts/docs/commands/token/balance.md @@ -38,7 +38,7 @@ wallet-cli token balance --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --network ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.token.balance","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","token":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","balance":"1204560000","symbol":"USDT","decimals":6},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"token.balance","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","token":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","balance":"1204560000","symbol":"USDT","decimals":6},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/token/info.md b/ts/docs/commands/token/info.md index f678b4d2..17fb90f4 100644 --- a/ts/docs/commands/token/info.md +++ b/ts/docs/commands/token/info.md @@ -38,7 +38,7 @@ wallet-cli token info --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --network tr ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.token.info","data":{"contract":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","name":"Tether USD","symbol":"USDT","decimals":6,"totalSupply":"17600000000030000000"},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"token.info","data":{"contract":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","name":"Tether USD","symbol":"USDT","decimals":6,"totalSupply":"17600000000030000000"},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/token/list.md b/ts/docs/commands/token/list.md index 129affc6..e9544e28 100644 --- a/ts/docs/commands/token/list.md +++ b/ts/docs/commands/token/list.md @@ -35,7 +35,7 @@ wallet-cli token list --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.token.list","data":{"network":"tron:nile","account":"wlt_b2.0","tokens":[{"kind":"trc20","id":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"name":"Tether USD","source":"user"}]},"meta":{"durationMs":13,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"token.list","data":{"network":"tron:nile","account":"wlt_b2.0","tokens":[{"kind":"trc20","id":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"name":"Tether USD","source":"user"}]},"meta":{"durationMs":13,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/token/remove.md b/ts/docs/commands/token/remove.md index a2e637ce..2e006b52 100644 --- a/ts/docs/commands/token/remove.md +++ b/ts/docs/commands/token/remove.md @@ -40,7 +40,7 @@ wallet-cli token remove --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf --network ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.token.remove","data":{"network":"tron:nile","account":"wlt_b2.0","removed":{"kind":"trc20","id":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"name":"Tether USD"}},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"token.remove","data":{"network":"tron:nile","account":"wlt_b2.0","removed":{"kind":"trc20","id":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","symbol":"USDT","decimals":6,"name":"Tether USD"}},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/tx/broadcast.md b/ts/docs/commands/tx/broadcast.md index 09d43b81..f4fd1ca6 100644 --- a/ts/docs/commands/tx/broadcast.md +++ b/ts/docs/commands/tx/broadcast.md @@ -52,7 +52,7 @@ wallet-cli tx broadcast --tx-stdin --network tron:nile < signed.json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.tx.broadcast","data":{"kind":"broadcast","stage":"submitted","txId":"72a315303323125708f426c77b94c5215afd8964ed27d67e49c29b56e29078f5"},"meta":{"durationMs":926,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"tx.broadcast","data":{"kind":"broadcast","stage":"submitted","txId":"72a315303323125708f426c77b94c5215afd8964ed27d67e49c29b56e29078f5"},"meta":{"durationMs":926,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/tx/info.md b/ts/docs/commands/tx/info.md index 1c16108d..fc24d207 100644 --- a/ts/docs/commands/tx/info.md +++ b/ts/docs/commands/tx/info.md @@ -40,13 +40,13 @@ Block #69,084,269 `-o json` returns the full detail (`transaction` is the raw tx, `info` is the receipt; elided as `{…}` here): ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.tx.info","data":{"txid":"52332505ab6b605aff626aaef2b07f3718d4bac8f45cdab1c0ea9465eb98e065","from":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","to":"TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH","amount":"1","symbol":"TRX","status":"SUCCESS","blockNumber":69084269,"transaction":{…},"info":{…}},"meta":{"durationMs":1396,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"tx.info","data":{"txid":"52332505ab6b605aff626aaef2b07f3718d4bac8f45cdab1c0ea9465eb98e065","from":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","to":"TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH","amount":"1","symbol":"TRX","status":"SUCCESS","blockNumber":69084269,"transaction":{…},"info":{…}},"meta":{"durationMs":1396,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` An unknown txid errors out (`rpc_error`, exit 1) — unlike `tx status`'s `not_found` (exit 0): ```json -{"schema":"wallet-cli.result.v1","success":false,"command":"tron.tx.info","error":{"code":"rpc_error","message":"TRON getTransaction failed: Transaction not found"},"meta":{"durationMs":1033,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"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:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/tx/send.md b/ts/docs/commands/tx/send.md index faa2fd72..63e44db9 100644 --- a/ts/docs/commands/tx/send.md +++ b/ts/docs/commands/tx/send.md @@ -76,7 +76,7 @@ printf '%s' "$PW" | wallet-cli tx send --to TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH - ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.tx.send","data":{"kind":"send","stage":"submitted","txId":"4574b646adc694e99a1f64e548b2bdf9da62621c2d833f77354f67b751fbd0c4","rawAmount":"1000000","to":"TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH"},"meta":{"durationMs":2172,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"tx.send","data":{"kind":"send","stage":"submitted","txId":"4574b646adc694e99a1f64e548b2bdf9da62621c2d833f77354f67b751fbd0c4","rawAmount":"1000000","to":"TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH"},"meta":{"durationMs":2172,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/tx/status.md b/ts/docs/commands/tx/status.md index 877f9d98..8b306447 100644 --- a/ts/docs/commands/tx/status.md +++ b/ts/docs/commands/tx/status.md @@ -39,13 +39,13 @@ Status confirmed ✅ ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.tx.status","data":{"txid":"7d9b6a08505537f7fd51ed4fb4223ce89098403d26e8d3fe07bdb3d625a46364","state":"confirmed","confirmed":true,"failed":false,"blockNumber":68822193},"meta":{"durationMs":1006,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"tx.status","data":{"txid":"7d9b6a08505537f7fd51ed4fb4223ce89098403d26e8d3fe07bdb3d625a46364","state":"confirmed","confirmed":true,"failed":false,"blockNumber":68822193},"meta":{"durationMs":1006,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` An unknown txid is a **success** with `state: "not_found"` (exit 0) — the query worked; the answer is "not there": ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.tx.status","data":{"txid":"0000…0000","state":"not_found","confirmed":false,"failed":false},"meta":{"durationMs":1022,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"tx.status","data":{"txid":"0000…0000","state":"not_found","confirmed":false,"failed":false},"meta":{"durationMs":1022,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/vote/cast.md b/ts/docs/commands/vote/cast.md index 170232bc..a45c0939 100644 --- a/ts/docs/commands/vote/cast.md +++ b/ts/docs/commands/vote/cast.md @@ -57,7 +57,7 @@ echo "$PW" | wallet-cli vote cast --for TZ4...=600 --for TT5...=400 --network tr ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.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:nile","chainId":"nile"}} +{"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:nile","chainId":"nile"}} ``` Add `--wait` to block until the vote is confirmed on chain (adds real block / fee): diff --git a/ts/docs/commands/vote/list.md b/ts/docs/commands/vote/list.md index 0ab73fa8..6340fd62 100644 --- a/ts/docs/commands/vote/list.md +++ b/ts/docs/commands/vote/list.md @@ -45,7 +45,7 @@ wallet-cli vote list --limit 3 --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.vote.list","data":{"witnesses":[{"rank":1,"name":"TRONSCAN","address":"TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g","voteCount":"1203456789","rewardRatioPct":80,"brokeragePct":20,"aprPct":4.8}]},"meta":{"durationMs":40,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"vote.list","data":{"witnesses":[{"rank":1,"name":"TRONSCAN","address":"TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g","voteCount":"1203456789","rewardRatioPct":80,"brokeragePct":20,"aprPct":4.8}]},"meta":{"durationMs":40,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/commands/vote/status.md b/ts/docs/commands/vote/status.md index 884759e8..ef5142ff 100644 --- a/ts/docs/commands/vote/status.md +++ b/ts/docs/commands/vote/status.md @@ -44,7 +44,7 @@ wallet-cli vote status --account main --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.vote.status","data":{"address":"TQk...","votingPower":{"total":1500,"used":1000,"available":500},"claimableRewardSun":"12345678","votes":[{"witness":"TZ4...","name":"TRONSCAN","count":600,"rewardRatioPct":80,"brokeragePct":20,"aprPct":4.8},{"witness":"TT5...","name":"Binance Staking","count":400,"rewardRatioPct":0,"brokeragePct":100,"aprPct":0}]},"meta":{"durationMs":16,"warnings":[{"code":"zero_reward_ratio","message":"400 votes on TT5... (Binance Staking) earn nothing: reward ratio is 0%"}]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"vote.status","data":{"address":"TQk...","votingPower":{"total":1500,"used":1000,"available":500},"claimableRewardSun":"12345678","votes":[{"witness":"TZ4...","name":"TRONSCAN","count":600,"rewardRatioPct":80,"brokeragePct":20,"aprPct":4.8},{"witness":"TT5...","name":"Binance Staking","count":400,"rewardRatioPct":0,"brokeragePct":100,"aprPct":0}]},"meta":{"durationMs":16,"warnings":[{"code":"zero_reward_ratio","message":"400 votes on TT5... (Binance Staking) earn nothing: reward ratio is 0%"}]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output diff --git a/ts/docs/guide/scripting.md b/ts/docs/guide/scripting.md index 3a68502f..f48489b5 100644 --- a/ts/docs/guide/scripting.md +++ b/ts/docs/guide/scripting.md @@ -11,7 +11,7 @@ wallet-cli account balance --network tron:nile -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.account.balance","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","balance":"1976489000","decimals":6,"symbol":"TRX"},"meta":{"durationMs":1114,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"account.balance","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","balance":"1976489000","decimals":6,"symbol":"TRX"},"meta":{"durationMs":1114,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` **2. Check the exit code, then `error.code`.** `0` success, `1` runtime failure, `2` you built the command wrong: diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index b9e79fb6..ec72528f 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -32,7 +32,7 @@ Schema id: `wallet-cli.result.v1`. { "schema": "wallet-cli.result.v1", "success": true, - "command": "tron.account.balance", + "command": "account.balance", "data": { "address": "TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ", "balance": "1976489000", "decimals": 6, "symbol": "TRX" }, "meta": { "durationMs": 1114, "warnings": [] }, "chain": { "family": "tron", "network": "tron:nile", "chainId": "nile" } @@ -45,7 +45,7 @@ Schema id: `wallet-cli.result.v1`. { "schema": "wallet-cli.result.v1", "success": false, - "command": "tron.tx.info", + "command": "tx.info", "error": { "code": "rpc_error", "message": "TRON getTransaction failed: Transaction not found" }, "meta": { "durationMs": 1033, "warnings": [] }, "chain": { "family": "tron", "network": "tron:nile", "chainId": "nile" } @@ -56,7 +56,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. `tron.tx.send`, `list` | +| `command` | string | always | Canonical command id, e.g. `tx.send`, `list` | | `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 | diff --git a/ts/package.json b/ts/package.json index eb1d0dcf..607db144 100644 --- a/ts/package.json +++ b/ts/package.json @@ -8,6 +8,8 @@ }, "files": [ "dist", + "docs", + "skills", "README.md", "LICENSE" ], From 3e8e97e89dcfd558ba4b8fbf040bc3862d9ab918 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 16 Jul 2026 14:16:13 +0800 Subject: [PATCH 15/16] fix(ts): correct missed message.sign command id in docs Follow-up to 0b410edd: the message namespace was not in the previous replacement set, so tron.message.sign remained. Now unprefixed like the rest. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/docs/commands/message/sign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/docs/commands/message/sign.md b/ts/docs/commands/message/sign.md index 7ff67676..c6073902 100644 --- a/ts/docs/commands/message/sign.md +++ b/ts/docs/commands/message/sign.md @@ -50,7 +50,7 @@ echo "$PW" | wallet-cli message sign --message "hello" --password-stdin -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"tron.message.sign","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","message":"hello","signature":"0x9f3c..."},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"message.sign","data":{"address":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ","message":"hello","signature":"0x9f3c..."},"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` Ledger account — message via stdin, confirm on device: From d3e2fc2a6bcf94a1348671309884f9f705de3d43 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 16 Jul 2026 14:24:18 +0800 Subject: [PATCH 16/16] docs(ts): add output examples to block and import ledger reference pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These two were the only non-interactive commands lacking a JSON output example (change-password stays exampleless — it produces no structured output by design). Shapes derived from the actual use-case returns: block -> {block: } (large fields elided as … per the tx.info convention); import.ledger -> the shared account descriptor with type "ledger" and the device path. Co-Authored-By: Claude Opus 4.8 (1M context) --- ts/docs/commands/block.md | 32 +++++++++++++++++++++++++ ts/docs/commands/import/ledger.md | 40 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/ts/docs/commands/block.md b/ts/docs/commands/block.md index 09f35cf9..4900f09f 100644 --- a/ts/docs/commands/block.md +++ b/ts/docs/commands/block.md @@ -20,6 +20,38 @@ wallet-cli block [] [options] Requires `--network` (or config.defaultNetwork). +## Examples + +```bash +wallet-cli block --network tron:nile +``` + +```console +Number #69,093,315 +Time 2026-07-11 15:29:21 UTC +Transactions 212 +``` + +```bash +wallet-cli block 69093315 --network tron: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:nile","chainId":"nile"}} +``` + +## Output + +`data.block` is the raw TRON block as returned by the node, unmodified. 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). + +| Field | Type | Meaning | +|---|---|---| +| `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) | +| `block.block_header.raw_data.witness_address` | string | Producing SR, hex (`41…`) | +| `block.transactions` | array | Transactions in the block (omitted when empty) | + ## Exit status `0` success · `1` execution failure · `2` usage error. See [machine-interface](../machine-interface.md). diff --git a/ts/docs/commands/import/ledger.md b/ts/docs/commands/import/ledger.md index 77ca77ef..542a945c 100644 --- a/ts/docs/commands/import/ledger.md +++ b/ts/docs/commands/import/ledger.md @@ -25,6 +25,46 @@ Plus [global options](../index.md). Creates a watch-only entry; no secret is stored. Requires the device unlocked with the TRON app open. See [Ledger guide](../../guide/ledger.md). +## Examples + +```bash +wallet-cli import ledger --app tron --index 0 --label cold +``` + +```console +✅ Registered Ledger account "cold" + Account ID wlt_7h2k9d3m + App tron + Path m/44'/195'/0'/0/0 + Address TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ + +⚠️ No private key is stored locally. Signing requires device confirmation. +``` + +```bash +wallet-cli import ledger --app tron --index 0 --label cold -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"import.ledger","data":{"status":"created","accountId":"wlt_7h2k9d3m","label":"cold","type":"ledger","index":null,"active":true,"addresses":{"tron":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ"},"family":"tron","path":"m/44'/195'/0'/0/0"},"meta":{"durationMs":812,"warnings":[]}} +``` + +## Output + +`data` carries the newly registered Ledger account — address and derivation path only, no secret. Local command — no `chain` block. + +| Field | Type | Meaning | +|---|---|---| +| `status` | string | `"created"`, or `"existing"` if the account was already registered | +| `accountId` | string | Stable account id | +| `label` | string | Account label | +| `type` | string | `"ledger"` (signs on device) | +| `index` | number \| null | Non-HD account, always `null` (device index lives in `path`) | +| `active` | boolean | Became the active account | +| `addresses.tron` | string | Base58 TRON address | +| `family` | string | Chain family of the address (e.g. `tron`) | +| `path` | string | BIP32 derivation path on the device | + ## Exit status `0` success · `1` execution failure · `2` usage error. See [machine-interface](../../machine-interface.md).