From 243876bb16be4fe6ee3217f883d3aa3bc7ca310f Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 12:08:38 +0800 Subject: [PATCH 01/24] feat(ts): add multisig permission management --- ts/package-lock.json | 2 +- ts/package.json | 2 +- .../adapters/inbound/cli/commands/artifact.ts | 24 ++ .../inbound/cli/commands/permission.ts | 96 +++++ .../inbound/cli/context/context.test.ts | 13 +- ts/src/adapters/inbound/cli/context/index.ts | 9 +- .../inbound/cli/contracts/envelope.ts | 5 +- .../adapters/inbound/cli/contracts/runtime.ts | 5 +- ts/src/adapters/inbound/cli/help/index.ts | 2 + .../adapters/inbound/cli/output/envelope.ts | 8 +- ts/src/adapters/inbound/cli/render/index.ts | 2 + .../adapters/inbound/cli/render/permission.ts | 81 +++++ ts/src/adapters/inbound/cli/render/tx.ts | 10 + ts/src/adapters/inbound/cli/stream/index.ts | 11 +- .../inbound/cli/stream/stream.test.ts | 12 + .../chain/tron/transaction-codec.test.ts | 94 +++++ .../outbound/chain/tron/transaction-codec.ts | 224 ++++++++++++ .../chain/tron/tron.permissions.test.ts | 108 ++++++ ts/src/adapters/outbound/chain/tron/tron.ts | 295 +++++++++++++++- ts/src/adapters/outbound/config/builtins.ts | 2 + .../application/contracts/execution-scope.ts | 4 +- .../application/ports/chain/tron-gateway.ts | 35 +- ts/src/application/services/pipeline/index.ts | 54 ++- .../services/pipeline/pipeline.test.ts | 43 ++- ts/src/application/services/target/index.ts | 3 + .../services/target/target.test.ts | 7 + .../services/transaction-mode.test.ts | 23 +- .../application/services/transaction-mode.ts | 50 ++- .../tron/multisig-authorization.test.ts | 99 ++++++ .../use-cases/tron/multisig-authorization.ts | 126 +++++++ .../use-cases/tron/permission-service.test.ts | 122 +++++++ .../use-cases/tron/permission-service.ts | 113 ++++++ ts/src/bootstrap/composition.ts | 1 + ts/src/bootstrap/families/tron.ts | 12 + ts/src/domain/permission/index.ts | 327 ++++++++++++++++++ ts/src/domain/permission/permission.test.ts | 141 ++++++++ ts/src/domain/types/index.ts | 1 + ts/src/domain/types/permission.ts | 31 ++ ts/src/domain/types/tx.ts | 28 +- 39 files changed, 2181 insertions(+), 44 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/artifact.ts create mode 100644 ts/src/adapters/inbound/cli/commands/permission.ts create mode 100644 ts/src/adapters/inbound/cli/render/permission.ts create mode 100644 ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/transaction-codec.ts create mode 100644 ts/src/adapters/outbound/chain/tron/tron.permissions.test.ts create mode 100644 ts/src/application/use-cases/tron/multisig-authorization.test.ts create mode 100644 ts/src/application/use-cases/tron/multisig-authorization.ts create mode 100644 ts/src/application/use-cases/tron/permission-service.test.ts create mode 100644 ts/src/application/use-cases/tron/permission-service.ts create mode 100644 ts/src/domain/permission/index.ts create mode 100644 ts/src/domain/permission/permission.test.ts create mode 100644 ts/src/domain/types/permission.ts diff --git a/ts/package-lock.json b/ts/package-lock.json index e88755b24..79864b8c5 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -20,7 +20,7 @@ "@scure/bip39": "^2.2.0", "axios": "^1.18.1", "lossless-json": "^4.3.0", - "tronweb": "^6.4.0", + "tronweb": "6.4.0", "yaml": "^2.9.0", "yargs": "^18.0.0", "zod": "^4.4.3" diff --git a/ts/package.json b/ts/package.json index b0452c38e..1511cde28 100644 --- a/ts/package.json +++ b/ts/package.json @@ -61,7 +61,7 @@ "@scure/bip39": "^2.2.0", "axios": "^1.18.1", "lossless-json": "^4.3.0", - "tronweb": "^6.4.0", + "tronweb": "6.4.0", "yaml": "^2.9.0", "yargs": "^18.0.0", "zod": "^4.4.3" diff --git a/ts/src/adapters/inbound/cli/commands/artifact.ts b/ts/src/adapters/inbound/cli/commands/artifact.ts new file mode 100644 index 000000000..689d87280 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/artifact.ts @@ -0,0 +1,24 @@ +import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs"; +import { UsageError } from "../../../../domain/errors/index.js"; + +export function readBoundedTextFile(path: string, maxBytes: number, label: string): string { + let fd: number | undefined; + try { + fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + const stat = fstatSync(fd); + if (!stat.isFile()) throw new UsageError("invalid_value", `${label} must be a regular file`); + if (stat.size > maxBytes) throw new UsageError("invalid_value", `${label} exceeds the ${maxBytes}-byte limit`); + return readFileSync(fd, "utf8"); + } catch (error) { + if (error instanceof UsageError) throw error; + throw new UsageError("invalid_value", `could not read ${label}: ${(error as Error).message}`); + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +export function exactlyOne(values: readonly unknown[], message: string): void { + if (values.filter((value) => value !== undefined && value !== false).length !== 1) { + throw new UsageError("invalid_option", message); + } +} diff --git a/ts/src/adapters/inbound/cli/commands/permission.ts b/ts/src/adapters/inbound/cli/commands/permission.ts new file mode 100644 index 000000000..01a2f2a7b --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/permission.ts @@ -0,0 +1,96 @@ +import { isLosslessNumber, parse as parseLosslessJson } from "lossless-json"; +import { z } from "zod"; +import type { TronPermissionService } from "../../../../application/use-cases/tron/permission-service.js"; +import { UsageError } from "../../../../domain/errors/index.js"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import { TextFormatters } from "../render/index.js"; +import { exactlyOne, readBoundedTextFile } from "./artifact.js"; + +const showFields = z.object({}); + +export const permissionShowSpec: ChainSpec = { + path: ["permission", "show"], + network: "optional", + wallet: "optional", + auth: "none", + capability: "permission.read", + summary: "Show owner, witness, and active permission groups", + description: "Show thresholds, authorized keys, and decoded operation bitmaps. --account may be a local account or any activated TRON address.", + baseFields: showFields, + examples: [ + { cmd: "wallet-cli permission show" }, + { cmd: "wallet-cli permission show --account T... -o json" }, + ], + formatText: TextFormatters.permissionShow, +}; + +const updateFields = z.object({ + file: z.string().min(1).optional().describe("complete replacement permission JSON file"), + json: z.string().min(1).optional().describe("inline complete replacement permission JSON"), + dryRun: z.boolean().default(false).describe("validate, build, and estimate without signing or broadcasting"), + signOnly: z.boolean().default(false).describe("build and sign, then output complete transaction hex"), + buildOnly: z.boolean().default(false).describe("build and output unsigned complete transaction hex without unlocking"), + permissionId: z.number().int().min(0).max(9).default(0).describe("TRON permission group id used to authorize this transaction"), + expiration: z.number().int().min(1).max(86_400_000).optional().describe("expiration duration in milliseconds; only with --sign-only or --build-only"), +}); + +export const permissionUpdateSpec: ChainSpec = { + path: ["permission", "update"], + network: "optional", + wallet: "optional", + auth: "required", + broadcasts: true, + capability: "permission.update", + summary: "Replace the complete account permission structure", + description: "Replaces owner/witness/active permissions in one AccountPermissionUpdateContract. Misconfiguration can permanently lock the account; use --dry-run first.", + baseFields: updateFields, + baseRefine: (input, context) => { + if ([input.file, input.json].filter((value) => value !== undefined).length !== 1) { + context.addIssue({ code: "custom", path: ["file"], message: "provide exactly one of --file or --json" }); + } + if ([input.dryRun, input.signOnly, input.buildOnly].filter(Boolean).length > 1) { + context.addIssue({ code: "custom", path: ["dryRun"], message: "choose at most one of --dry-run, --sign-only, --build-only" }); + } + if (input.expiration !== undefined && !input.signOnly && !input.buildOnly) { + context.addIssue({ code: "custom", path: ["expiration"], message: "--expiration is only valid with --sign-only or --build-only" }); + } + }, + examples: [ + { cmd: "wallet-cli permission update --file permissions.json --dry-run" }, + { cmd: "wallet-cli permission update --file permissions.json --wait --password-stdin" }, + ], + formatText: TextFormatters.permissionUpdate, +}; + +export const permissionShowTronBinding = (service: TronPermissionService): FamilyBinding => ({ + run: async (ctx, network) => service.show(network, ctx.resolveAddress("tron")), +}); + +export const permissionUpdateTronBinding = (service: TronPermissionService): FamilyBinding => ({ + run: async (ctx, network, input) => { + exactlyOne([input.file, input.json], "provide exactly one of --file or --json"); + const raw = input.file + ? readBoundedTextFile(input.file, 1024 * 1024, "permission JSON file") + : input.json; + let parsed: unknown; + try { + parsed = normalizeLossless(parseLosslessJson(raw)); + } catch { + throw new UsageError("invalid_permission", "permission structure must be valid JSON"); + } + return service.update(ctx, network, input, parsed); + }, +}); + +function normalizeLossless(value: unknown): unknown { + if (isLosslessNumber(value)) { + const exact = value.toString(); + const number = Number(exact); + return Number.isSafeInteger(number) ? number : exact; + } + if (Array.isArray(value)) return value.map(normalizeLossless); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, normalizeLossless(entry)])); + } + return value; +} diff --git a/ts/src/adapters/inbound/cli/context/context.test.ts b/ts/src/adapters/inbound/cli/context/context.test.ts index 7e97044cb..09da96467 100644 --- a/ts/src/adapters/inbound/cli/context/context.test.ts +++ b/ts/src/adapters/inbound/cli/context/context.test.ts @@ -4,12 +4,12 @@ import { StreamManager } from "../stream/index.js"; import { createOutputFormatter } from "../output/index.js"; import type { Globals } from "../contracts/index.js"; -function ctxWith(output: "text" | "json") { +function ctxWith(output: "text" | "json", overrides: Partial = {}) { const out: string[] = []; const err: string[] = []; const sm = new StreamManager(output, false, (s) => out.push(s), (s) => err.push(s)); const formatter = createOutputFormatter(output, sm, 0); - const globals = { output, verbose: false } as Globals; + const globals = { output, verbose: false, ...overrides } as Globals; // only streams + formatter are exercised by emit(); the rest is lazily used elsewhere. const deps = { config: { timeoutMs: 1 }, streams: sm, formatter } as unknown as RuntimeDeps; return { ctx: buildExecutionContext(globals, deps), out, err }; @@ -29,3 +29,12 @@ describe("ExecutionContext.emit (progress events)", () => { expect(err[0]).toContain("broadcasting"); }); }); + +describe("ExecutionContext direct address target", () => { + it("resolves a valid --account address without requiring a local wallet record", () => { + const address = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; + const { ctx } = ctxWith("json", { account: address }); + expect(ctx.activeAccount).toBe(address); + expect(ctx.resolveAddress("tron")).toBe(address); + }); +}); diff --git a/ts/src/adapters/inbound/cli/context/index.ts b/ts/src/adapters/inbound/cli/context/index.ts index bb49993a7..df9c77b9a 100644 --- a/ts/src/adapters/inbound/cli/context/index.ts +++ b/ts/src/adapters/inbound/cli/context/index.ts @@ -3,7 +3,7 @@ * account-level: activeAccount is resolved lazily from --account/--wallet or wallets.json. * Build is side-effect-free; secrets never enter the serializable surface. */ -import type { AccountRef, ChainFamily, Config, OutputMode } from "../../../../domain/types/index.js"; +import type { AccountRef, ChainFamily, Config, OutputMode, WarningView } from "../../../../domain/types/index.js"; import type { ProgressEvent } from "../../../../application/contracts/index.js"; import type { NetworkRegistry } from "../../../../application/ports/network-registry.js"; import type { ExecutionContext, Globals, SecretResolver, StreamManager } from "../contracts/index.js"; @@ -13,6 +13,7 @@ import type { AccountStore } from "../../../../application/ports/account-store.j import { accountRef, walletAddress } from "../../../../domain/wallet/index.js"; import { WalletError } from "../../../../domain/errors/index.js"; import { SOURCE_KINDS } from "../../../../domain/sources/index.js"; +import { addressCodec, familyOf } from "../../../../domain/family/index.js"; export interface RuntimeDeps { config: Config; @@ -65,6 +66,7 @@ class ExecutionContextImpl implements ExecutionContext { const ks = this.deps.keystore; let ref: AccountRef | null; if (this.globals.account) { + if (familyOf(this.globals.account)) return this.globals.account as AccountRef; const { wallet, index } = ks.resolveAccount(this.globals.account); ref = accountRef(wallet.id, SOURCE_KINDS[wallet.source.type].isHD ? index : null); } else { @@ -78,6 +80,9 @@ class ExecutionContextImpl implements ExecutionContext { } resolveAddress(family: ChainFamily): string { + if (this.globals.account && addressCodec(family).validate(this.globals.account)) { + return this.globals.account; + } const { wallet, index } = this.deps.keystore.resolveAccount(this.activeAccount); const address = walletAddress(wallet, family, index); if (!address) throw new WalletError("missing_wallet_address", `active account has no ${family} address`); @@ -88,7 +93,7 @@ class ExecutionContextImpl implements ExecutionContext { this.deps.streams.event(this.deps.formatter.event(e)); } - warn(message: string): void { + warn(message: string | WarningView): void { this.deps.streams.diagnostic("warn", message); } } diff --git a/ts/src/adapters/inbound/cli/contracts/envelope.ts b/ts/src/adapters/inbound/cli/contracts/envelope.ts index 99cef10ae..b2280a14c 100644 --- a/ts/src/adapters/inbound/cli/contracts/envelope.ts +++ b/ts/src/adapters/inbound/cli/contracts/envelope.ts @@ -4,6 +4,9 @@ */ import type { ChainFamily } from "../../../../domain/family/index.js"; import type { OutputMode } from "../../../../domain/types/primitives.js"; +import type { WarningView } from "../../../../domain/types/permission.js"; + +export type WarningItem = string | WarningView; export interface ChainView { family: ChainFamily; @@ -12,7 +15,7 @@ export interface ChainView { } export interface Meta { durationMs: number; - warnings: string[]; + warnings: WarningItem[]; } export interface ResultEnvelope { schema: "wallet-cli.result.v1"; diff --git a/ts/src/adapters/inbound/cli/contracts/runtime.ts b/ts/src/adapters/inbound/cli/contracts/runtime.ts index 06c6788de..659c23e58 100644 --- a/ts/src/adapters/inbound/cli/contracts/runtime.ts +++ b/ts/src/adapters/inbound/cli/contracts/runtime.ts @@ -1,18 +1,19 @@ /** CLI runtime seams implemented by stream and secret input adapters. */ import type { NetworkDescriptor } from "../../../../domain/types/network.js"; +import type { WarningItem } from "./envelope.js"; export type DiagnosticLevel = "info" | "debug" | "warn"; export interface StreamManager { result(text: string): void; - diagnostic(level: DiagnosticLevel, msg: string): void; + diagnostic(level: DiagnosticLevel, msg: WarningItem): void; /** always-on stderr line. */ errorLine(msg: string): void; /** intermediate progress frame → stderr plain line; null is skipped (StreamManager). */ event(frame: string | null): void; readStdinOnce(): string; /** warnings accumulated for the JSON envelope's meta.warnings. */ - warnings(): string[]; + warnings(): WarningItem[]; } export type SecretKind = "password" | "privateKey" | "mnemonic" | "tx" | "message"; diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index 067d21c9a..aa0cc49b9 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -105,6 +105,7 @@ export class HelpService { ["stake", "Stake / delegate resources & query state", "tron"], ["vote", "Vote for super representatives", "tron"], ["reward", "Query / withdraw voting rewards", "tron"], + ["permission", "View and update account multi-sign permissions", "tron"], ["chain", "Query chain params, prices & node info", ""], ["message", "Sign arbitrary messages", ""], ["typed-data", "Sign EIP-712 / TIP-712 structured data", ""], @@ -436,6 +437,7 @@ const GROUP_DESCRIPTIONS: Record = { stake: "Stake / delegate resources & query state (TRON Stake 2.0).", vote: "Vote for super representatives (SR).\nVoting accrues rewards — query and claim them with 'wallet-cli reward'.", reward: "Query and withdraw voting/block rewards.", + permission: "View and update account permissions (TRON multi-sign).\nMisconfiguring owner permission can permanently lock the account.", chain: "Query on-chain parameters, resource prices, and node status.", message: "Sign arbitrary messages.", "typed-data": "Sign EIP-712 / TIP-712 structured data.", diff --git a/ts/src/adapters/inbound/cli/output/envelope.ts b/ts/src/adapters/inbound/cli/output/envelope.ts index 14fce6023..e5419f27f 100644 --- a/ts/src/adapters/inbound/cli/output/envelope.ts +++ b/ts/src/adapters/inbound/cli/output/envelope.ts @@ -4,7 +4,7 @@ * Pure (no I/O); the formatter turns the envelope into strings. */ import type { NetworkDescriptor } from "../../../../domain/types/index.js"; -import type { ChainView, ErrorEnvelope, Meta, ResultEnvelope } from "../contracts/index.js"; +import type { ChainView, ErrorEnvelope, Meta, ResultEnvelope, WarningItem } from "../contracts/index.js"; type CliErrorEnvelopeShape = { code: string; message: string; details?: object }; @@ -27,7 +27,7 @@ function chainView(net: NetworkDescriptor): ChainView { }; } -function meta(durationMs: number, warnings: string[]): Meta { +function meta(durationMs: number, warnings: WarningItem[]): Meta { return { durationMs, warnings }; } @@ -36,7 +36,7 @@ export const OutputEnvelope = { command: string, net: NetworkDescriptor | undefined, data: unknown, - m: { durationMs: number; warnings: string[] }, + m: { durationMs: number; warnings: WarningItem[] }, ): ResultEnvelope { const env: ResultEnvelope = { schema: SCHEMA_VERSION, @@ -53,7 +53,7 @@ export const OutputEnvelope = { command: string, net: NetworkDescriptor | undefined, err: CliErrorEnvelopeShape, - m: { durationMs: number; warnings: string[] }, + m: { durationMs: number; warnings: WarningItem[] }, ): ErrorEnvelope { const env: ErrorEnvelope = { schema: SCHEMA_VERSION, diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index 041647baa..a1fc98db2 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -22,6 +22,7 @@ import { VoteFormatters } from "./vote.js" import { RewardFormatters } from "./reward.js" import { ChainFormatters } from "./chain.js" import { MiscFormatters } from "./misc.js" +import { PermissionFormatters } from "./permission.js" export { FAMILY_RENDER, renderFamily } from "./family.js" @@ -34,6 +35,7 @@ export const TextFormatters = { ...RewardFormatters, ...ChainFormatters, ...MiscFormatters, + ...PermissionFormatters, } export function renderGenericText(command: string, net: NetworkDescriptor | undefined, data: unknown): string { diff --git a/ts/src/adapters/inbound/cli/render/permission.ts b/ts/src/adapters/inbound/cli/render/permission.ts new file mode 100644 index 000000000..0105892de --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/permission.ts @@ -0,0 +1,81 @@ +import type { AccountPermissionsView, PermissionGroupView } from "../../../../domain/types/index.js"; +import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; +import { formatInt, formatSun } from "./scalars.js"; +import { fail, ok, pending, receipt, type Pair } from "./layout.js"; + +function operationLines(labels: string[]): string[] { + const lines: string[] = []; + let current = ""; + for (const label of labels) { + const next = current ? `${current} · ${label}` : label; + if (next.length > 76 && current) { + lines.push(current); + current = label; + } else { + current = next; + } + } + if (current) lines.push(current); + return lines; +} + +function permissionCard(permission: PermissionGroupView, active = false): string { + const activePermission = active ? permission as AccountPermissionsView["actives"][number] : undefined; + const lines = [ + `Permission Name ${permission.name} (id ${permission.id}${active ? ", active" : ""})`, + ]; + if (activePermission) { + const operations = [ + ...activePermission.operationLabels, + ...activePermission.unknownOperationIds.map((id) => `Unknown contract type ${id}`), + ]; + const wrapped = operationLines(operations); + lines.push(`Operation(s) ${wrapped[0] ?? ""}`); + for (const continuation of wrapped.slice(1)) lines.push(` ${continuation}`); + lines[lines.length - 1] += ` (${operations.length} total)`; + lines.push(`Operations Hex ${activePermission.operationsHex}`); + } + lines.push(`Threshold ${formatInt(permission.threshold)}`); + lines.push("Authorized To Address Weight"); + for (const key of permission.keys) { + lines.push(` ${key.address} ${String(key.weight).padStart(6)}${key.local ? ` (this wallet: ${key.local})` : ""}`); + } + return lines.join("\n"); +} + +function renderPermissions(value: AccountPermissionsView, ctx?: TextRenderContext): string { + const account = ctx?.accountLabel ? `${ctx.accountLabel} (${value.address})` : value.address; + return [ + `Account ${account}`, + "", + permissionCard(value.owner), + ...(value.witness ? ["", permissionCard(value.witness)] : []), + ...value.actives.flatMap((permission) => ["", permissionCard(permission, true)]), + ].join("\n"); +} + +function updateReceipt(value: any): string { + if ((value.mode === "build-only" || value.mode === "sign-only") && value.hex) return String(value.hex); + const pairs: Pair[] = []; + if (value.txId) pairs.push(["TxID", String(value.txId)]); + if (value.blockNumber !== undefined) pairs.push(["Block", `#${formatInt(value.blockNumber)}`]); + const feeSun = value.feeSun ?? value.fee?.feeSun; + if (feeSun !== undefined) pairs.push(["Fee", `${formatSun(feeSun)} TRX`]); + if (value.mode === "dry-run") pairs.push(["Status", "not submitted"]); + else if (value.stage === "submitted") pairs.push(["Status", "pending — not yet on-chain"]); + else if (value.stage === "failed") pairs.push(["Status", String(value.result ?? "failed")]); + else if (value.stage === "confirmed") pairs.push(["Status", "success"]); + const header = value.mode === "dry-run" + ? receipt(pending(), "Permission update dry run", pairs) + : value.stage === "failed" + ? receipt(fail(), "Permission update failed", pairs) + : value.stage === "confirmed" + ? receipt(ok(), "Permissions updated", pairs) + : receipt(pending(), "Permission update submitted", pairs); + return value.permissions ? `${header}\n\n${renderPermissions(value.permissions)}` : header; +} + +export const PermissionFormatters = { + permissionShow: ((value, ctx) => renderPermissions(value, ctx)) satisfies TextFormatter, + permissionUpdate: ((value) => updateReceipt(value)) satisfies TextFormatter, +}; diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index 54fb0ebc4..2ff30600a 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -38,6 +38,12 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { ["Tx", summarizeTx(r.tx)], ]) } + if (r.mode === "build-only") { + return r.hex ?? receipt(pending(), `Built ${actionLabel(r.kind)}`, [ + ["Fee", formatFee(r.fee, family)], + ["Tx", summarizeTx(r.tx)], + ]) + } if (r.mode === "sign-only") { // kv() drops empty rows, so a fee-less signature (tx sign estimates nothing) omits the Fee line. return receipt(ok(), `Signed ${actionLabel(r.kind)}`, [ @@ -135,6 +141,8 @@ function receiptSummary(r: TxReceiptView, family: ChainFamily): string { // switch total over TxReceiptKind. case "sign": return "Signed" + case "permission-update": + return "Permissions updated" } } @@ -198,6 +206,8 @@ function actionLabel(kind: TxReceiptKind): string { return "vote cast" case "reward-withdraw": return "reward withdraw" + case "permission-update": + return "permission update" } } diff --git a/ts/src/adapters/inbound/cli/stream/index.ts b/ts/src/adapters/inbound/cli/stream/index.ts index 0db40e728..bb4b1d639 100644 --- a/ts/src/adapters/inbound/cli/stream/index.ts +++ b/ts/src/adapters/inbound/cli/stream/index.ts @@ -9,13 +9,14 @@ import type { OutputMode } from "../../../../domain/types/index.js"; import type { DiagnosticLevel, StreamManager as IStreamManager, + WarningItem, } from "../contracts/index.js"; import { ExecutionError } from "../../../../domain/errors/index.js"; export class StreamManager implements IStreamManager { #resultWritten = false; #stdinRead = false; - #warnings: string[] = []; + #warnings: WarningItem[] = []; constructor( private readonly output: OutputMode, @@ -32,14 +33,14 @@ export class StreamManager implements IStreamManager { this.out(text.endsWith("\n") ? text : text + "\n"); } - diagnostic(level: DiagnosticLevel, msg: string): void { + diagnostic(level: DiagnosticLevel, msg: WarningItem): void { if (level === "warn") { this.#warnings.push(msg); - if (this.output === "text") this.err(`warning: ${msg}\n`); + if (this.output === "text") this.err(`warning: ${typeof msg === "string" ? msg : msg.message}\n`); return; } if (level === "debug" && !this.verbose) return; - this.err(`${msg}\n`); + this.err(`${typeof msg === "string" ? msg : msg.message}\n`); } errorLine(msg: string): void { @@ -56,7 +57,7 @@ export class StreamManager implements IStreamManager { this.err(frame.endsWith("\n") ? frame : frame + "\n"); } - warnings(): string[] { + warnings(): WarningItem[] { return this.#warnings; } diff --git a/ts/src/adapters/inbound/cli/stream/stream.test.ts b/ts/src/adapters/inbound/cli/stream/stream.test.ts index e46a00d36..cf46a5e64 100644 --- a/ts/src/adapters/inbound/cli/stream/stream.test.ts +++ b/ts/src/adapters/inbound/cli/stream/stream.test.ts @@ -48,6 +48,18 @@ describe("StreamManager", () => { expect(t.err).toEqual(["warning: careful\n"]); }); + it("preserves structured warning codes in JSON and prints only the message in text mode", () => { + const warning = { code: "owner_lockout", message: "local owner key is unavailable" }; + const j = capture("json"); + j.sm.diagnostic("warn", warning); + expect(j.sm.warnings()).toEqual([warning]); + expect(j.err).toEqual([]); + + const t = capture("text"); + t.sm.diagnostic("warn", warning); + expect(t.err).toEqual(["warning: local owner key is unavailable\n"]); + }); + it("event writes an intermediate frame as a plain line to stderr, never stdout", () => { const { sm, out, err } = capture("json"); sm.event('{"type":"signed"}'); diff --git a/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts b/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts new file mode 100644 index 000000000..c3d538620 --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + decodeTransactionHex, + encodeTransactionHex, + normalizeTransactionHex, +} from "./transaction-codec.js"; + +const OWNER = "411111111111111111111111111111111111111111"; +const OTHER = "412222222222222222222222222222222222222222"; +const OPERATIONS = "00".repeat(32); +const SIGNATURE_A = "ab".repeat(65); +const SIGNATURE_B = "cd".repeat(65); + +function fixture(type: string, value: Record) { + return { + visible: false, + raw_data: { + contract: [{ + parameter: { value, type_url: `type.googleapis.com/protocol.${type}` }, + type, + Permission_id: 2, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + expiration: 1_900_000_000_000, + timestamp: 1_899_999_000_000, + fee_limit: 10_000_000, + }, + signature: [SIGNATURE_A, SIGNATURE_B], + }; +} + +const CASES: Array<[string, Record]> = [ + ["TransferContract", { owner_address: OWNER, to_address: OTHER, amount: 123 }], + ["TransferAssetContract", { owner_address: OWNER, to_address: OTHER, asset_name: "31303030303031", amount: 456 }], + ["TriggerSmartContract", { owner_address: OWNER, contract_address: OTHER, data: "a9059cbb", call_value: 0 }], + ["FreezeBalanceV2Contract", { owner_address: OWNER, frozen_balance: 1_000_000, resource: "ENERGY" }], + ["UnfreezeBalanceV2Contract", { owner_address: OWNER, unfreeze_balance: 1_000_000, resource: "BANDWIDTH" }], + ["WithdrawExpireUnfreezeContract", { owner_address: OWNER }], + ["DelegateResourceContract", { owner_address: OWNER, receiver_address: OTHER, balance: 1_000_000, resource: "ENERGY", lock: true, lock_period: 86_400 }], + ["UnDelegateResourceContract", { owner_address: OWNER, receiver_address: OTHER, balance: 1_000_000, resource: "BANDWIDTH" }], + ["CancelAllUnfreezeV2Contract", { owner_address: OWNER }], + ["VoteWitnessContract", { owner_address: OWNER, votes: [{ vote_address: OTHER, vote_count: 7 }] }], + ["WithdrawBalanceContract", { owner_address: OWNER }], + ["CreateSmartContract", { + owner_address: OWNER, + new_contract: { + origin_address: OWNER, + abi: { entrys: [] }, + bytecode: "60006000", + call_value: 0, + consume_user_resource_percent: 100, + name: "Fixture", + origin_energy_limit: 10_000_000, + }, + }], + ["AccountPermissionUpdateContract", { + owner_address: OWNER, + owner: { type: 0, id: 0, permission_name: "owner", threshold: 2, keys: [{ address: OWNER, weight: 1 }, { address: OTHER, weight: 1 }] }, + actives: [{ type: 2, id: 2, permission_name: "active", threshold: 1, operations: OPERATIONS, keys: [{ address: OWNER, weight: 1 }] }], + }], +]; + +describe("TRON complete transaction hex codec", () => { + it.each(CASES)("round-trips %s byte-for-byte", (type, value) => { + const hex = encodeTransactionHex(fixture(type, value)); + const decoded = decodeTransactionHex(hex); + + expect(decoded.raw_data.contract).toHaveLength(1); + expect(decoded.raw_data.contract[0]?.type).toBe(type); + expect(decoded.raw_data.contract[0]?.Permission_id).toBe(2); + expect(decoded.raw_data.expiration).toBe(1_900_000_000_000); + expect(decoded.signature).toEqual([SIGNATURE_A, SIGNATURE_B]); + expect(decoded.txID).toMatch(/^[0-9a-f]{64}$/); + expect(encodeTransactionHex(decoded)).toBe(hex); + }); + + it("rejects malformed, oversized, and multi-contract inputs", () => { + expect(() => normalizeTransactionHex("0xabc")).toThrowError(/even length/); + expect(() => normalizeTransactionHex("zz")).toThrowError(/non-hex/); + expect(() => normalizeTransactionHex("00".repeat(512 * 1024 + 1))).toThrowError(/512 KiB/); + + const transaction = fixture("TransferContract", { owner_address: OWNER, to_address: OTHER, amount: 1 }); + transaction.raw_data.contract.push(transaction.raw_data.contract[0]!); + expect(() => encodeTransactionHex(transaction)).toThrowError(/exactly one contract/); + }); + + it("rejects forged identity fields and malformed signatures", () => { + const transaction = fixture("TransferContract", { owner_address: OWNER, to_address: OTHER, amount: 1 }); + expect(() => encodeTransactionHex({ ...transaction, txID: "00".repeat(32) })).toThrowError(/txID does not match/); + expect(() => encodeTransactionHex({ ...transaction, raw_data_hex: "00" })).toThrowError(/raw_data_hex does not match/); + expect(() => encodeTransactionHex({ ...transaction, signature: ["aa"] })).toThrowError(/65 bytes/); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/transaction-codec.ts b/ts/src/adapters/outbound/chain/tron/transaction-codec.ts new file mode 100644 index 000000000..161c526db --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/transaction-codec.ts @@ -0,0 +1,224 @@ +import { utils as tronUtils } from "tronweb"; +import { ChainError } from "../../../../domain/errors/index.js"; +import type { TronTransactionArtifact } from "../../../../domain/types/index.js"; + +const MAX_TRANSACTION_BYTES = 512 * 1024; +const SIGNATURE_BYTES = 65; + +interface ProtobufTransaction { + serializeBinary(): Uint8Array; + getRawData(): { + serializeBinary(): Uint8Array; + getContractList(): Array<{ getType(): number }>; + }; + getSignatureList_asU8(): Uint8Array[]; + addSignature(value: Uint8Array): unknown; +} + +interface TransactionProtoConstructor { + deserializeBinary(bytes: Uint8Array): ProtobufTransaction; +} + +const CONTRACT_TYPE_BY_ID: Readonly> = Object.freeze({ + 0: "AccountCreateContract", + 1: "TransferContract", + 2: "TransferAssetContract", + 4: "VoteWitnessContract", + 5: "WitnessCreateContract", + 6: "AssetIssueContract", + 8: "WitnessUpdateContract", + 9: "ParticipateAssetIssueContract", + 10: "AccountUpdateContract", + 11: "FreezeBalanceContract", + 12: "UnfreezeBalanceContract", + 13: "WithdrawBalanceContract", + 14: "UnfreezeAssetContract", + 15: "UpdateAssetContract", + 16: "ProposalCreateContract", + 17: "ProposalApproveContract", + 18: "ProposalDeleteContract", + 19: "SetAccountIdContract", + 30: "CreateSmartContract", + 31: "TriggerSmartContract", + 33: "UpdateSettingContract", + 41: "ExchangeCreateContract", + 42: "ExchangeInjectContract", + 43: "ExchangeWithdrawContract", + 44: "ExchangeTransactionContract", + 45: "UpdateEnergyLimitContract", + 46: "AccountPermissionUpdateContract", + 48: "ClearABIContract", + 49: "UpdateBrokerageContract", + 52: "MarketSellAssetContract", + 53: "MarketCancelOrderContract", + 54: "FreezeBalanceV2Contract", + 55: "UnfreezeBalanceV2Contract", + 56: "WithdrawExpireUnfreezeContract", + 57: "DelegateResourceContract", + 58: "UnDelegateResourceContract", + 59: "CancelAllUnfreezeV2Contract", +}); + +function invalidTransaction(message: string): never { + throw new ChainError("invalid_transaction", message); +} + +function normalizeHash(value: unknown, field: string): string { + if (typeof value !== "string" || !/^(?:0x)?[0-9a-fA-F]{64}$/.test(value)) { + return invalidTransaction(`${field} must be a 32-byte hex value`); + } + return value.replace(/^0x/i, "").toLowerCase(); +} + +function transactionProto(): TransactionProtoConstructor { + const proto = (globalThis as typeof globalThis & { + TronWebProto?: { Transaction?: TransactionProtoConstructor }; + }).TronWebProto?.Transaction; + if (!proto) { + throw new ChainError( + "provider_error", + "the installed TronWeb version does not expose the transaction protobuf codec", + ); + } + return proto; +} + +/** Bound and normalize untrusted protocol.Transaction hex before allocation or parsing. */ +export function normalizeTransactionHex(input: string): string { + if (typeof input !== "string") return invalidTransaction("transaction hex must be text"); + const normalized = input.trim().replace(/^0x/i, ""); + if (normalized.length === 0) return invalidTransaction("transaction hex is empty"); + if (normalized.length % 2 !== 0) return invalidTransaction("transaction hex must have an even length"); + if (!/^[0-9a-fA-F]+$/.test(normalized)) { + return invalidTransaction("transaction hex contains non-hex characters"); + } + if (normalized.length / 2 > MAX_TRANSACTION_BYTES) { + return invalidTransaction("transaction hex exceeds the 512 KiB limit"); + } + return normalized.toLowerCase(); +} + +function appendSignatures(pb: ProtobufTransaction, signatures: unknown): void { + if (signatures === undefined) return; + if (!Array.isArray(signatures)) return invalidTransaction("transaction signatures must be an array"); + for (const signature of signatures) { + if (typeof signature !== "string" || !/^(?:0x)?[0-9a-fA-F]+$/.test(signature)) { + return invalidTransaction("transaction signature must be hex"); + } + const normalized = signature.replace(/^0x/i, ""); + if (normalized.length !== SIGNATURE_BYTES * 2) { + return invalidTransaction("transaction signature must be exactly 65 bytes"); + } + pb.addSignature(Uint8Array.from(Buffer.from(normalized, "hex"))); + } +} + +/** Encode JSON into complete protocol.Transaction bytes, preserving existing signatures. */ +export function encodeTransactionHex(transaction: unknown): string { + if (!transaction || typeof transaction !== "object") { + return invalidTransaction("transaction must be an object"); + } + const candidate = transaction as Partial; + if (!candidate.raw_data || !Array.isArray(candidate.raw_data.contract) || candidate.raw_data.contract.length !== 1) { + return invalidTransaction("exactly one contract is required per transaction"); + } + let pb: ProtobufTransaction; + try { + pb = tronUtils.transaction.txJsonToPb(candidate) as ProtobufTransaction; + } catch { + return invalidTransaction("transaction JSON cannot be encoded as TRON protobuf"); + } + + const computedRawDataHex = tronUtils.transaction.txPbToRawDataHex(pb).toLowerCase(); + const computedTxId = tronUtils.transaction.txPbToTxID(pb).replace(/^0x/i, "").toLowerCase(); + if (candidate.raw_data_hex !== undefined) { + const suppliedRawDataHex = normalizeTransactionHex(candidate.raw_data_hex); + if (suppliedRawDataHex !== computedRawDataHex) { + return invalidTransaction("raw_data_hex does not match raw_data"); + } + } + if (candidate.txID !== undefined && normalizeHash(candidate.txID, "txID") !== computedTxId) { + return invalidTransaction("txID does not match raw_data"); + } + + appendSignatures(pb, candidate.signature); + const encoded = Buffer.from(pb.serializeBinary()).toString("hex").toLowerCase(); + if (encoded.length / 2 > MAX_TRANSACTION_BYTES) { + return invalidTransaction("encoded transaction exceeds the 512 KiB limit"); + } + return encoded; +} + +/** + * Decode complete protocol.Transaction bytes without accepting lossy reconstruction. + * Unknown protobuf fields, multiple contracts, and unsupported contract types fail closed. + */ +export function decodeTransactionHex(input: string): TronTransactionArtifact { + const normalized = normalizeTransactionHex(input); + let pb: ProtobufTransaction; + try { + pb = transactionProto().deserializeBinary(Uint8Array.from(Buffer.from(normalized, "hex"))); + } catch { + return invalidTransaction("transaction protobuf cannot be decoded"); + } + + const raw = pb.getRawData(); + const contracts = raw?.getContractList?.() ?? []; + if (contracts.length !== 1) { + return invalidTransaction("exactly one contract is required per transaction"); + } + const contractType = CONTRACT_TYPE_BY_ID[contracts[0]!.getType()]; + if (!contractType) { + return invalidTransaction(`unsupported TRON contract type id ${contracts[0]!.getType()}`); + } + + const rawDataHex = Buffer.from(raw.serializeBinary()).toString("hex").toLowerCase(); + let rawData: TronTransactionArtifact["raw_data"]; + try { + rawData = tronUtils.deserializeTx.deserializeTransaction(contractType, rawDataHex) as TronTransactionArtifact["raw_data"]; + } catch { + return invalidTransaction(`TRON contract type ${contractType} cannot be decoded losslessly`); + } + const txID = tronUtils.transaction.txPbToTxID(pb).replace(/^0x/i, "").toLowerCase(); + const signature = pb.getSignatureList_asU8().map((value) => Buffer.from(value).toString("hex").toLowerCase()); + if (signature.some((value) => value.length !== SIGNATURE_BYTES * 2)) { + return invalidTransaction("transaction contains a malformed signature"); + } + + const transaction: TronTransactionArtifact = { + visible: false, + txID, + raw_data: rawData, + raw_data_hex: rawDataHex, + ...(signature.length > 0 ? { signature } : {}), + }; + if (encodeTransactionHex(transaction) !== normalized) { + return invalidTransaction("transaction cannot be represented losslessly by this client"); + } + return transaction; +} + +/** Recompute txID and raw_data_hex after a deliberate unsigned raw_data mutation. */ +export function refreshTransactionIdentity(transaction: unknown): TronTransactionArtifact { + if (!transaction || typeof transaction !== "object") { + return invalidTransaction("transaction must be an object"); + } + const candidate = transaction as Partial; + if (Array.isArray(candidate.signature) && candidate.signature.length > 0) { + return invalidTransaction("a signed transaction cannot be mutated"); + } + let pb: ProtobufTransaction; + try { + pb = tronUtils.transaction.txJsonToPb(candidate) as ProtobufTransaction; + } catch { + return invalidTransaction("transaction JSON cannot be encoded as TRON protobuf"); + } + const refreshed: TronTransactionArtifact = { + ...(candidate as TronTransactionArtifact), + visible: false, + txID: tronUtils.transaction.txPbToTxID(pb).replace(/^0x/i, "").toLowerCase(), + raw_data_hex: tronUtils.transaction.txPbToRawDataHex(pb).toLowerCase(), + }; + decodeTransactionHex(encodeTransactionHex(refreshed)); + return refreshed; +} diff --git a/ts/src/adapters/outbound/chain/tron/tron.permissions.test.ts b/ts/src/adapters/outbound/chain/tron/tron.permissions.test.ts new file mode 100644 index 000000000..922ab0e33 --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/tron.permissions.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TronRpcClient } from "./tron.js"; +import { decodeTransactionHex, encodeTransactionHex } from "./transaction-codec.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const A_HEX = "4174472e7d35395a6b5add427eecb7f4b62ad2b071"; +const B_HEX = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("TronRpcClient account permission boundary", () => { + it("binds permission and expiration before signing, then recomputes tx identity", () => { + const original = decodeTransactionHex(encodeTransactionHex({ + visible: false, + raw_data: { + contract: [{ + type: "TransferContract", + parameter: { + type_url: "type.googleapis.com/protocol.TransferContract", + value: { owner_address: A_HEX, to_address: B_HEX, amount: 1 }, + }, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: 1_900_000_000_000, + expiration: 1_900_000_060_000, + }, + })); + const prepared = new TronRpcClient("https://node.invalid", 100).prepareTransaction(original, { + permissionId: 2, + expiration: 86_400_000, + }); + expect(prepared.raw_data.contract[0]?.Permission_id).toBe(2); + expect(prepared.raw_data.expiration).toBe(1_900_086_400_000); + expect(prepared.txID).not.toBe(original.txID); + expect(original.raw_data.contract[0]?.Permission_id).toBe(0); + expect(original.raw_data.expiration).toBe(1_900_000_060_000); + expect(decodeTransactionHex(encodeTransactionHex(prepared)).txID).toBe(prepared.txID); + }); + + it("normalizes node addresses and decodes active operation bitmaps", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + address: A_HEX, + owner_permission: { + id: 0, + permission_name: "owner", + threshold: 2, + keys: [{ address: A_HEX, weight: 1 }, { address: B_HEX, weight: 1 }], + }, + active_permission: [{ + id: 2, + permission_name: "finance", + threshold: 1, + operations: "0600008000000000000000000000000000000000000000000000000000000000", + keys: [{ address: A_HEX, weight: 1 }], + }], + }), { status: 200 }))); + + const permissions = await new TronRpcClient("https://node.invalid", 100).getAccountPermissions(A); + expect(permissions).toMatchObject({ + address: A, + owner: { + id: 0, + threshold: 2, + keys: [{ address: A, weight: 1, local: null }, { address: B, weight: 1, local: null }], + }, + witness: null, + actives: [{ + id: 2, + name: "finance", + operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], + }], + }); + }); + + it("maps an empty account response to not_found", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 }))); + await expect(new TronRpcClient("https://node.invalid", 100).getAccountPermissions(A)) + .rejects.toMatchObject({ code: "not_found" }); + }); + + it("materializes protocol default owner/active groups when explicit permissions are absent", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ address: A_HEX }), { status: 200 }))); + const permissions = await new TronRpcClient("https://node.invalid", 100).getAccountPermissions(A); + expect(permissions).toMatchObject({ + owner: { id: 0, threshold: 1, keys: [{ address: A, weight: 1 }] }, + actives: [{ id: 2, name: "active", threshold: 1 }], + }); + expect(permissions.actives[0]?.operations).toContain("TransferContract"); + expect(permissions.actives[0]?.operationsHex).toBe("7fff1fc0033ef30f" + "00".repeat(24)); + }); + + it("fails closed on impossible node permission state", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + address: A_HEX, + owner_permission: { + id: 0, + permission_name: "owner", + threshold: 2, + keys: [{ address: A_HEX, weight: 1 }], + }, + active_permission: [], + }), { status: 200 }))); + await expect(new TronRpcClient("https://node.invalid", 100).getAccountPermissions(A)) + .rejects.toMatchObject({ code: "provider_error" }); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 079dc4037..3641cf8e1 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -6,7 +6,15 @@ import { TronWeb } from "tronweb"; import type { Types } from "tronweb"; import { isLosslessNumber, parse as parseLosslessJson } from "lossless-json"; -import type { BroadcastResult, SignedTx } from "../../../../domain/types/index.js"; +import type { + AccountPermissionsView, + ActivePermissionView, + BroadcastResult, + PermissionGroupView, + SignedTx, + TronTransactionArtifact, + UnsignedTx, +} from "../../../../domain/types/index.js"; import type { RpcResourceCode } from "../../../../domain/resources/index.js"; import type { Broadcaster } from "../../../../application/ports/chain/broadcaster.js"; import type { @@ -17,6 +25,7 @@ import type { TronDelegatedResource, TronGateway, TronNodeInfo, + TronSignWeight, TronTokenInfo, TronTx, TronTxInfo, @@ -31,9 +40,18 @@ import { parseTronTx, parseTronTxInfo } from "./tron-responses.js"; import { assertBuiltTx } from "./tx-guard.js"; import { decodeTronTransaction } from "./transaction-decoder.js"; import { isDeployedContract, normalizeContractResponses } from "./contract-response.js"; +import { + decodeTransactionHex, + encodeTransactionHex, + normalizeTransactionHex, + refreshTransactionIdentity, +} from "./transaction-codec.js"; +import { decodeOperations } from "../../../../domain/permission/index.js"; +import { addressCodec } from "../../../../domain/family/index.js"; /** a valid base58 owner used as the caller for read-only (constant) contract calls. */ const TRON_READ_OWNER = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; +const DEFAULT_ACTIVE_OPERATIONS = "7fff1fc0033ef30f000000000000000000000000000000000000000000000000"; /** 41-prefixed hex TRON address → base58; passes through values that are already base58/empty. * TronGrid's native /transactions endpoint returns hex even with visible=true. */ @@ -80,6 +98,187 @@ export class TronRpcClient implements TronGateway, Broadcaster { return { txId: res.txid ?? res.transaction?.txID }; } + prepareTransaction( + transaction: UnsignedTx, + options: { permissionId: number; expiration?: number }, + ): TronTransactionArtifact { + if (!transaction || typeof transaction !== "object") { + throw new ChainError("invalid_transaction", "TRON builder returned a non-object transaction"); + } + const prepared = structuredClone(transaction) as TronTransactionArtifact; + if (Array.isArray(prepared.signature) && prepared.signature.length > 0) { + throw new ChainError("invalid_transaction", "cannot prepare a transaction that is already signed"); + } + const contracts = prepared.raw_data?.contract; + if (!Array.isArray(contracts) || contracts.length !== 1) { + throw new ChainError("invalid_transaction", "exactly one TRON contract is required"); + } + if (options.permissionId === 0) delete contracts[0]!.Permission_id; + else contracts[0]!.Permission_id = options.permissionId; + if (options.expiration !== undefined) { + const timestamp = prepared.raw_data.timestamp; + if (!Number.isSafeInteger(timestamp)) { + throw new ChainError("invalid_transaction", "TRON transaction timestamp is missing or imprecise"); + } + const expiration = timestamp! + options.expiration; + if (!Number.isSafeInteger(expiration)) { + throw new ChainError("invalid_transaction", "TRON transaction expiration is imprecise"); + } + prepared.raw_data.expiration = expiration; + } + return refreshTransactionIdentity(prepared); + } + + encodeTransactionHex(transaction: UnsignedTx): string { + return encodeTransactionHex(transaction); + } + + decodeTransactionHex(hex: string): TronTransactionArtifact { + return decodeTransactionHex(hex); + } + + async getAccountPermissions(address: string): Promise { + const account = await this.getAccount(address); + const returnedAddress = hexToBase58(account.address); + if (!returnedAddress) { + throw new ChainError("not_found", `TRON account is not activated: ${address}`); + } + if (returnedAddress !== address) { + throw new ChainError("provider_error", "TRON node returned permissions for a different account"); + } + const ownerRaw = account.owner_permission; + const activeRaw = account.active_permission; + if (!ownerRaw && activeRaw === undefined) { + const decoded = decodeOperations(DEFAULT_ACTIVE_OPERATIONS); + const defaultGroup = (id: number, name: string): PermissionGroupView => ({ + id, + name, + threshold: 1, + keys: [{ address, weight: 1, local: null }], + }); + const isWitness = account.is_witness === true || account.isWitness === true; + return { + address, + owner: defaultGroup(0, "owner"), + witness: isWitness ? defaultGroup(1, "witness") : null, + actives: [{ + ...defaultGroup(2, "active"), + operations: decoded.operations, + operationLabels: decoded.labels, + operationsHex: decoded.operationsHex, + unknownOperationIds: decoded.unknownOperationIds, + }], + }; + } + if (!ownerRaw) { + throw new ChainError("provider_error", "TRON node response is missing owner_permission"); + } + if (activeRaw !== undefined && !Array.isArray(activeRaw)) { + throw new ChainError("provider_error", "TRON node returned malformed active_permission data"); + } + if ((activeRaw?.length ?? 0) > 8) { + throw new ChainError("provider_error", "TRON node returned more than 8 active permissions"); + } + const owner = permissionGroupFromNode(ownerRaw, "owner", 0, { expectedId: 0 }); + const witness = account.witness_permission + ? permissionGroupFromNode(account.witness_permission, "witness", 1, { expectedId: 1, exactKeys: 1 }) + : null; + const actives = (activeRaw ?? []).map((permission, index) => activePermissionFromNode(permission, index)); + if (new Set(actives.map((permission) => permission.id)).size !== actives.length) { + throw new ChainError("provider_error", "TRON node returned duplicate active permission ids"); + } + return { address, owner, witness, actives }; + } + + async buildAccountPermissionUpdate(owner: string, permissions: AccountPermissionsView): Promise { + const toPermission = (permission: PermissionGroupView, type: number): Types.Permission => ({ + type, + id: permission.id, + permission_name: permission.name, + threshold: permission.threshold, + keys: permission.keys.map((key) => ({ address: key.address, weight: key.weight })), + }); + const ownerPermission = toPermission(permissions.owner, 0); + const witnessPermission = permissions.witness ? toPermission(permissions.witness, 1) : null; + const actives = permissions.actives.map((permission): Types.Permission => ({ + ...toPermission(permission, 2), + operations: permission.operationsHex, + })); + return this.#wrap("update account permissions", async () => + assertBuiltTx( + await this.#tw.transactionBuilder.updateAccountPermissions( + owner, + ownerPermission, + witnessPermission, + actives, + ), + "AccountPermissionUpdateContract", + ), + ); + } + + async getSignWeight(transaction: UnsignedTx): Promise { + return this.#wrap("getSignWeight", async () => { + // TronWeb mutates Permission_id when absent; never expose the caller's artifact by reference. + const response = await this.#tw.trx.getSignWeight(structuredClone(transaction) as Types.Transaction); + const permission = response.permission + ? permissionGroupFromNode(response.permission, "permission", Number(response.permission.id ?? 0)) + : null; + return { + permission: permission ? { + id: permission.id, + name: permission.name, + threshold: permission.threshold, + operationsHex: typeof response.permission.operations === "string" + ? response.permission.operations.toLowerCase() + : undefined, + keys: permission.keys.map(({ address: keyAddress, weight }) => ({ address: keyAddress, weight })), + } : null, + approvedList: (response.approved_list ?? []).map(hexToBase58), + currentWeight: safeNodeInteger(response.current_weight, "current_weight"), + resultCode: String(response.result?.code ?? ""), + message: decodeTronMessage(response.result?.message), + }; + }); + } + + async getApprovedList(transaction: UnsignedTx): Promise { + return this.#wrap("getApprovedList", async () => { + const response = await this.#tw.trx.getApprovedList(structuredClone(transaction) as Types.Transaction); + return (response.approved_list ?? []).map(hexToBase58); + }); + } + + async broadcastHex(input: string): Promise { + const hex = normalizeTransactionHex(input); + decodeTransactionHex(hex); + const response = await this.#wrap("broadcast hex", () => this.#tw.trx.sendHexTransaction(hex)); + if (response.result === false) { + const reason = decodeTronMessage(response.message) || response.code || "rejected by node"; + throw new ChainError("transaction_rejected", `TRON broadcast rejected: ${redactErrorMessage(String(reason))}`); + } + const returnedTxId = response.transaction && typeof response.transaction === "object" + ? response.transaction.txID + : undefined; + return { txId: response.txid ?? returnedTxId }; + } + + async getUpdateAccountPermissionFee(): Promise { + return this.#chainParameter("getUpdateAccountPermissionFee"); + } + + async getMultiSignFee(): Promise { + return this.#chainParameter("getMultiSignFee"); + } + + async #chainParameter(key: string): Promise { + const parameter = (await this.getChainParameters()).find((entry) => entry.key === key); + if (parameter?.value === undefined) { + throw new ChainError("provider_error", `TRON chain parameter is unavailable: ${key}`); + } + return safeNodeInteger(parameter.value, key); + } + /** build an unsigned TRX transfer (tronweb fills ref block etc.). */ async buildNativeTransfer(from: string, to: string, amountSun: string): Promise { // tronweb's sendTrx amount param is a JS number; guard before #wrap so a precision-losing @@ -100,6 +299,7 @@ export class TronRpcClient implements TronGateway, Broadcaster { try { return await fn(); } catch (e) { + if (e instanceof ChainError || e instanceof UsageError || e instanceof TransportError) throw e; throw new TransportError("rpc_error", `TRON ${label} failed: ${redactErrorMessage((e as Error).message?.split("\n")[0] ?? "")}`); } } @@ -522,6 +722,99 @@ export function parseTronAccountResponse(text: string): TronAccount { return normalizeAccountValue(parseLosslessJson(text)) as TronAccount; } +function safeNodeInteger(value: unknown, field: string): number { + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return value; + if (typeof value === "string" && /^\d+$/.test(value)) { + const parsed = Number(value); + if (Number.isSafeInteger(parsed)) return parsed; + } + throw new ChainError("provider_error", `TRON node returned an imprecise or invalid ${field}`); +} + +function safePermissionName(value: unknown, fallback: string, field: string): string { + const name = value === undefined ? fallback : value; + if (typeof name !== "string" + || name.length === 0 + || Buffer.byteLength(name, "utf8") > 32 + || /\p{Cc}/u.test(name)) { + throw new ChainError("provider_error", `TRON node returned an invalid ${field}`); + } + return name; +} + +function permissionGroupFromNode( + value: unknown, + kind: string, + fallbackId: number, + constraints: { expectedId?: number; exactKeys?: number } = {}, +): PermissionGroupView { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ChainError("provider_error", `TRON node returned malformed ${kind} permission data`); + } + const raw = value as Record; + if (!Array.isArray(raw.keys) + || raw.keys.length === 0 + || raw.keys.length > 5 + || (constraints.exactKeys !== undefined && raw.keys.length !== constraints.exactKeys)) { + throw new ChainError("provider_error", `TRON node returned an invalid ${kind} key count`); + } + const threshold = safeNodeInteger(raw.threshold, `${kind}.threshold`); + if (threshold === 0) throw new ChainError("provider_error", `TRON node returned zero ${kind} threshold`); + const seen = new Set(); + let totalWeight = 0n; + const keys = raw.keys.map((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new ChainError("provider_error", `TRON node returned malformed ${kind}.keys[${index}]`); + } + const key = entry as Record; + const address = hexToBase58(key.address); + const weight = safeNodeInteger(key.weight, `${kind}.keys[${index}].weight`); + if (!addressCodec("tron").validate(address) || weight === 0 || seen.has(address)) { + throw new ChainError("provider_error", `TRON node returned invalid ${kind}.keys[${index}]`); + } + seen.add(address); + totalWeight += BigInt(weight); + return { address, weight, local: null }; + }); + if (BigInt(threshold) > totalWeight) { + throw new ChainError("provider_error", `TRON node returned ${kind} threshold above total key weight`); + } + const id = raw.id === undefined ? fallbackId : safeNodeInteger(raw.id, `${kind}.id`); + if (constraints.expectedId !== undefined && id !== constraints.expectedId) { + throw new ChainError("provider_error", `TRON node returned invalid ${kind} permission id`); + } + return { + id, + name: safePermissionName(raw.permission_name, kind, `${kind}.permission_name`), + threshold, + keys, + }; +} + +function activePermissionFromNode(value: unknown, index: number): ActivePermissionView { + const group = permissionGroupFromNode(value, `active[${index}]`, index + 2); + if (group.id < 2 || group.id > 9) { + throw new ChainError("provider_error", `TRON node returned invalid active[${index}] permission id`); + } + const raw = value as Record; + if (typeof raw.operations !== "string") { + throw new ChainError("provider_error", `TRON node returned active[${index}] without operations`); + } + let operations: ReturnType; + try { + operations = decodeOperations(raw.operations); + } catch { + throw new ChainError("provider_error", `TRON node returned malformed active[${index}] operations`); + } + return { + ...group, + operations: operations.operations, + operationLabels: operations.labels, + operationsHex: operations.operationsHex, + unknownOperationIds: operations.unknownOperationIds, + }; +} + function normalizeAccountValue(value: unknown, key?: string): unknown { if (isLosslessNumber(value)) { const exact = value.toString(); diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index 47f59f791..0825fc335 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -31,6 +31,8 @@ export const CAP_SUMMARIES: Record = { "vote.status": "current SR votes and voting power", "reward.balance": "claimable voting/block reward", "reward.withdraw": "withdraw voting/block rewards", + "permission.read": "read account multi-sign permissions", + "permission.update": "replace account multi-sign permissions", } export const BUILTIN_NETWORKS: Record = { diff --git a/ts/src/application/contracts/execution-scope.ts b/ts/src/application/contracts/execution-scope.ts index d497f0090..2b352998c 100644 --- a/ts/src/application/contracts/execution-scope.ts +++ b/ts/src/application/contracts/execution-scope.ts @@ -1,4 +1,4 @@ -import type { AccountRef, ChainFamily } from "../../domain/types/index.js"; +import type { AccountRef, ChainFamily, WarningView } from "../../domain/types/index.js"; import type { ProgressEvent } from "./progress.js"; export interface AccountScope { @@ -13,5 +13,5 @@ export interface TransactionScope extends AccountScope { emit(event: ProgressEvent): void; /** surface a non-fatal warning: captured into the JSON envelope's meta.warnings and, in text * mode, printed to stderr. Use when an outcome silently degrades (e.g. --wait not confirmed). */ - warn(message: string): void; + warn(message: string | WarningView): void; } diff --git a/ts/src/application/ports/chain/tron-gateway.ts b/ts/src/application/ports/chain/tron-gateway.ts index 6ac8bb196..4b851dfc3 100644 --- a/ts/src/application/ports/chain/tron-gateway.ts +++ b/ts/src/application/ports/chain/tron-gateway.ts @@ -1,4 +1,10 @@ -import type { FeeReport, UnsignedTx } from "../../../domain/types/index.js"; +import type { + AccountPermissionsView, + BroadcastResult, + FeeReport, + TronTransactionArtifact, + UnsignedTx, +} from "../../../domain/types/index.js"; import type { RpcResourceCode } from "../../../domain/resources/index.js"; import type { Broadcaster } from "./broadcaster.js"; @@ -140,8 +146,35 @@ export interface TronNodeInfo { [key: string]: unknown; } +export interface TronSignWeight { + permission: { + id: number; + name: string; + threshold: number; + operationsHex?: string; + keys: Array<{ address: string; weight: number }>; + } | null; + approvedList: string[]; + currentWeight: number; + resultCode: string; + message?: string; +} + /** TRON-specific application boundary; chain-specific capabilities remain explicit. */ export interface TronGateway extends Broadcaster { + prepareTransaction( + transaction: UnsignedTx, + options: { permissionId: number; expiration?: number }, + ): UnsignedTx; + encodeTransactionHex(transaction: UnsignedTx): string; + decodeTransactionHex(hex: string): TronTransactionArtifact; + getAccountPermissions(address: string): Promise; + buildAccountPermissionUpdate(owner: string, permissions: AccountPermissionsView): Promise; + getSignWeight(transaction: UnsignedTx): Promise; + getApprovedList(transaction: UnsignedTx): Promise; + broadcastHex(hex: string): Promise; + getUpdateAccountPermissionFee(): Promise; + getMultiSignFee(): Promise; getNativeBalance(address: string): Promise; getAccount(address: string): Promise; getAccountResources(address: string): Promise; diff --git a/ts/src/application/services/pipeline/index.ts b/ts/src/application/services/pipeline/index.ts index e54d82f48..ff088edf2 100644 --- a/ts/src/application/services/pipeline/index.ts +++ b/ts/src/application/services/pipeline/index.ts @@ -10,6 +10,7 @@ import { SignerResolver } from "../signer/index.js"; import { UsageError } from "../../../domain/errors/index.js"; import { obtainSignature } from "../signing/obtain-signature.js"; import type { Broadcaster } from "../../ports/chain/broadcaster.js"; +import type { TransactionExecutionMode } from "../transaction-mode.js"; export interface TxPipelineParams { ctx: TransactionScope; @@ -20,7 +21,18 @@ export interface TxPipelineParams { build: (signerAddress: string) => Promise; estimate: (tx: UnsignedTx) => Promise; dryRun: boolean; + buildOnly?: boolean; broadcast: boolean; + mode?: TransactionExecutionMode; + permissionId?: number; + expiration?: number; + signerOptions?: { requireSoftware?: boolean }; + /** Bind permission/expiration and recompute transaction identity before signing. */ + prepare?: (tx: UnsignedTx, options: { permissionId: number; expiration?: number }) => Promise | UnsignedTx; + /** Complete transaction protobuf serializer, required for build-only. */ + artifact?: (tx: UnsignedTx | SignedTx) => string; + /** Authorization check performed before software key decryption or Ledger interaction. */ + preflight?: (tx: UnsignedTx, signerAddress: string) => Promise; /** Optional post-broadcast confirmation: poll the chain for on-chain results (fee/energy/ * withdrawn amount) and merge them into the broadcast outcome. Best-effort — it must never * throw; on timeout it returns undefined and the receipt falls back to txid + echoed inputs. */ @@ -54,22 +66,50 @@ export class TxPipeline { } async run(p: TxPipelineParams): Promise { - // --wait only makes sense when we actually broadcast (dry-run/sign-only never reach the chain). - if (p.ctx.wait && !p.broadcast) { - throw new UsageError("invalid_option", "--wait has nothing to wait for with --dry-run/--sign-only (neither broadcasts)"); + const mode: TransactionExecutionMode = p.mode + ?? (p.dryRun ? "dry-run" : p.buildOnly ? "build-only" : p.broadcast ? "broadcast" : "sign-only"); + if (p.ctx.wait && mode !== "broadcast") { + throw new UsageError("invalid_option", "--wait cannot be used with --dry-run, --sign-only, or --build-only"); } - const signer = this.signers.resolve(p.account, p.net.family); // RPC steps (build/estimate/broadcast) are bounded by the adapter's own --timeout, so they // aren't wrapped here. The one thing no RPC timeout covers is a Ledger tap that never comes; // obtainSignature bounds the device signature and aborts its prompt on timeout. - const tx = await p.build(signer.address); + const ownerAddress = p.ctx.resolveAddress(p.net.family); + let tx = await p.build(ownerAddress); + if (p.prepare) { + tx = await p.prepare(tx, { + permissionId: p.permissionId ?? 0, + ...(p.expiration === undefined ? {} : { expiration: p.expiration }), + }); + } else if ((p.permissionId ?? 0) !== 0 || p.expiration !== undefined) { + throw new UsageError("invalid_option", "this chain adapter cannot apply --permission-id or --expiration"); + } const fee = await p.estimate(tx); - if (p.dryRun) return { stage: "plan", tx, fee }; + if (mode === "dry-run") return { stage: "plan", tx, fee }; + if (mode === "build-only") { + if (!p.artifact) throw new UsageError("invalid_option", "this chain adapter cannot produce transaction hex"); + return { stage: "built", tx, hex: p.artifact(tx), fee }; + } + this.signers.assertCanSign(p.account, p.net.family, p.signerOptions); + const signer = this.signers.resolve(p.account, p.net.family); + if (signer.address !== ownerAddress) { + throw new UsageError("invalid_account", "resolved signer address changed during transaction construction"); + } + await p.preflight?.(tx, signer.address); const signed = await obtainSignature(signer, p.ctx, (opts) => signer.sign(tx, opts)); - if (!p.broadcast) return { stage: "signed", signed, fee, address: signer.address, txId: txIdOf(signed) }; + if (mode === "sign-only") { + return { + stage: "signed", + signed, + ...(p.artifact ? { hex: p.artifact(signed) } : {}), + fee, + address: signer.address, + txId: txIdOf(signed), + }; + } const result = await p.broadcaster.broadcast(signed); const txId = String(result.txId ?? result.hash ?? ""); // default (no --wait): non-blocking, return the submitted txid only (fee/energy unknown yet). diff --git a/ts/src/application/services/pipeline/pipeline.test.ts b/ts/src/application/services/pipeline/pipeline.test.ts index 0eb0bf3e6..1dde8078e 100644 --- a/ts/src/application/services/pipeline/pipeline.test.ts +++ b/ts/src/application/services/pipeline/pipeline.test.ts @@ -47,9 +47,50 @@ describe("TxPipeline device-sign timeout", () => { signMessage: async () => "", signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), }; - const signers = { resolve: () => signer } as unknown as SignerResolver; + const signers = { assertCanSign: () => {}, resolve: () => signer } as unknown as SignerResolver; await expect(new TxPipeline(signers).run(params(signer))).rejects.toMatchObject({ code: "timeout" }); expect(captured?.aborted).toBe(true); // the abort is wired so the device prompt is cancelled }); + + it("build-only uses the public address and never resolves or unlocks a signer", async () => { + const signer: Signer = { + kind: "software", + address: "TSender", + sign: vi.fn(async (tx) => tx), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + const signers = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => signer), + } as unknown as SignerResolver; + const result = await new TxPipeline(signers).run(params(signer, { + mode: "build-only", + buildOnly: true, + prepare: (tx) => tx, + artifact: () => "abcd", + })); + + expect(result).toMatchObject({ stage: "built", hex: "abcd" }); + expect(signers.assertCanSign).not.toHaveBeenCalled(); + expect(signers.resolve).not.toHaveBeenCalled(); + expect(signer.sign).not.toHaveBeenCalled(); + }); + + it("preflights authorization before invoking the signer", async () => { + const order: string[] = []; + const signer: Signer = { + kind: "software", + address: "TSender", + sign: vi.fn(async (tx) => { order.push("sign"); return tx; }), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + const signers = { assertCanSign: vi.fn(), resolve: () => signer } as unknown as SignerResolver; + await new TxPipeline(signers).run(params(signer, { + preflight: async () => { order.push("preflight"); }, + })); + expect(order).toEqual(["preflight", "sign"]); + }); }); diff --git a/ts/src/application/services/target/index.ts b/ts/src/application/services/target/index.ts index 0254a929a..78df434bc 100644 --- a/ts/src/application/services/target/index.ts +++ b/ts/src/application/services/target/index.ts @@ -4,6 +4,7 @@ import type { NetworkRegistry } from "../../ports/network-registry.js"; import { UsageError } from "../../../domain/errors/index.js"; import { sourceFamily } from "../../../domain/sources/index.js"; import type { AccountStore } from "../../ports/account-store.js"; +import { familyOf } from "../../../domain/family/index.js"; export interface TargetResolverDeps { networkRegistry: NetworkRegistry; @@ -54,6 +55,8 @@ export class TargetResolver { #singleFamilyAccount(selection: ExecutionSelection): ChainFamily | undefined { const ref = selection.account ?? this.deps.keystore.activeAccount() ?? undefined; if (!ref) return undefined; + const directFamily = familyOf(ref); + if (directFamily) return directFamily; const { wallet } = this.deps.keystore.resolveAccount(ref); return sourceFamily(wallet.source); } diff --git a/ts/src/application/services/target/target.test.ts b/ts/src/application/services/target/target.test.ts index 8d21745db..2941df5a0 100644 --- a/ts/src/application/services/target/target.test.ts +++ b/ts/src/application/services/target/target.test.ts @@ -52,6 +52,13 @@ describe("TargetResolver", () => { expect(target.network?.id).toBe("evm:1"); }); + it("detects a direct on-chain address without resolving it from the keystore", () => { + const target = resolver("tron:mainnet").resolve(policy("tron"), { + account: "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7", + }); + expect(target.network?.id).toBe("tron:mainnet"); + }); + it("rejects a command implementation that does not support the selected network family", () => { expect(() => resolver("tron:mainnet").resolve(policy("evm" as any), {}), diff --git a/ts/src/application/services/transaction-mode.test.ts b/ts/src/application/services/transaction-mode.test.ts index 061e17cc8..bfa1c425b 100644 --- a/ts/src/application/services/transaction-mode.test.ts +++ b/ts/src/application/services/transaction-mode.test.ts @@ -13,18 +13,35 @@ function expectCode(fn: () => unknown, code: string) { describe("transactionMode", () => { it("no flag → broadcast (default)", () => { - expect(transactionMode({})).toEqual({ dryRun: false, broadcast: true }); + expect(transactionMode({})).toEqual({ + mode: "broadcast", + dryRun: false, + buildOnly: false, + broadcast: true, + permissionId: 0, + }); }); it("--dry-run → build + estimate only", () => { - expect(transactionMode({ dryRun: true })).toEqual({ dryRun: true, broadcast: false }); + expect(transactionMode({ dryRun: true })).toMatchObject({ mode: "dry-run", dryRun: true, broadcast: false }); }); it("--sign-only → sign, do not broadcast", () => { - expect(transactionMode({ signOnly: true })).toEqual({ dryRun: false, broadcast: false }); + expect(transactionMode({ signOnly: true })).toMatchObject({ mode: "sign-only", dryRun: false, broadcast: false }); }); it("--dry-run + --sign-only → invalid_option", () => { expectCode(() => transactionMode({ dryRun: true, signOnly: true }), "invalid_option"); }); + + it("supports unsigned build artifacts and bounded expiration", () => { + expect(transactionMode({ buildOnly: true, permissionId: 2, expiration: 86_400_000 })).toMatchObject({ + mode: "build-only", + permissionId: 2, + expiration: 86_400_000, + }); + expectCode(() => transactionMode({ expiration: 1_000 }), "invalid_option"); + expectCode(() => transactionMode({ signOnly: true, expiration: 86_400_001 }), "invalid_option"); + expectCode(() => transactionMode({ permissionId: 10 }), "invalid_option"); + }); }); diff --git a/ts/src/application/services/transaction-mode.ts b/ts/src/application/services/transaction-mode.ts index 1ea54587a..11f9387a9 100644 --- a/ts/src/application/services/transaction-mode.ts +++ b/ts/src/application/services/transaction-mode.ts @@ -4,22 +4,56 @@ import { UsageError } from "../../domain/errors/index.js"; export interface TransactionModeInput { dryRun?: boolean; signOnly?: boolean; + buildOnly?: boolean; + permissionId?: number; + expiration?: number; } -export function transactionMode(input: TransactionModeInput): { +export type TransactionExecutionMode = "dry-run" | "build-only" | "sign-only" | "broadcast"; + +export interface ResolvedTransactionMode { + mode: TransactionExecutionMode; dryRun: boolean; + buildOnly: boolean; broadcast: boolean; -} { - if (input.dryRun && input.signOnly) { - throw new UsageError("invalid_option", "choose at most one of --dry-run, --sign-only"); + permissionId: number; + expiration?: number; +} + +export function transactionMode(input: TransactionModeInput): ResolvedTransactionMode { + const selected = [input.dryRun, input.signOnly, input.buildOnly].filter(Boolean).length; + if (selected > 1) { + throw new UsageError("invalid_option", "choose at most one of --dry-run, --sign-only, --build-only"); + } + const permissionId = input.permissionId ?? 0; + if (!Number.isInteger(permissionId) || permissionId < 0 || permissionId > 9) { + throw new UsageError("invalid_option", "--permission-id must be an integer from 0 to 9"); + } + if (input.expiration !== undefined) { + if (!Number.isInteger(input.expiration) || input.expiration < 1 || input.expiration > 86_400_000) { + throw new UsageError("invalid_option", "--expiration must be an integer from 1 to 86400000 milliseconds"); + } + if (!input.signOnly && !input.buildOnly) { + throw new UsageError("invalid_option", "--expiration is only valid with --sign-only or --build-only"); + } } - if (input.dryRun) return { dryRun: true, broadcast: false }; - if (input.signOnly) return { dryRun: false, broadcast: false }; - return { dryRun: false, broadcast: true }; + const common = { permissionId, ...(input.expiration === undefined ? {} : { expiration: input.expiration }) }; + if (input.dryRun) return { mode: "dry-run", dryRun: true, buildOnly: false, broadcast: false, ...common }; + if (input.buildOnly) return { mode: "build-only", dryRun: false, buildOnly: true, broadcast: false, ...common }; + if (input.signOnly) return { mode: "sign-only", dryRun: false, buildOnly: false, broadcast: false, ...common }; + return { mode: "broadcast", dryRun: false, buildOnly: false, broadcast: true, ...common }; +} + +export function transactionRequiresSigner(input: TransactionModeInput): boolean { + const mode = transactionMode(input).mode; + return mode === "sign-only" || mode === "broadcast"; } export function outcomeData(outcome: TxOutcome): Record { if (outcome.stage === "plan") return { mode: "dry-run", fee: outcome.fee, tx: outcome.tx }; + if (outcome.stage === "built") { + return { mode: "build-only", tx: outcome.tx, hex: outcome.hex, fee: outcome.fee }; + } if (outcome.stage === "signed") { // `fee` is absent when the caller supplied the transaction (tx sign): nothing was estimated. // Omit rather than emit undefined — kv() drops empty rows and JSON stays additive. @@ -29,8 +63,8 @@ export function outcomeData(outcome: TxOutcome): Record { ...(outcome.fee === undefined ? {} : { fee: outcome.fee }), ...(outcome.address === undefined ? {} : { address: outcome.address }), ...(outcome.txId === undefined ? {} : { txId: outcome.txId }), + ...(outcome.hex === undefined ? {} : { hex: outcome.hex }), }; } return outcome as unknown as Record; } - diff --git a/ts/src/application/use-cases/tron/multisig-authorization.test.ts b/ts/src/application/use-cases/tron/multisig-authorization.test.ts new file mode 100644 index 000000000..90f15d2f3 --- /dev/null +++ b/ts/src/application/use-cases/tron/multisig-authorization.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TronTransactionArtifact } from "../../../domain/types/index.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import { assertTronSignerAuthorized, authorizationState } from "./multisig-authorization.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + +function transaction(permissionId = 2): TronTransactionArtifact { + return { + txID: "ab".repeat(32), + raw_data_hex: "00", + raw_data: { + expiration: 2_000_000_000_000, + contract: [{ type: "TransferContract", Permission_id: permissionId }], + }, + }; +} + +function gateway(over: Partial = {}): TronGateway { + return { + getSignWeight: vi.fn(async () => ({ + permission: { + id: 2, + name: "finance", + threshold: 2, + operationsHex: "02" + "00".repeat(31), + keys: [{ address: A, weight: 1 }, { address: B, weight: 1 }], + }, + approvedList: [], + currentWeight: 0, + resultCode: "NOT_ENOUGH_PERMISSION", + })), + getApprovedList: vi.fn(async () => []), + ...over, + } as unknown as TronGateway; +} + +describe("TRON multisig authorization preflight", () => { + it("accepts an unused key whose active bitmap allows the transaction contract", async () => { + await expect(assertTronSignerAuthorized(gateway(), transaction(), A, 1_900_000_000_000)) + .resolves.toBeUndefined(); + }); + + it("rejects a signer outside the selected permission and a repeated signer", async () => { + await expect(assertTronSignerAuthorized(gateway(), transaction(), "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", 1_900_000_000_000)) + .rejects.toMatchObject({ code: "not_authorized" }); + + const signed = gateway({ + getSignWeight: vi.fn(async () => ({ + permission: { + id: 2, + name: "finance", + threshold: 2, + operationsHex: "02" + "00".repeat(31), + keys: [{ address: A, weight: 1 }, { address: B, weight: 1 }], + }, + approvedList: [A], + currentWeight: 1, + resultCode: "NOT_ENOUGH_PERMISSION", + })), + getApprovedList: vi.fn(async () => [A]), + }); + await expect(assertTronSignerAuthorized(signed, transaction(), A, 1_900_000_000_000)) + .rejects.toMatchObject({ code: "already_signed" }); + }); + + it("fails closed on a disallowed operation and inconsistent node approval state", async () => { + const disallowed = gateway({ + getSignWeight: vi.fn(async () => ({ + permission: { + id: 2, + name: "vote", + threshold: 1, + operationsHex: "10" + "00".repeat(31), + keys: [{ address: A, weight: 1 }], + }, + approvedList: [], + currentWeight: 0, + resultCode: "NOT_ENOUGH_PERMISSION", + })), + }); + await expect(assertTronSignerAuthorized(disallowed, transaction(), A, 1_900_000_000_000)) + .rejects.toMatchObject({ code: "not_authorized" }); + + const inconsistent = gateway({ + getApprovedList: vi.fn(async () => [A]), + }); + await expect(authorizationState(inconsistent, transaction())) + .rejects.toMatchObject({ code: "provider_error" }); + }); + + it("rejects expired transactions before calling the node", async () => { + const target = gateway(); + await expect(assertTronSignerAuthorized(target, transaction(), A, 2_100_000_000_000)) + .rejects.toMatchObject({ code: "tx_expired" }); + expect(target.getSignWeight).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/tron/multisig-authorization.ts b/ts/src/application/use-cases/tron/multisig-authorization.ts new file mode 100644 index 000000000..5e74063f2 --- /dev/null +++ b/ts/src/application/use-cases/tron/multisig-authorization.ts @@ -0,0 +1,126 @@ +import type { TronTransactionArtifact, UnsignedTx } from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { decodeOperations } from "../../../domain/permission/index.js"; +import { addressCodec } from "../../../domain/family/index.js"; +import type { TronGateway, TronSignWeight } from "../../ports/chain/tron-gateway.js"; + +export function transactionContract(transaction: TronTransactionArtifact) { + const contracts = transaction.raw_data?.contract; + if (!Array.isArray(contracts) || contracts.length !== 1) { + throw new ChainError("invalid_transaction", "transaction must contain exactly one contract"); + } + return contracts[0]!; +} + +export function expirationOf(transaction: TronTransactionArtifact): number { + const expiration = transaction.raw_data.expiration; + if (!Number.isSafeInteger(expiration) || expiration! <= 0) { + throw new ChainError("invalid_transaction", "transaction expiration is missing or imprecise"); + } + return expiration!; +} + +export function assertNotExpired(transaction: TronTransactionArtifact, now = Date.now()): void { + const expiration = expirationOf(transaction); + if (expiration <= now) { + throw new ChainError("tx_expired", `transaction expired at ${new Date(expiration).toISOString()}`); + } +} + +function uniqueAddresses(addresses: readonly string[], field: string): Set { + const set = new Set(addresses); + if (set.size !== addresses.length + || addresses.some((address) => !addressCodec("tron").validate(address))) { + throw new ChainError("provider_error", `TRON node returned invalid or duplicate addresses in ${field}`); + } + return set; +} + +function assertPermissionConsistent( + transaction: TronTransactionArtifact, + weight: TronSignWeight, + approved: string[], +): NonNullable { + const acceptedCodes = new Set(["ENOUGH_PERMISSION", "NOT_ENOUGH_PERMISSION"]); + if (weight.resultCode && !acceptedCodes.has(weight.resultCode)) { + throw new ChainError( + weight.resultCode.includes("PERMISSION") ? "not_authorized" : "invalid_transaction", + weight.message || `node rejected transaction signatures (${weight.resultCode})`, + ); + } + if (!weight.permission) { + const code = weight.resultCode || "unknown"; + throw new ChainError( + code.includes("PERMISSION") ? "not_authorized" : "invalid_transaction", + weight.message || `node could not resolve transaction permission (${code})`, + ); + } + const permissionId = transactionContract(transaction).Permission_id ?? 0; + if (weight.permission.id !== permissionId) { + throw new ChainError("provider_error", "node resolved a different permission id for the transaction"); + } + const fromWeight = uniqueAddresses(weight.approvedList, "getsignweight.approved_list"); + const fromApproved = uniqueAddresses(approved, "getapprovedlist.approved_list"); + if (fromWeight.size !== fromApproved.size || [...fromWeight].some((address) => !fromApproved.has(address))) { + throw new ChainError("provider_error", "getsignweight and getapprovedlist returned different approvals"); + } + const keys = new Map(weight.permission.keys.map((key) => [key.address, key.weight])); + let computedWeight = 0; + for (const address of fromApproved) { + const keyWeight = keys.get(address); + if (keyWeight === undefined) { + throw new ChainError("provider_error", "node approved a signer outside the selected permission group"); + } + computedWeight += keyWeight; + } + if (computedWeight !== weight.currentWeight) { + throw new ChainError("provider_error", "node current_weight does not match approved signer weights"); + } + + const contractType = transactionContract(transaction).type; + if (permissionId >= 2) { + if (!weight.permission.operationsHex) { + throw new ChainError("provider_error", "active permission is missing its operations bitmap"); + } + let operations: ReturnType; + try { + operations = decodeOperations(weight.permission.operationsHex); + } catch { + throw new ChainError("provider_error", "active permission has a malformed operations bitmap"); + } + if (!operations.operations.includes(contractType)) { + throw new ChainError("not_authorized", `permission ${permissionId} does not allow ${contractType}`); + } + } + return weight.permission; +} + +export async function authorizationState( + gateway: TronGateway, + transaction: TronTransactionArtifact, +) { + const [weight, approved] = await Promise.all([ + gateway.getSignWeight(transaction), + gateway.getApprovedList(transaction), + ]); + const permission = assertPermissionConsistent(transaction, weight, approved); + return { weight, approved, permission }; +} + +/** Validate permission membership before decrypting a software key or opening Ledger UI. */ +export async function assertTronSignerAuthorized( + gateway: TronGateway, + transaction: UnsignedTx, + signerAddress: string, + now = Date.now(), +): Promise { + const artifact = transaction as TronTransactionArtifact; + assertNotExpired(artifact, now); + const { approved, permission } = await authorizationState(gateway, artifact); + if (!permission.keys.some((key) => key.address === signerAddress)) { + throw new ChainError("not_authorized", "selected signer is not a key in the transaction permission group"); + } + if (approved.includes(signerAddress)) { + throw new ChainError("already_signed", "selected signer has already approved this transaction"); + } +} diff --git a/ts/src/application/use-cases/tron/permission-service.test.ts b/ts/src/application/use-cases/tron/permission-service.test.ts new file mode 100644 index 000000000..b39c40ce0 --- /dev/null +++ b/ts/src/application/use-cases/tron/permission-service.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AccountPermissionsView } from "../../../domain/types/index.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { AccountStore } from "../../ports/account-store.js"; +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import { TronPermissionService } from "./permission-service.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const NETWORK = { id: "tron:nile", family: "tron" } as never; + +function permissions(): AccountPermissionsView { + return { + address: A, + owner: { + id: 0, + name: "owner", + threshold: 2, + keys: [{ address: A, weight: 1, local: null }, { address: B, weight: 1, local: null }], + }, + witness: null, + actives: [{ + id: 2, + name: "finance", + threshold: 1, + keys: [{ address: A, weight: 1, local: null }], + operations: ["TransferContract"], + operationLabels: ["Transfer TRX"], + operationsHex: "02" + "00".repeat(31), + unknownOperationIds: [], + }], + }; +} + +function scope(): TransactionScope { + return { + activeAccount: "local" as never, + timeoutMs: 100, + wait: false, + waitTimeoutMs: 100, + resolveAddress: () => A, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function accounts(): AccountStore { + return { + list: () => [{ + accountId: "local" as never, + label: "main", + type: "privateKey", + index: null, + active: true, + addresses: { tron: A }, + }], + } as unknown as AccountStore; +} + +function setup(balance = "200000000") { + const gateway = { + getAccountPermissions: vi.fn(async () => permissions()), + getUpdateAccountPermissionFee: vi.fn(async () => 100_000_000), + getNativeBalance: vi.fn(async () => balance), + buildAccountPermissionUpdate: vi.fn(async () => ({ txID: "tx" })), + prepareTransaction: vi.fn((tx) => tx), + encodeTransactionHex: vi.fn(() => "abcd"), + } as unknown as TronGateway; + let captured: TxPipelineParams | undefined; + const pipeline = { + assertCanSign: vi.fn(), + run: vi.fn(async (params: TxPipelineParams) => { + captured = params; + const tx = await params.build(A); + return { stage: "plan" as const, tx, fee: await params.estimate(tx) }; + }), + } as unknown as TxPipeline; + const provider = { get: () => gateway } as unknown as ChainGatewayProvider; + return { + gateway, + pipeline, + service: new TronPermissionService(provider, accounts(), pipeline), + captured: () => captured, + }; +} + +describe("TRON permission service", () => { + it("annotates locally controlled keys on show", async () => { + const { service } = setup(); + const view = await service.show(NETWORK, A); + expect(view.owner.keys.map((key) => key.local)).toEqual(["main", null]); + }); + + it("dry-runs with dynamic fee, emits structured warnings, and never gates a signer", async () => { + const { service, pipeline, captured } = setup(); + const ctx = scope(); + const result = await service.update(ctx, NETWORK, { dryRun: true }, permissions()); + expect(result).toMatchObject({ kind: "permission-update", mode: "dry-run" }); + expect(ctx.warn).toHaveBeenCalledWith(expect.objectContaining({ code: "owner_lockout_partial" })); + expect(pipeline.assertCanSign).not.toHaveBeenCalled(); + expect(captured()?.permissionId).toBe(0); + expect((result as unknown as { fee: unknown }).fee).toMatchObject({ + feeSun: 100_000_000, + balanceSun: "200000000", + }); + }); + + it("allows an offline build even when balance is currently low", async () => { + const { service, pipeline } = setup("0"); + await expect(service.update(scope(), NETWORK, { buildOnly: true }, permissions())).resolves.toBeDefined(); + expect(pipeline.assertCanSign).not.toHaveBeenCalled(); + }); + + it("rejects insufficient balance before constructing a broadcast transaction", async () => { + const { service, gateway, pipeline } = setup("99999999"); + await expect(service.update(scope(), NETWORK, {}, permissions())).rejects.toMatchObject({ code: "insufficient_balance" }); + expect(gateway.buildAccountPermissionUpdate).not.toHaveBeenCalled(); + expect(pipeline.run).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/tron/permission-service.ts b/ts/src/application/use-cases/tron/permission-service.ts new file mode 100644 index 000000000..7ceeff08b --- /dev/null +++ b/ts/src/application/use-cases/tron/permission-service.ts @@ -0,0 +1,113 @@ +import type { AccountPermissionsView, NetworkDescriptor } from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { + annotateLocalPermissionKeys, + buildLocalPermissionInventory, + permissionSafetyWarnings, + validatePermissionStructure, +} from "../../../domain/permission/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { AccountStore } from "../../ports/account-store.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; +import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { assertTronSignerAuthorized } from "./multisig-authorization.js"; + +export class TronPermissionService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly accounts: Pick, + private readonly pipeline: TxPipeline, + ) {} + + async show(network: NetworkDescriptor, address: string): Promise { + const permissions = await this.gateways.get(network, "tron").getAccountPermissions(address); + return annotateLocalPermissionKeys(permissions, buildLocalPermissionInventory(this.accounts.list())); + } + + async update( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionModeInput, + requested: unknown, + ) { + const gateway = this.gateways.get(network, "tron"); + const address = scope.resolveAddress("tron"); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); + const inventory = buildLocalPermissionInventory(this.accounts.list()); + const permissions = annotateLocalPermissionKeys( + validatePermissionStructure(requested, address), + inventory, + ); + for (const warning of permissionSafetyWarnings(permissions, inventory)) scope.warn(warning); + + const mode = transactionMode(input); + const [feeSun, balanceSun] = await Promise.all([ + gateway.getUpdateAccountPermissionFee(), + gateway.getNativeBalance(address), + ]); + if (mode.mode === "broadcast" && BigInt(balanceSun) < BigInt(feeSun)) { + throw new ChainError( + "insufficient_balance", + `account balance ${balanceSun} SUN is below the ${feeSun} SUN permission update fee`, + ); + } + + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...mode, + prepare: (tx, options) => gateway.prepareTransaction(tx, options), + artifact: (tx) => gateway.encodeTransactionHex(tx), + preflight: (tx, signer) => assertTronSignerAuthorized(gateway, tx, signer), + confirm: tronConfirmation(gateway, scope), + build: () => gateway.buildAccountPermissionUpdate(address, permissions), + estimate: async () => ({ feeModel: "tron-resource", feeSun, balanceSun }), + }); + + let resultPermissions: AccountPermissionsView | undefined; + if (outcome.stage === "confirmed") { + resultPermissions = await this.show(network, address); + if (canonicalPermissions(resultPermissions) !== canonicalPermissions(permissions)) { + scope.warn({ + code: "permission_postcheck_mismatch", + message: "confirmed transaction permissions differ from the requested canonical structure", + }); + } + } else if (outcome.stage === "plan" || outcome.stage === "built" || outcome.stage === "signed") { + resultPermissions = permissions; + } + return { + kind: "permission-update" as const, + ...outcomeData(outcome), + ...(resultPermissions ? { permissions: resultPermissions } : {}), + }; + } +} + +function canonicalPermissions(value: AccountPermissionsView): string { + const key = ({ address, weight }: { address: string; weight: number }) => ({ address, weight }); + const group = (permission: AccountPermissionsView["owner"]) => ({ + id: permission.id, + name: permission.name, + threshold: permission.threshold, + keys: permission.keys.map(key), + }); + return JSON.stringify({ + address: value.address, + owner: group(value.owner), + witness: value.witness ? group(value.witness) : null, + actives: value.actives.map((permission) => ({ + ...group(permission), + operationsHex: permission.operationsHex, + })), + }); +} diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index bc2d45468..8b72db188 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -84,6 +84,7 @@ export function composeCliRuntime(options: BootstrapOptions) { prices: priceProvider, signers: signerResolver, transactions: txPipeline, + accounts: keystore, timeoutMs, }); diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 9e00fd3fc..cfd1628eb 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -27,6 +27,12 @@ import { } from "../../adapters/inbound/cli/commands/token.js"; import { messageSignSpec, messageSignBinding } from "../../adapters/inbound/cli/commands/shared.js"; import { typedDataSignSpec, typedDataSignBinding } from "../../adapters/inbound/cli/commands/typed-data.js"; +import { + permissionShowSpec, + permissionShowTronBinding, + permissionUpdateSpec, + permissionUpdateTronBinding, +} from "../../adapters/inbound/cli/commands/permission.js"; import { txBroadcastSpec, txBroadcastTronBinding, @@ -77,11 +83,13 @@ import { TronChainService } from "../../application/use-cases/tron/chain-service import { TronBlockService } from "../../application/use-cases/tron/block-service.js"; import { MessageService } from "../../application/use-cases/message-service.js"; import { TypedDataService } from "../../application/use-cases/typed-data-service.js"; +import { TronPermissionService } from "../../application/use-cases/tron/permission-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 { AccountStore } from "../../application/ports/account-store.js"; import type { FamilyPlugin } from "./types.js"; export const tronFamily: FamilyPlugin<"tron"> = { @@ -96,6 +104,7 @@ export interface TronChainCommandDependencies { prices: PriceProvider; signers: SignerResolver; transactions: TxPipeline; + accounts: AccountStore; timeoutMs: number; } @@ -110,6 +119,7 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC const message = new MessageService(deps.signers); const typedData = new TypedDataService(deps.signers); const transaction = new TronTransactionService(deps.gateways, deps.tokens, deps.transactions); + const permission = new TronPermissionService(deps.gateways, deps.accounts, deps.transactions); const stake = new TronStakeService(deps.gateways, deps.transactions); const vote = new TronVoteService(deps.gateways, deps.transactions, stake); const reward = new TronRewardService(deps.gateways, deps.transactions); @@ -133,6 +143,8 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC reg.addChain(txBroadcastSpec, "tron", txBroadcastTronBinding(transaction)); reg.addChain(txStatusSpec, "tron", txStatusTronBinding(transaction)); reg.addChain(txInfoSpec, "tron", txInfoTronBinding(transaction)); + reg.addChain(permissionShowSpec, "tron", permissionShowTronBinding(permission)); + reg.addChain(permissionUpdateSpec, "tron", permissionUpdateTronBinding(permission)); for (const definition of stakeDefinitions(stake)) { reg.addChain(definition.spec, "tron", definition.binding); } diff --git a/ts/src/domain/permission/index.ts b/ts/src/domain/permission/index.ts new file mode 100644 index 000000000..d2a33a803 --- /dev/null +++ b/ts/src/domain/permission/index.ts @@ -0,0 +1,327 @@ +import type { + AccountDescriptor, + AccountPermissionsView, + ActivePermissionView, + PermissionGroupView, + PermissionKeyView, + WarningView, +} from "../types/index.js"; +import { UsageError } from "../errors/index.js"; +import { TronAddress } from "../address/index.js"; + +const OPERATIONS_BYTES = 32; +const MAX_KEYS = 5; +const MAX_ACTIVES = 8; +const MAX_PERMISSION_NAME_BYTES = 32; +const addressCodec = new TronAddress(); + +export interface TronOperation { + contractTypeId: number; + contractType: string; + label: string; +} + +/** Authoritative mapping shared by permission bitmaps, display labels, and authorization checks. */ +export const TRON_OPERATIONS: readonly TronOperation[] = Object.freeze([ + { contractTypeId: 0, contractType: "AccountCreateContract", label: "Activate Account" }, + { contractTypeId: 1, contractType: "TransferContract", label: "Transfer TRX" }, + { contractTypeId: 2, contractType: "TransferAssetContract", label: "Transfer TRC10" }, + { contractTypeId: 4, contractType: "VoteWitnessContract", label: "Vote" }, + { contractTypeId: 5, contractType: "WitnessCreateContract", label: "Apply to Become a SR Candidate" }, + { contractTypeId: 6, contractType: "AssetIssueContract", label: "Issue TRC10" }, + { contractTypeId: 8, contractType: "WitnessUpdateContract", label: "Update SR Info" }, + { contractTypeId: 9, contractType: "ParticipateAssetIssueContract", label: "Participate in TRC10 Issuance" }, + { contractTypeId: 10, contractType: "AccountUpdateContract", label: "Update Account Name" }, + { contractTypeId: 11, contractType: "FreezeBalanceContract", label: "TRX Stake (1.0)" }, + { contractTypeId: 12, contractType: "UnfreezeBalanceContract", label: "TRX Unstake (1.0)" }, + { contractTypeId: 13, contractType: "WithdrawBalanceContract", label: "Claim Voting Rewards" }, + { contractTypeId: 14, contractType: "UnfreezeAssetContract", label: "Unstake TRC10" }, + { contractTypeId: 15, contractType: "UpdateAssetContract", label: "Update TRC10 Parameters" }, + { contractTypeId: 16, contractType: "ProposalCreateContract", label: "Create Proposal" }, + { contractTypeId: 17, contractType: "ProposalApproveContract", label: "Approve Proposal" }, + { contractTypeId: 18, contractType: "ProposalDeleteContract", label: "Cancel Proposal" }, + { contractTypeId: 19, contractType: "SetAccountIdContract", label: "Set Account Id" }, + { contractTypeId: 30, contractType: "CreateSmartContract", label: "Create Smart Contract" }, + { contractTypeId: 31, contractType: "TriggerSmartContract", label: "Trigger Smart Contract" }, + { contractTypeId: 33, contractType: "UpdateSettingContract", label: "Update Contract Parameters" }, + { contractTypeId: 41, contractType: "ExchangeCreateContract", label: "Create Bancor Transaction" }, + { contractTypeId: 42, contractType: "ExchangeInjectContract", label: "Inject Assets into Bancor Transaction" }, + { contractTypeId: 43, contractType: "ExchangeWithdrawContract", label: "Withdraw Assets from Bancor Transaction" }, + { contractTypeId: 44, contractType: "ExchangeTransactionContract", label: "Execute Bancor Transaction" }, + { contractTypeId: 45, contractType: "UpdateEnergyLimitContract", label: "Update Contract Energy Limit" }, + { contractTypeId: 46, contractType: "AccountPermissionUpdateContract", label: "Update Account Permissions" }, + { contractTypeId: 48, contractType: "ClearABIContract", label: "Clear Contract ABI" }, + { contractTypeId: 49, contractType: "UpdateBrokerageContract", label: "Update SR Commission Ratio" }, + { contractTypeId: 52, contractType: "MarketSellAssetContract", label: "Market Sell Asset" }, + { contractTypeId: 53, contractType: "MarketCancelOrderContract", label: "Market Cancel Order" }, + { contractTypeId: 54, contractType: "FreezeBalanceV2Contract", label: "TRX Stake (2.0)" }, + { contractTypeId: 55, contractType: "UnfreezeBalanceV2Contract", label: "TRX Unstake (2.0)" }, + { contractTypeId: 56, contractType: "WithdrawExpireUnfreezeContract", label: "Withdraw Unstaked TRX" }, + { contractTypeId: 57, contractType: "DelegateResourceContract", label: "Delegate Resources" }, + { contractTypeId: 58, contractType: "UnDelegateResourceContract", label: "Reclaim Resources" }, + { contractTypeId: 59, contractType: "CancelAllUnfreezeV2Contract", label: "Cancel Unstake" }, +]); + +const operationByType = new Map(TRON_OPERATIONS.map((operation) => [operation.contractType, operation])); +const operationById = new Map(TRON_OPERATIONS.map((operation) => [operation.contractTypeId, operation])); + +function invalidPermission(message: string, details?: object): never { + throw new UsageError("invalid_permission", message, details); +} + +function ownRecord(value: unknown, field: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalidPermission(`${field} must be an object`); + } + return value as Record; +} + +function safePositiveInteger(value: unknown, field: string): number { + let text: string; + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) return invalidPermission(`${field} must be a safe integer`); + text = String(value); + } else if (typeof value === "bigint") { + text = value.toString(); + } else if (typeof value === "string" && /^(?:0|[1-9][0-9]*)$/.test(value)) { + text = value; + } else if (value && typeof value === "object" && typeof (value as { toString?: unknown }).toString === "function") { + text = String(value); + } else { + return invalidPermission(`${field} must be an integer`); + } + if (!/^[1-9][0-9]*$/.test(text)) return invalidPermission(`${field} must be greater than zero`); + const integer = BigInt(text); + if (integer > BigInt(Number.MAX_SAFE_INTEGER)) { + return invalidPermission(`${field} exceeds the precise integer range supported by TronWeb`); + } + return Number(integer); +} + +function exactInteger(value: unknown, expected: number, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value !== expected) { + return invalidPermission(`${field} must be ${expected}`); + } + return value; +} + +function permissionName(value: unknown, fallback: string, field: string): string { + const name = value === undefined ? fallback : value; + if (typeof name !== "string" || name.length === 0) return invalidPermission(`${field} must not be empty`); + if (Buffer.byteLength(name, "utf8") > MAX_PERMISSION_NAME_BYTES) { + return invalidPermission(`${field} must be at most ${MAX_PERMISSION_NAME_BYTES} UTF-8 bytes`); + } + if (/\p{Cc}/u.test(name)) return invalidPermission(`${field} must not contain control characters`); + return name; +} + +function keys(value: unknown, field: string, exactCount?: number): PermissionKeyView[] { + if (!Array.isArray(value) + || value.length < 1 + || value.length > MAX_KEYS + || (exactCount !== undefined && value.length !== exactCount)) { + return invalidPermission( + exactCount === undefined + ? `${field} must contain 1 to ${MAX_KEYS} keys` + : `${field} must contain exactly ${exactCount} key`, + ); + } + const seen = new Set(); + return value.map((entry, index) => { + const item = ownRecord(entry, `${field}[${index}]`); + if (typeof item.address !== "string" || !addressCodec.validate(item.address)) { + return invalidPermission(`${field}[${index}].address is not a valid TRON address`); + } + if (seen.has(item.address)) return invalidPermission(`${field} contains a duplicate address`); + seen.add(item.address); + return { + address: item.address, + weight: safePositiveInteger(item.weight, `${field}[${index}].weight`), + local: null, + }; + }); +} + +function group(value: unknown, kind: "owner" | "witness", expectedId: 0 | 1): PermissionGroupView { + const input = ownRecord(value, kind); + if (input.operations !== undefined || input.operationsHex !== undefined) { + return invalidPermission(`${kind} permission must not define operations`); + } + const parsedKeys = keys(input.keys, `${kind}.keys`, kind === "witness" ? 1 : undefined); + const threshold = safePositiveInteger(input.threshold, `${kind}.threshold`); + const total = parsedKeys.reduce((sum, key) => sum + BigInt(key.weight), 0n); + if (BigInt(threshold) > total) return invalidPermission(`${kind}.threshold exceeds the total key weight`); + return { + id: exactInteger(input.id, expectedId, `${kind}.id`), + name: permissionName(input.name, kind, `${kind}.name`), + threshold, + keys: parsedKeys, + }; +} + +export interface DecodedOperations { + operations: string[]; + labels: string[]; + unknownOperationIds: number[]; + operationsHex: string; +} + +export function decodeOperations(input: string): DecodedOperations { + const operationsHex = input.trim().replace(/^0x/i, "").toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(operationsHex)) { + return invalidPermission("operationsHex must be exactly 32 bytes of hex"); + } + const bytes = Buffer.from(operationsHex, "hex"); + const operations: string[] = []; + const labels: string[] = []; + const unknownOperationIds: number[] = []; + for (let id = 0; id < OPERATIONS_BYTES * 8; id += 1) { + if ((bytes[Math.floor(id / 8)]! & (1 << (id % 8))) === 0) continue; + const operation = operationById.get(id); + if (operation) { + operations.push(operation.contractType); + labels.push(operation.label); + } else { + unknownOperationIds.push(id); + } + } + return { operations, labels, unknownOperationIds, operationsHex }; +} + +export function encodeOperations(contractTypes: readonly string[]): string { + if (!Array.isArray(contractTypes) || contractTypes.length === 0) { + return invalidPermission("active.operations must contain at least one contract type"); + } + const bytes = Buffer.alloc(OPERATIONS_BYTES); + const seen = new Set(); + for (const contractType of contractTypes) { + if (typeof contractType !== "string") return invalidPermission("active.operations entries must be strings"); + if (seen.has(contractType)) continue; + seen.add(contractType); + const operation = operationByType.get(contractType); + if (!operation) return invalidPermission(`unknown TRON contract type: ${contractType}`); + bytes[Math.floor(operation.contractTypeId / 8)]! |= 1 << (operation.contractTypeId % 8); + } + return bytes.toString("hex"); +} + +function activeGroup(value: unknown, index: number): ActivePermissionView { + const input = ownRecord(value, `actives[${index}]`); + if (typeof input.id !== "number" || !Number.isSafeInteger(input.id) || input.id < 2 || input.id > 9) { + return invalidPermission(`actives[${index}].id must be an integer from 2 to 9`); + } + if (!Array.isArray(input.operations)) { + return invalidPermission(`actives[${index}].operations must be an array`); + } + const operationsHex = encodeOperations(input.operations as string[]); + if (input.operationsHex !== undefined) { + if (typeof input.operationsHex !== "string" || decodeOperations(input.operationsHex).operationsHex !== operationsHex) { + return invalidPermission(`actives[${index}].operationsHex does not match operations`); + } + } + const decoded = decodeOperations(operationsHex); + const parsedKeys = keys(input.keys, `actives[${index}].keys`); + const threshold = safePositiveInteger(input.threshold, `actives[${index}].threshold`); + const total = parsedKeys.reduce((sum, key) => sum + BigInt(key.weight), 0n); + if (BigInt(threshold) > total) { + return invalidPermission(`actives[${index}].threshold exceeds the total key weight`); + } + return { + id: input.id, + name: permissionName(input.name, "active", `actives[${index}].name`), + threshold, + keys: parsedKeys, + operations: decoded.operations, + operationLabels: decoded.labels, + operationsHex, + unknownOperationIds: [], + }; +} + +/** Strictly validate and canonicalize the complete replacement structure. */ +export function validatePermissionStructure(value: unknown, expectedAddress?: string): AccountPermissionsView { + const input = ownRecord(value, "permission structure"); + if (expectedAddress !== undefined && input.address !== undefined && input.address !== expectedAddress) { + return invalidPermission("permission structure address does not match the selected account"); + } + const address = expectedAddress ?? input.address; + if (typeof address !== "string" || !addressCodec.validate(address)) { + return invalidPermission("permission structure address is not a valid TRON address"); + } + const owner = group(input.owner, "owner", 0); + const witness = input.witness === undefined || input.witness === null + ? null + : group(input.witness, "witness", 1); + const activeInput = input.actives ?? []; + if (!Array.isArray(activeInput) || activeInput.length > MAX_ACTIVES) { + return invalidPermission(`actives must contain at most ${MAX_ACTIVES} permission groups`); + } + const actives = activeInput.map(activeGroup); + const ids = new Set(); + for (const active of actives) { + if (ids.has(active.id)) return invalidPermission("active permission ids must be unique"); + ids.add(active.id); + } + return { address, owner, witness, actives }; +} + +export type LocalPermissionInventory = ReadonlyMap; + +/** Watch-only accounts are excluded because they cannot contribute a signature. */ +export function buildLocalPermissionInventory(accounts: readonly AccountDescriptor[]): LocalPermissionInventory { + const inventory = new Map(); + for (const account of accounts) { + if (account.type === "watch") continue; + const address = account.addresses.tron; + if (!address || inventory.has(address)) continue; + inventory.set(address, account.label ?? String(account.accountId)); + } + return inventory; +} + +export function annotateLocalPermissionKeys( + permissions: AccountPermissionsView, + inventory: LocalPermissionInventory, +): AccountPermissionsView { + const annotate = (permission: PermissionGroupView): PermissionGroupView => ({ + ...permission, + keys: permission.keys.map((key) => ({ ...key, local: inventory.get(key.address) ?? null })), + }); + return { + ...permissions, + owner: annotate(permissions.owner), + witness: permissions.witness ? annotate(permissions.witness) : null, + actives: permissions.actives.map((active) => ({ ...active, ...annotate(active) })), + }; +} + +export function permissionSafetyWarnings( + permissions: AccountPermissionsView, + inventory: LocalPermissionInventory, +): WarningView[] { + const localOwnerWeight = permissions.owner.keys.reduce( + (sum, key) => inventory.has(key.address) ? sum + key.weight : sum, + 0, + ); + const warnings: WarningView[] = []; + if (localOwnerWeight === 0) { + warnings.push({ + code: "owner_lockout", + message: "local signing keys hold no owner weight; applying this structure may permanently lock out this wallet", + }); + } else if (localOwnerWeight < permissions.owner.threshold) { + warnings.push({ + code: "owner_lockout_partial", + message: `local keys hold ${localOwnerWeight} of ${permissions.owner.threshold} owner weight; co-signers are required for owner-level operations`, + }); + } + for (const active of permissions.actives) { + if (active.operations.includes("AccountPermissionUpdateContract")) { + warnings.push({ + code: "active_can_update_permission", + message: `active permission ${active.name} (id ${active.id}) can replace the account permission structure`, + }); + } + } + return warnings; +} diff --git a/ts/src/domain/permission/permission.test.ts b/ts/src/domain/permission/permission.test.ts new file mode 100644 index 000000000..f71e7f711 --- /dev/null +++ b/ts/src/domain/permission/permission.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import type { AccountDescriptor } from "../types/index.js"; +import { + annotateLocalPermissionKeys, + buildLocalPermissionInventory, + decodeOperations, + encodeOperations, + permissionSafetyWarnings, + validatePermissionStructure, +} from "./index.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const C = "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8"; + +function structure() { + return { + address: A, + owner: { + id: 0, + threshold: 2, + keys: [{ address: A, weight: 1, local: "forged" }, { address: B, weight: 1 }], + }, + witness: null, + actives: [{ + id: 2, + name: "finance", + threshold: 1, + operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], + operationsHex: "0600008000000000000000000000000000000000000000000000000000000000", + keys: [{ address: A, weight: 1 }], + }], + }; +} + +describe("TRON permission operations", () => { + it("uses contract ids as little-endian bits within a fixed 32-byte bitmap", () => { + const encoded = encodeOperations(["TransferContract", "TransferAssetContract", "TriggerSmartContract"]); + expect(encoded).toBe("0600008000000000000000000000000000000000000000000000000000000000"); + expect(decodeOperations(encoded)).toMatchObject({ + operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], + labels: ["Transfer TRX", "Transfer TRC10", "Trigger Smart Contract"], + unknownOperationIds: [], + }); + }); + + it("preserves unknown set bits when reading node state", () => { + const decoded = decodeOperations("08" + "00".repeat(31)); + expect(decoded.operations).toEqual([]); + expect(decoded.unknownOperationIds).toEqual([3]); + }); + + it("rejects unknown contract names and malformed bitmaps", () => { + expect(() => encodeOperations(["FutureContract"])).toThrowError(/unknown TRON contract/); + expect(() => decodeOperations("00")).toThrowError(/exactly 32 bytes/); + }); +}); + +describe("permission replacement validation", () => { + it("canonicalizes a valid structure and ignores forged local labels", () => { + const parsed = validatePermissionStructure(structure()); + expect(parsed.owner.keys[0]?.local).toBeNull(); + expect(parsed.owner.name).toBe("owner"); + expect(parsed.actives[0]?.operationLabels).toContain("Transfer TRX"); + }); + + it("rejects unsafe thresholds, duplicate ids/addresses, unknown operations, and control names", () => { + const high = structure(); + high.owner.threshold = 3; + expect(() => validatePermissionStructure(high)).toThrowError(/threshold exceeds/); + + const duplicateAddress = structure(); + duplicateAddress.owner.keys[1]!.address = A; + expect(() => validatePermissionStructure(duplicateAddress)).toThrowError(/duplicate address/); + + const duplicateId = structure(); + duplicateId.actives.push({ ...duplicateId.actives[0]!, name: "second" }); + expect(() => validatePermissionStructure(duplicateId)).toThrowError(/ids must be unique/); + + const unknown = structure(); + unknown.actives[0]!.operations = ["FutureContract"]; + expect(() => validatePermissionStructure(unknown)).toThrowError(/unknown TRON contract/); + + const control = structure(); + control.actives[0]!.name = "safe\u001b[31m"; + expect(() => validatePermissionStructure(control)).toThrowError(/control characters/); + }); + + it("matches the Java protocol guard that witness permission has exactly one key", () => { + const input = structure(); + input.witness = { + id: 1, + threshold: 1, + keys: [{ address: A, weight: 1 }, { address: B, weight: 1 }], + } as never; + expect(() => validatePermissionStructure(input)).toThrowError(/exactly 1 key/); + }); + + it("rejects integers outside the exact TronWeb range and another account's export", () => { + const input = structure() as unknown as { owner: { threshold: unknown } }; + input.owner.threshold = "9007199254740992"; + expect(() => validatePermissionStructure(input)).toThrowError(/precise integer range/); + expect(() => validatePermissionStructure(structure(), B)).toThrowError(/does not match/); + }); +}); + +describe("local key inventory and lockout warnings", () => { + const account = (type: AccountDescriptor["type"], address: string, label: string): AccountDescriptor => ({ + accountId: `wlt_${label}` as never, + label, + type, + index: null, + active: false, + addresses: { tron: address }, + }); + + it("excludes watch-only keys, de-duplicates addresses, and ignores forged labels", () => { + const inventory = buildLocalPermissionInventory([ + account("privateKey", A, "main"), + account("ledger", A, "duplicate"), + account("watch", B, "watch"), + ]); + expect([...inventory.entries()]).toEqual([[A, "main"]]); + const annotated = annotateLocalPermissionKeys(validatePermissionStructure(structure()), inventory); + expect(annotated.owner.keys.map((key) => key.local)).toEqual(["main", null]); + expect(permissionSafetyWarnings(annotated, inventory)).toEqual([ + expect.objectContaining({ code: "owner_lockout_partial" }), + ]); + }); + + it("reports complete owner lockout and active permission escalation", () => { + const input = structure(); + input.actives[0]!.operations.push("AccountPermissionUpdateContract"); + input.actives[0]!.operationsHex = encodeOperations(input.actives[0]!.operations); + const warnings = permissionSafetyWarnings(validatePermissionStructure(input), new Map([[C, "other"]])); + expect(warnings.map((warning) => warning.code)).toEqual([ + "owner_lockout", + "active_can_update_permission", + ]); + }); +}); diff --git a/ts/src/domain/types/index.ts b/ts/src/domain/types/index.ts index 8efa3534e..04204fbd1 100644 --- a/ts/src/domain/types/index.ts +++ b/ts/src/domain/types/index.ts @@ -27,4 +27,5 @@ export * from "./wallet.js"; export * from "./token.js"; export * from "./keystore.js"; export * from "./tx.js"; +export * from "./permission.js"; export * from "./primitives.js"; diff --git a/ts/src/domain/types/permission.ts b/ts/src/domain/types/permission.ts new file mode 100644 index 000000000..9980aa818 --- /dev/null +++ b/ts/src/domain/types/permission.ts @@ -0,0 +1,31 @@ +export interface WarningView { + code: string; + message: string; +} + +export interface PermissionKeyView { + address: string; + weight: number; + local: string | null; +} + +export interface PermissionGroupView { + id: number; + name: string; + threshold: number; + keys: PermissionKeyView[]; +} + +export interface ActivePermissionView extends PermissionGroupView { + operations: string[]; + operationLabels: string[]; + operationsHex: string; + unknownOperationIds: number[]; +} + +export interface AccountPermissionsView { + address: string; + owner: PermissionGroupView; + witness: PermissionGroupView | null; + actives: ActivePermissionView[]; +} diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index 5dcabaaa0..914245054 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -20,6 +20,26 @@ export interface TypedDataSignature { primaryType: string; } +/** Lossless JSON projection of one complete TRON protocol.Transaction artifact. */ +export interface TronTransactionArtifact { + visible?: boolean; + txID: string; + raw_data: { + contract: Array<{ + type: string; + Permission_id?: number; + parameter?: { value?: Record; type_url?: string }; + [key: string]: unknown; + }>; + expiration?: number; + timestamp?: number; + [key: string]: unknown; + }; + raw_data_hex: string; + signature?: string[]; + [key: string]: unknown; +} + export interface BroadcastResult { txId?: string; hash?: string; @@ -32,8 +52,9 @@ export type BroadcastStage = "submitted" | "confirmed" | "failed"; export type TxOutcome = | { stage: "plan"; tx: UnsignedTx; fee: FeeReport } + | { stage: "built"; tx: UnsignedTx; hex: string; fee: FeeReport } // `fee` is absent when the caller supplied the transaction (tx sign): nothing was estimated. - | { stage: "signed"; signed: SignedTx; fee?: FeeReport; address?: string; txId?: string } + | { stage: "signed"; signed: SignedTx; hex?: string; fee?: FeeReport; address?: string; txId?: string } | ({ stage: BroadcastStage } & BroadcastResult); // ════════════════════ per-command typed text outputs ══════════════════════ @@ -67,7 +88,7 @@ export type TxReceiptKind = | "send" | "broadcast" | "sign" | "stake-freeze" | "stake-unfreeze" | "stake-delegate" | "stake-undelegate" | "stake-withdraw" | "stake-cancel" | "contract-send" | "contract-deploy" - | "vote-cast" | "reward-withdraw"; + | "vote-cast" | "reward-withdraw" | "permission-update"; /** * Canonical tx receipt the signing commands return (dry-run / sign-only / broadcast stages). @@ -77,7 +98,7 @@ export type TxReceiptKind = */ export interface TxReceiptView { kind: TxReceiptKind; - mode?: "dry-run" | "sign-only"; + mode?: "dry-run" | "build-only" | "sign-only"; stage?: BroadcastStage; txId?: string; hash?: string; @@ -87,6 +108,7 @@ export interface TxReceiptView { fee?: FeeReport; tx?: UnsignedTx; signed?: SignedTx; + hex?: string; // transfer / stake inputs rawAmount?: string; amountSun?: string | number; From d12c6f7d20b34934fe615f00db1d017d785aaffa Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 12:17:33 +0800 Subject: [PATCH 02/24] feat(ts): add local multisig signing workflow --- .../adapters/inbound/cli/commands/shared.ts | 9 +- .../cli/commands/text-formatters.test.ts | 53 ++++++ .../inbound/cli/commands/tx.sign.test.ts | 87 ++++++++- ts/src/adapters/inbound/cli/commands/tx.ts | 123 +++++++++++-- ts/src/adapters/inbound/cli/help/index.ts | 4 +- ts/src/adapters/inbound/cli/render/index.ts | 2 + .../adapters/inbound/cli/render/multisig.ts | 57 ++++++ ts/src/adapters/inbound/cli/render/tx.ts | 6 +- ts/src/adapters/outbound/config/builtins.ts | 1 + .../transaction-artifact-writer.test.ts | 30 ++++ .../transaction-artifact-writer.ts | 60 +++++++ .../use-cases/tron/contract-service.ts | 17 +- .../use-cases/tron/multisig-authorization.ts | 11 ++ .../use-cases/tron/multisig-service.test.ts | 168 ++++++++++++++++++ .../use-cases/tron/multisig-service.ts | 145 +++++++++++++++ .../use-cases/tron/reward-service.ts | 11 +- .../use-cases/tron/stake-service.ts | 12 +- .../use-cases/tron/transaction-service.ts | 11 +- .../use-cases/tron/vote-service.ts | 11 +- ts/src/bootstrap/families/tron.ts | 14 +- ts/src/domain/permission/index.ts | 4 + ts/src/domain/types/index.ts | 1 + ts/src/domain/types/multisig.ts | 35 ++++ ts/src/domain/types/tx.ts | 2 + 24 files changed, 834 insertions(+), 40 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/render/multisig.ts create mode 100644 ts/src/adapters/outbound/persistence/transaction-artifact-writer.test.ts create mode 100644 ts/src/adapters/outbound/persistence/transaction-artifact-writer.ts create mode 100644 ts/src/application/use-cases/tron/multisig-service.test.ts create mode 100644 ts/src/application/use-cases/tron/multisig-service.ts create mode 100644 ts/src/domain/types/multisig.ts diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts index 366969683..e856fb49d 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -10,10 +10,13 @@ import { TextFormatters } from "../render/index.js"; import type { MessageService } from "../../../../application/use-cases/message-service.js"; // ── execution-mode flags shared by every signing command ───────────────────────── -/** dry-run / sign-only fields; default (no flag) = sign AND broadcast on-chain. */ +/** Transaction execution fields; default (no mode flag) = sign and broadcast on-chain. */ export const txModeFields = { - dryRun: z.boolean().default(false).describe("build and estimate only, with no signature and no broadcast; mutually exclusive with --sign-only"), - signOnly: z.boolean().default(false).describe("sign and output the transaction without broadcasting; mutually exclusive with --dry-run; broadcast later with tx broadcast"), + dryRun: z.boolean().default(false).describe("build and estimate only, with no signature and no broadcast"), + signOnly: z.boolean().default(false).describe("sign and output complete transaction hex without broadcasting"), + buildOnly: z.boolean().default(false).describe("build and output unsigned complete transaction hex without unlocking"), + permissionId: z.number().int().min(0).max(9).default(0).describe("TRON permission group id used to authorize this transaction"), + expiration: z.number().int().min(1).max(86_400_000).optional().describe("expiration duration in milliseconds; only with --sign-only or --build-only"), }; // ── unified --amount / --raw-amount selector (shared by every chain's `tx send`) ──── // A transfer of 0 is meaningless on any chain — reject it here (exit 2) rather than let the node 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 558b1e284..5278aa137 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -198,6 +198,59 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); }); +describe("local multisig formatters", () => { + const approval = { + txId: "abc123", + contractType: "TransferContract", + operation: "TransferContract", + from: "Towner", + to: "Trecipient", + rawAmount: "1000000", + permission: { id: 2, name: "operations", threshold: 2 }, + currentWeight: 1, + missingWeight: 1, + thresholdReached: false, + approved: [{ address: "Tsigner", weight: 1 }], + expiration: Date.now() + 60_000, + expired: false, + signatures: 1, + }; + + it("shows permission progress and approved signer weight", () => { + const out = TextFormatters.txApprovals(approval) as string; + expect(out).toContain('active "operations" (id 2)'); + expect(out).toContain("Progress 1 / 2"); + expect(out).toContain("1 more weight needed"); + expect(out).toContain("Tsigner"); + }); + + it("shows the next broadcast command only after threshold is reached", () => { + const pending = TextFormatters.txSign({ + kind: "tx-sign", + signer: "Tsigner", + signerWeight: 1, + hex: "aabb", + transaction: approval, + }) as string; + expect(pending).not.toContain("wallet-cli tx broadcast"); + + const ready = TextFormatters.txSign({ + kind: "tx-sign", + signer: "Tsigner2", + signerWeight: 1, + hex: "ccdd", + out: "signed.hex", + transaction: { + ...approval, + currentWeight: 2, + missingWeight: 0, + thresholdReached: true, + }, + }) as string; + expect(ready).toContain("wallet-cli tx broadcast --file signed.hex"); + }); +}); + describe("txStatus formatter (family-agnostic; command supplies `state`)", () => { it("tron: confirmed when not failed", () => { const out = TextFormatters.txStatus({ txid: "abc", state: "confirmed", confirmed: true, failed: false, blockNumber: 123 }); diff --git a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts index abf4b125e..9e3c7be4d 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { txSignSpec, txSignTronBinding } from "./tx.js"; +import { + txBroadcastSpec, + txBroadcastTronBinding, + txSignSpec, + txSignTronBinding, +} from "./tx.js"; const ctx = { activeAccount: "main" } as never; const net = { family: "tron", id: "nile" } as never; @@ -11,9 +16,10 @@ describe("tx sign spec", () => { expect(txSignSpec.auth).toBe("required"); }); - it("requires a --transaction payload", () => { - expect(txSignSpec.baseFields.safeParse({}).success).toBe(false); + it("accepts the retained JSON input and the new hex/file inputs", () => { expect(txSignSpec.baseFields.safeParse({ transaction: "{}" }).success).toBe(true); + expect(txSignSpec.baseFields.safeParse({ hex: "abcd" }).success).toBe(true); + expect(txSignSpec.baseFields.safeParse({ file: "tx.hex" }).success).toBe(true); }); // the payload is not a secret, so argv is the only channel — no --tx-stdin on this command. @@ -31,13 +37,84 @@ describe("tx sign binding", () => { return { kind: "sign" }; }, }; - await txSignTronBinding(svc as never).run(ctx, net, { transaction: '{"txID":"abc"}' }); + await txSignTronBinding(svc as never, {} as never, {} as never) + .run(ctx, net, { transaction: '{"txID":"abc"}' }); expect(received).toEqual({ txID: "abc" }); }); it("rejects malformed JSON with invalid_value", async () => { const svc = { sign: async () => ({}) }; - await expect(txSignTronBinding(svc as never).run(ctx, net, { transaction: "not json" })) + await expect(txSignTronBinding(svc as never, {} as never, {} as never) + .run(ctx, net, { transaction: "not json" })) .rejects.toMatchObject({ code: "invalid_value" }); }); + + it("routes hex co-signing through the multisig service and writes --out", async () => { + const multisig = { + sign: async () => ({ kind: "tx-sign", hex: "beef", signer: "T1", signerWeight: 1, transaction: {} }), + }; + let written: unknown; + const writer = { write: (path: string, hex: string) => { written = { path, hex }; } }; + const result = await txSignTronBinding({} as never, multisig as never, writer as never) + .run(ctx, net, { hex: "abcd", out: "signed.hex" }); + expect(written).toEqual({ path: "signed.hex", hex: "beef" }); + expect(result).toMatchObject({ out: "signed.hex", hex: "beef" }); + }); +}); + +describe("tx broadcast binding", () => { + const broadcastContext = (stdin?: string, wait = false) => ({ + wait, + secrets: { + has: (kind: string) => kind === "tx" && stdin !== undefined, + pick: (inline: string | undefined) => inline ?? stdin, + }, + }) as never; + + it("retains JSON/stdin inputs and adds hex/file inputs", () => { + expect(txBroadcastSpec.baseFields.safeParse({ transaction: "{}" }).success).toBe(true); + expect(txBroadcastSpec.baseFields.safeParse({ hex: "abcd" }).success).toBe(true); + expect(txBroadcastSpec.baseFields.safeParse({ file: "tx.hex" }).success).toBe(true); + }); + + it("routes protobuf hex without parsing it as JSON", async () => { + const service = { + broadcastHex: async (_ctx: unknown, _net: unknown, hex: string, dryRun: boolean) => ({ + hex, + dryRun, + }), + broadcastJson: async () => { + throw new Error("unexpected JSON route"); + }, + }; + await expect(txBroadcastTronBinding(service as never) + .run(broadcastContext(), net, { hex: "aabb", dryRun: true })) + .resolves.toEqual({ hex: "aabb", dryRun: true }); + }); + + it("routes the retained --tx-stdin JSON source", async () => { + let received: unknown; + const service = { + broadcastHex: async () => { + throw new Error("unexpected hex route"); + }, + broadcastJson: async (_ctx: unknown, _net: unknown, transaction: unknown) => { + received = transaction; + return { txId: "abc" }; + }, + }; + await txBroadcastTronBinding(service as never) + .run(broadcastContext('{"txID":"abc"}'), net, { dryRun: false }); + expect(received).toEqual({ txID: "abc" }); + }); + + it("rejects ambiguous input and --wait with --dry-run", async () => { + const service = { broadcastHex: async () => ({}), broadcastJson: async () => ({}) }; + await expect(txBroadcastTronBinding(service as never) + .run(broadcastContext(), net, { transaction: "{}", hex: "aabb", dryRun: false })) + .rejects.toMatchObject({ code: "invalid_option" }); + await expect(txBroadcastTronBinding(service as never) + .run(broadcastContext(undefined, true), net, { hex: "aabb", dryRun: true })) + .rejects.toMatchObject({ code: "invalid_option" }); + }); }); diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index 0a401a267..1875eac6d 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -2,6 +2,8 @@ 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 type { TronMultisigService } from "../../../../application/use-cases/tron/multisig-service.js"; +import type { TransactionArtifactWriter } from "../../../outbound/persistence/transaction-artifact-writer.js"; import { Schemas } from "../schemas/index.js"; import { amountSelector, @@ -9,6 +11,7 @@ import { unifiedAmountFields, } from "./shared.js"; import { TextFormatters } from "../render/index.js"; +import { exactlyOne, readBoundedTextFile } from "./artifact.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). @@ -53,7 +56,11 @@ export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => const broadcastFields = z.object({ transaction: z.string().optional() - .describe("signed TRON transaction JSON; provide this OR --tx-stdin; exactly one is required"), + .describe("signed TRON transaction JSON; mutually exclusive with --tx-stdin/--hex/--file"), + hex: z.string().min(2).optional().describe("complete signed protocol.Transaction hex"), + file: z.string().min(1).optional().describe("file containing complete signed protocol.Transaction hex"), + dryRun: z.boolean().default(false) + .describe("validate signatures, threshold, expiration, and dynamic multi-sign fee without broadcasting"), }); export const txBroadcastSpec: ChainSpec = { @@ -62,17 +69,41 @@ export const txBroadcastSpec: ChainSpec = { network: "optional", wallet: "none", auth: "none", broadcasts: true, capability: "tx.broadcast", - summary: "Broadcast a presigned transaction", + summary: "Validate and broadcast a presigned JSON or protobuf-hex transaction", baseFields: broadcastFields, - examples: [{ cmd: "wallet-cli tx broadcast --tx-stdin < signed.json" }], + baseRefine: (input, context) => { + if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length > 1) { + context.addIssue({ + code: "custom", + path: ["transaction"], + message: "--transaction, --hex, and --file are mutually exclusive", + }); + } + }, + examples: [ + { cmd: "wallet-cli tx broadcast --tx-stdin < signed.json" }, + { cmd: "wallet-cli tx broadcast --file signed.hex" }, + ], formatText: TextFormatters.txReceipt, }; -export const txBroadcastTronBinding = (svc: TronTransactionService): FamilyBinding => ({ +export const txBroadcastTronBinding = (service: TronMultisigService): FamilyBinding => ({ run: async (ctx, net, input) => { + if (input.dryRun && ctx.wait) { + throw new UsageError("invalid_option", "--wait cannot be used with --dry-run"); + } + const stdin = ctx.secrets.has("tx"); + exactlyOne( + [input.transaction, stdin ? true : undefined, input.hex, input.file], + "provide exactly one of --transaction, --tx-stdin, --hex, or --file", + ); + if (input.hex || input.file) { + const hex = input.hex ?? readBoundedTextFile(input.file, 1024 * 1024 + 4096, "transaction hex file"); + return service.broadcastHex(ctx, net, hex, input.dryRun); + } const raw = ctx.secrets.pick(input.transaction, "tx", "transaction"); try { - return svc.broadcast(ctx, net, JSON.parse(raw)); + return service.broadcastJson(ctx, net, JSON.parse(raw), input.dryRun); } catch (error) { if (error instanceof SyntaxError) { throw new UsageError("invalid_value", "TRON presigned tx must be JSON"); @@ -82,38 +113,87 @@ export const txBroadcastTronBinding = (svc: TronTransactionService): FamilyBindi }, }); +const artifactFields = { + hex: z.string().min(2).optional().describe("complete protocol.Transaction hex"), + file: z.string().min(1).optional().describe("file containing complete protocol.Transaction hex"), +}; + +const approvalsFields = z.object(artifactFields); + +export const txApprovalsSpec: ChainSpec = { + path: ["tx", "approvals"], + network: "optional", wallet: "none", auth: "none", + capability: "tx.multisig.local", + summary: "Show permission, signature approvals, current weight, and expiration", + description: "Inspect the transaction, selected permission group, approved signers, accumulated weight, missing weight, and expiration without signing.", + baseFields: approvalsFields, + baseRefine: hexOrFileRefine, + examples: [{ cmd: "wallet-cli tx approvals --file partially-signed.hex" }], + formatText: TextFormatters.txApprovals, +}; + +export const txApprovalsTronBinding = (service: TronMultisigService): FamilyBinding => ({ + run: async (_ctx, network, input) => service.approvals(network, hexInput(input)), +}); + const signFields = z.object({ - transaction: z.string().min(1) - .describe("unsigned TRON transaction JSON, as built (raw_data, raw_data_hex and txID must agree)"), + transaction: z.string().min(1).optional() + .describe("unsigned TRON transaction JSON; retained for direct single-signature compatibility"), + ...artifactFields, + out: z.string().min(1).optional().describe("atomically write co-signed transaction hex to this file"), }); export const txSignSpec: ChainSpec = { path: ["tx", "sign"], network: "optional", wallet: "optional", auth: "required", broadcasts: false, - capability: "tx.sign", - summary: "Sign a transaction built elsewhere, without broadcasting", + capability: "tx.multisig.local", + summary: "Sign transaction JSON or append a signature to transaction hex", description: - "Sign a transaction built outside this CLI and output the signed result; broadcast it later\n" + - "with `tx broadcast`. Signs any well-formed transaction without inspecting its contents, but\n" + - "rejects a payload whose txID does not match its raw_data — the signature always covers the\n" + - "exact bytes you passed.", + "With --transaction, preserve the direct JSON signing flow. With --hex/--file, validate the\n" + + "selected permission, append exactly one signature, preserve prior signatures, and report\n" + + "the new approval weight. This command never broadcasts.", baseFields: signFields, + baseRefine: (input, context) => { + if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length !== 1) { + context.addIssue({ + code: "custom", + path: ["transaction"], + message: "provide exactly one of --transaction, --hex, or --file", + }); + } + if (input.out && input.transaction) { + context.addIssue({ code: "custom", path: ["out"], message: "--out is only valid with --hex or --file" }); + } + }, examples: [ { cmd: `wallet-cli tx sign --transaction '{"txID":"...","raw_data":{...},"raw_data_hex":"..."}'` }, + { cmd: "wallet-cli tx sign --file partially-signed.hex --out signed.hex --password-stdin" }, ], - formatText: TextFormatters.txReceipt, + formatText: TextFormatters.txSign, }; -export const txSignTronBinding = (svc: TronTransactionService): FamilyBinding => ({ +export const txSignTronBinding = ( + transactionService: TronTransactionService, + multisigService: TronMultisigService, + writer: TransactionArtifactWriter, +): FamilyBinding => ({ run: async (ctx, net, input) => { + exactlyOne([input.transaction, input.hex, input.file], "provide exactly one of --transaction, --hex, or --file"); + if (!input.transaction) { + const result = await multisigService.sign(ctx, net, hexInput(input)); + if (!input.out) return result; + writer.write(input.out, result.hex); + return { ...result, out: input.out }; + } + if (input.out) throw new UsageError("invalid_option", "--out is only valid with --hex or --file"); let tx: unknown; try { tx = JSON.parse(input.transaction); } catch { throw new UsageError("invalid_value", "TRON transaction must be JSON"); } - return svc.sign(ctx, net, tx); + return transactionService.sign(ctx, net, tx); }, }); @@ -161,3 +241,14 @@ function tokenOptional( }); } } + +function hexOrFileRefine(value: { hex?: string; file?: string }, context: z.RefinementCtx): void { + if ([value.hex, value.file].filter((entry) => entry !== undefined).length !== 1) { + context.addIssue({ code: "custom", path: ["hex"], message: "provide exactly one of --hex or --file" }); + } +} + +function hexInput(input: { hex?: string; file?: string }): string { + exactlyOne([input.hex, input.file], "provide exactly one of --hex or --file"); + return input.hex ?? readBoundedTextFile(input.file!, 1024 * 1024 + 4096, "transaction hex file"); +} diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index aa0cc49b9..6bff3ff9e 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -100,7 +100,7 @@ export class HelpService { const management = [ ["account", "Query on-chain account state", ""], ["token", "Manage the token address book and query tokens", ""], - ["tx", "Build, send, broadcast, and inspect transactions", ""], + ["tx", "Build, send, broadcast, co-sign, and inspect transactions", ""], ["contract", "Call, send, deploy, and inspect smart contracts", ""], ["stake", "Stake / delegate resources & query state", "tron"], ["vote", "Vote for super representatives", "tron"], @@ -432,7 +432,7 @@ const GROUP_DESCRIPTIONS: Record = { import: "Import a wallet from an existing secret or device.", account: "Query on-chain account state.", token: "Manage the token address book and query tokens.", - tx: "Build, send, broadcast, and inspect transactions.", + tx: "Build, send, broadcast, co-sign, 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).\nVoting accrues rewards — query and claim them with 'wallet-cli reward'.", diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index a1fc98db2..d907b9241 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -23,6 +23,7 @@ import { RewardFormatters } from "./reward.js" import { ChainFormatters } from "./chain.js" import { MiscFormatters } from "./misc.js" import { PermissionFormatters } from "./permission.js" +import { MultisigFormatters } from "./multisig.js" export { FAMILY_RENDER, renderFamily } from "./family.js" @@ -36,6 +37,7 @@ export const TextFormatters = { ...ChainFormatters, ...MiscFormatters, ...PermissionFormatters, + ...MultisigFormatters, } export function renderGenericText(command: string, net: NetworkDescriptor | undefined, data: unknown): string { diff --git a/ts/src/adapters/inbound/cli/render/multisig.ts b/ts/src/adapters/inbound/cli/render/multisig.ts new file mode 100644 index 000000000..3c4ad0119 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/multisig.ts @@ -0,0 +1,57 @@ +import type { TxApprovalView, TxSignView } from "../../../../domain/types/index.js"; +import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; +import { formatAtWithRelative, formatInt, formatSun } from "./scalars.js"; +import { ok, query, receipt, table } from "./layout.js"; +import { TxFormatters } from "./tx.js"; + +function transactionType(value: TxApprovalView): string { + const label = value.operation ? `${value.operation} (${value.contractType})` : value.contractType; + if (!value.rawAmount) return label; + return value.contractType === "TransferContract" + ? `${label} — ${formatSun(value.rawAmount)} TRX` + : `${label} — ${value.rawAmount} base units`; +} + +function renderApproval(value: TxApprovalView): string { + const permissionKind = value.permission.id === 0 ? "owner" : value.permission.id === 1 ? "witness" : "active"; + const expires = `${formatAtWithRelative(value.expiration)}${value.expired ? " [EXPIRED]" : ""}`; + const transaction = query([ + ["TxID", value.txId], + ["Type", transactionType(value)], + ["From", value.from ?? ""], + ["To", value.to ?? ""], + ["Permission", `${permissionKind} "${value.permission.name}" (id ${value.permission.id}) threshold ${formatInt(value.permission.threshold)}`], + ["Expires", expires], + ]); + const progress = value.thresholdReached + ? `Progress ${formatInt(value.currentWeight)} / ${formatInt(value.permission.threshold)} — threshold reached` + : `Progress ${formatInt(value.currentWeight)} / ${formatInt(value.permission.threshold)} — ${formatInt(value.missingWeight)} more weight needed`; + const approved = value.approved.length === 0 + ? "No approved signers." + : table( + ["Approved signer", "Weight"], + value.approved.map((signer) => [signer.address, formatInt(signer.weight)]), + ); + const expired = value.expired ? "\n! Transaction expired; build a new transaction before collecting signatures." : ""; + return `Transaction\n${transaction.split("\n").map((line) => ` ${line}`).join("\n")}\n\n${progress}\n${approved}${expired}`; +} + +function renderSign(value: TxSignView): string { + const artifact = value.out ? `written to ${value.out}` : value.hex; + const action = receipt(ok(), "Signature added", [ + ["Signer", `${value.signer} (weight ${formatInt(value.signerWeight)})`], + ["Hex", artifact], + ]); + const next = value.transaction.thresholdReached + ? `\n! Broadcast it: wallet-cli tx broadcast ${value.out ? `--file ${value.out}` : "--hex "}` + : ""; + return `${action}\n\n${renderApproval(value.transaction)}${next}`; +} + +export const MultisigFormatters = { + txApprovals: ((value) => renderApproval(value)) satisfies TextFormatter, + txSign: ((value: TxSignView | any, ctx?: TextRenderContext) => + value.kind === "tx-sign" + ? renderSign(value) + : TxFormatters.txReceipt(value, ctx)) satisfies TextFormatter, +}; diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index 2ff30600a..562311b18 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -34,7 +34,9 @@ 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)], + ["Fee", r.multiSignFeeSun === undefined + ? formatFee(r.fee, family) + : `${formatSun(r.multiSignFeeSun)} TRX multi-sign fee`], ["Tx", summarizeTx(r.tx)], ]) } @@ -45,6 +47,7 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { ]) } if (r.mode === "sign-only") { + if (r.hex) return r.hex // kv() drops empty rows, so a fee-less signature (tx sign estimates nothing) omits the Fee line. return receipt(ok(), `Signed ${actionLabel(r.kind)}`, [ ["Address", r.address ?? ""], @@ -149,6 +152,7 @@ function receiptSummary(r: TxReceiptView, family: ChainFamily): string { /** action-specific extra rows (To/From/Address/Contract), by kind. */ function receiptRows(r: TxReceiptView): Pair[] { const rows: Pair[] = [] + if (r.multiSignFeeSun) rows.push(["Multi-sign fee", `${formatSun(r.multiSignFeeSun)} TRX`]) 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 ?? "")]) diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index 0825fc335..0b2832090 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -21,6 +21,7 @@ export const CAP_SUMMARIES: Record = { "token.tokenbook": "token address-book (add/list/remove)", "tx.send": "transfer native / token", "tx.broadcast": "broadcast a presigned transaction", + "tx.multisig.local": "inspect and append local multi-sign approvals", "message.sign": "sign a message", "contract.call": "constant + state-changing contract calls", "contract.deploy": "deploy a smart contract", diff --git a/ts/src/adapters/outbound/persistence/transaction-artifact-writer.test.ts b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.test.ts new file mode 100644 index 000000000..412022f33 --- /dev/null +++ b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { lstatSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { TransactionArtifactWriter } from "./transaction-artifact-writer.js"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("TransactionArtifactWriter", () => { + it("atomically writes a newline-terminated 0644 artifact", () => { + const dir = mkdtempSync(join(tmpdir(), "wallet-cli-tx-")); + dirs.push(dir); + const path = join(dir, "signed.hex"); + new TransactionArtifactWriter().write(path, "abcd"); + expect(readFileSync(path, "utf8")).toBe("abcd\n"); + if (process.platform !== "win32") expect(lstatSync(path).mode & 0o777).toBe(0o644); + }); + + it("refuses to replace a symbolic link", () => { + const dir = mkdtempSync(join(tmpdir(), "wallet-cli-tx-")); + dirs.push(dir); + const target = join(dir, "target"); + const link = join(dir, "signed.hex"); + symlinkSync(target, link); + expect(() => new TransactionArtifactWriter().write(link, "abcd")).toThrowError(/symbolic-link/); + }); +}); diff --git a/ts/src/adapters/outbound/persistence/transaction-artifact-writer.ts b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.ts new file mode 100644 index 000000000..e484253dc --- /dev/null +++ b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.ts @@ -0,0 +1,60 @@ +import { + closeSync, + constants, + fchmodSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { randomBytes } from "node:crypto"; +import { dirname } from "node:path"; +import { ExecutionError } from "../../../domain/errors/index.js"; + +/** Atomic, non-secret transaction artifact writer. Published files are mode 0644. */ +export class TransactionArtifactWriter { + write(path: string, hex: string): void { + if (!path) throw new ExecutionError("io_error", "transaction artifact path is empty"); + try { + const current = lstatSync(path, { throwIfNoEntry: false }); + if (current?.isSymbolicLink()) { + throw new ExecutionError("io_error", "refusing to replace a symbolic-link transaction artifact"); + } + const dir = dirname(path); + mkdirSync(dir, { recursive: true }); + const tmp = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; + let fd: number | undefined; + try { + fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644); + fchmodSync(fd, 0o644); + writeFileSync(fd, `${hex}\n`, "utf8"); + fsyncSync(fd); + closeSync(fd); + fd = undefined; + renameSync(tmp, path); + if (process.platform !== "win32") { + const dirFd = openSync(dir, constants.O_RDONLY); + try { + fsyncSync(dirFd); + } finally { + closeSync(dirFd); + } + } + } catch (error) { + if (fd !== undefined) closeSync(fd); + try { + unlinkSync(tmp); + } catch { + // best-effort cleanup + } + throw error; + } + } catch (error) { + if (error instanceof ExecutionError) throw error; + throw new ExecutionError("io_error", `could not write transaction artifact: ${(error as Error).message}`); + } + } +} diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index 8472b4aad..87be1a895 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -3,9 +3,15 @@ import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { TronContractParameter } 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 { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; import { tronHexToBase58 } from "../../../domain/address/index.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; export class TronContractService { constructor( @@ -38,7 +44,7 @@ export class TronContractService { feeLimit: string; }, ) { - this.pipeline.assertCanSign(scope.activeAccount, "tron"); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); const outcome = await this.pipeline.run({ ctx: scope, @@ -46,6 +52,7 @@ export class TronContractService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), confirm: tronConfirmation(gateway, scope), build: (from) => gateway.triggerSmartContract( from, @@ -80,7 +87,9 @@ export class TronContractService { }, ) { // Ledger TRON app firmware cannot sign a CreateSmartContract tx — reject before any device I/O. - this.pipeline.assertCanSign(scope.activeAccount, "tron", { requireSoftware: true }); + if (transactionRequiresSigner(input)) { + 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({ @@ -89,6 +98,8 @@ export class TronContractService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), + signerOptions: { requireSoftware: true }, confirm: tronConfirmation(gateway, scope), build: async (from) => { const tx = await gateway.deployContract(from, input); diff --git a/ts/src/application/use-cases/tron/multisig-authorization.ts b/ts/src/application/use-cases/tron/multisig-authorization.ts index 5e74063f2..daadf34f8 100644 --- a/ts/src/application/use-cases/tron/multisig-authorization.ts +++ b/ts/src/application/use-cases/tron/multisig-authorization.ts @@ -124,3 +124,14 @@ export async function assertTronSignerAuthorized( throw new ChainError("already_signed", "selected signer has already approved this transaction"); } } + +/** Shared TRON transaction hooks for permission binding, protobuf artifacts, and signer preflight. */ +export function tronTransactionHooks(gateway: TronGateway) { + return { + prepare: (transaction: UnsignedTx, options: { permissionId: number; expiration?: number }) => + gateway.prepareTransaction(transaction, options), + artifact: (transaction: UnsignedTx) => gateway.encodeTransactionHex(transaction), + preflight: (transaction: UnsignedTx, signerAddress: string) => + assertTronSignerAuthorized(gateway, transaction, signerAddress), + }; +} diff --git a/ts/src/application/use-cases/tron/multisig-service.test.ts b/ts/src/application/use-cases/tron/multisig-service.test.ts new file mode 100644 index 000000000..4c648893f --- /dev/null +++ b/ts/src/application/use-cases/tron/multisig-service.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Signer } from "../../../domain/types/index.js"; +import { encodeOperations } from "../../../domain/permission/index.js"; +import type { TronGateway, TronSignWeight } from "../../ports/chain/tron-gateway.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { SignerResolver } from "../../services/signer/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import { + decodeTransactionHex, + encodeTransactionHex, +} from "../../../adapters/outbound/chain/tron/transaction-codec.js"; +import { TronMultisigService } from "./multisig-service.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const OWNER_HEX = "417445076632894b7b844887d2bcd2e8c30bb6c6f2"; +const TO_HEX = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"; +const SIG_A = "ab".repeat(65); +const SIG_B = "cd".repeat(65); +const NOW = 1_900_000_000_000; + +function unsignedHex(expiration = NOW + 60_000): string { + return encodeTransactionHex({ + visible: false, + raw_data: { + contract: [{ + parameter: { + value: { owner_address: OWNER_HEX, to_address: TO_HEX, amount: 1 }, + type_url: "type.googleapis.com/protocol.TransferContract", + }, + type: "TransferContract", + Permission_id: 2, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: NOW, + expiration, + }, + }); +} + +function fakeGateway(overrides: Partial = {}): TronGateway { + const approvals = (transaction: unknown) => { + const count = (transaction as { signature?: string[] }).signature?.length ?? 0; + return count === 0 ? [] : count === 1 ? [A] : [A, B]; + }; + return { + decodeTransactionHex, + encodeTransactionHex, + decodeTransaction: () => ({ kind: "trx", from: A, to: B, rawAmount: "1" }), + getSignWeight: vi.fn(async (transaction): Promise => ({ + permission: { + id: 2, + name: "active", + threshold: 2, + operationsHex: encodeOperations(["TransferContract"]), + keys: [{ address: A, weight: 1 }, { address: B, weight: 1 }], + }, + approvedList: approvals(transaction), + currentWeight: approvals(transaction).length, + resultCode: approvals(transaction).length >= 2 ? "ENOUGH_PERMISSION" : "NOT_ENOUGH_PERMISSION", + })), + getApprovedList: vi.fn(async (transaction) => approvals(transaction)), + getMultiSignFee: vi.fn(async () => 1_000_000), + broadcastHex: vi.fn(async () => ({ txId: "txid" })), + getTransactionInfoById: vi.fn(async () => ({})), + ...overrides, + } as unknown as TronGateway; +} + +function scope(): TransactionScope { + return { + activeAccount: "local" as never, + timeoutMs: 100, + wait: false, + waitTimeoutMs: 100, + resolveAddress: () => A, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function service(gateway: TronGateway, signer?: Signer) { + const actualSigner: Signer = signer ?? { + kind: "software", + address: A, + sign: vi.fn(async (transaction) => { + const mutable = transaction as { signature?: string[] }; + mutable.signature = [...(mutable.signature ?? []), SIG_A]; + return transaction; + }), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + const signers = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => actualSigner), + } as unknown as SignerResolver; + const provider = { get: () => gateway } as unknown as ChainGatewayProvider; + return new TronMultisigService(provider, signers, () => NOW); +} + +const NETWORK = { id: "tron:nile", family: "tron" } as never; + +describe("local TRON multi-signature workflow", () => { + it("reports structured permission and missing weight for an unsigned transaction", async () => { + const view = await service(fakeGateway()).approvals(NETWORK, unsignedHex()); + expect(view).toMatchObject({ + permission: { id: 2, name: "active", threshold: 2 }, + currentWeight: 0, + missingWeight: 2, + thresholdReached: false, + expired: false, + contractType: "TransferContract", + approved: [], + }); + }); + + it("appends exactly one signature while preserving txID/raw_data and verifies its weight", async () => { + const before = decodeTransactionHex(unsignedHex()); + const signed = await service(fakeGateway()).sign(scope(), NETWORK, unsignedHex()); + const after = decodeTransactionHex(signed.hex); + expect(after.txID).toBe(before.txID); + expect(after.raw_data_hex).toBe(before.raw_data_hex); + expect(after.signature).toEqual([SIG_A]); + expect(signed).toMatchObject({ + signer: A, + signerWeight: 1, + transaction: { currentWeight: 1, missingWeight: 1 }, + }); + }); + + it("rejects expiration before calling a signer", async () => { + const signer: Signer = { + kind: "software", + address: A, + sign: vi.fn(async (transaction) => transaction), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + await expect(service(fakeGateway(), signer).sign(scope(), NETWORK, unsignedHex(NOW))) + .rejects.toMatchObject({ code: "tx_expired" }); + expect(signer.sign).not.toHaveBeenCalled(); + }); + + it("requires threshold before broadcast and reports the dynamic multi-sign fee", async () => { + const gateway = fakeGateway(); + await expect(service(gateway).broadcastHex(scope(), NETWORK, unsignedHex(), false)) + .rejects.toMatchObject({ code: "not_authorized" }); + + const multiSigned = encodeTransactionHex({ + ...decodeTransactionHex(unsignedHex()), + signature: [SIG_A, SIG_B], + }); + const dry = await service(gateway).broadcastHex(scope(), NETWORK, multiSigned, true); + expect(dry).toMatchObject({ multiSignFeeSun: 1_000_000, mode: "dry-run" }); + expect(gateway.broadcastHex).not.toHaveBeenCalled(); + + await service(gateway).broadcastHex(scope(), NETWORK, multiSigned, false); + expect(gateway.broadcastHex).toHaveBeenCalledWith(multiSigned); + }); + + it("fails closed when the two node approval endpoints disagree", async () => { + const gateway = fakeGateway({ getApprovedList: vi.fn(async () => [B]) }); + await expect(service(gateway).approvals(NETWORK, unsignedHex())) + .rejects.toMatchObject({ code: "provider_error" }); + }); +}); diff --git a/ts/src/application/use-cases/tron/multisig-service.ts b/ts/src/application/use-cases/tron/multisig-service.ts new file mode 100644 index 000000000..b22a11b1d --- /dev/null +++ b/ts/src/application/use-cases/tron/multisig-service.ts @@ -0,0 +1,145 @@ +import type { + NetworkDescriptor, + Signer, + TronTransactionArtifact, + TxApprovalView, +} from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { operationForContractType } from "../../../domain/permission/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 { SignerResolver } from "../../services/signer/index.js"; +import { obtainSignature } from "../../services/signing/obtain-signature.js"; +import { stageTronBroadcast } from "../../services/tron-confirmation.js"; +import { + assertNotExpired, + assertTronSignerAuthorized, + authorizationState, + expirationOf, + transactionContract, +} from "./multisig-authorization.js"; + +export class TronMultisigService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly signers: SignerResolver, + private readonly now: () => number = () => Date.now(), + ) {} + + async approvals(network: NetworkDescriptor, hex: string): Promise { + const gateway = this.gateways.get(network, "tron"); + return this.#approvalFor(gateway, gateway.decodeTransactionHex(hex)); + } + + async sign(scope: TransactionScope, network: NetworkDescriptor, hex: string) { + const gateway = this.gateways.get(network, "tron"); + const transaction = gateway.decodeTransactionHex(hex); + assertNotExpired(transaction, this.now()); + const originalTxId = transaction.txID; + const originalRawDataHex = transaction.raw_data_hex; + const previousSignatures = [...(transaction.signature ?? [])]; + + this.signers.assertCanSign(scope.activeAccount, "tron"); + const signer = this.signers.resolve(scope.activeAccount, "tron"); + await assertTronSignerAuthorized(gateway, transaction, signer.address, this.now()); + + const signed = await this.#sign(signer, transaction, scope); + const signedHex = gateway.encodeTransactionHex(signed); + const decoded = gateway.decodeTransactionHex(signedHex); + if (decoded.txID !== originalTxId || decoded.raw_data_hex !== originalRawDataHex) { + throw new ChainError("invalid_transaction", "signer changed the transaction raw_data or txID"); + } + const current = decoded.signature ?? []; + if (current.length !== previousSignatures.length + 1 + || previousSignatures.some((signature, index) => current[index] !== signature)) { + throw new ChainError("signing_rejected", "signer did not append exactly one signature while preserving prior approvals"); + } + + const transactionView = await this.#approvalFor(gateway, decoded); + const approvedSigner = transactionView.approved.find((approved) => approved.address === signer.address); + if (!approvedSigner) { + throw new ChainError("signing_rejected", "node did not recognize the newly appended signature"); + } + assertNotExpired(decoded, this.now()); + return { + kind: "tx-sign" as const, + signer: signer.address, + signerWeight: approvedSigner.weight, + hex: signedHex, + transaction: transactionView, + }; + } + + async broadcastHex(scope: TransactionScope, network: NetworkDescriptor, hex: string, dryRun: boolean) { + const gateway = this.gateways.get(network, "tron"); + const transaction = gateway.decodeTransactionHex(hex); + const approval = await this.#assertBroadcastable(gateway, transaction); + const multiSignFeeSun = approval.signatures > 1 ? await gateway.getMultiSignFee() : 0; + if (dryRun) { + return { kind: "broadcast" as const, mode: "dry-run" as const, transaction: approval, multiSignFeeSun }; + } + const result = await gateway.broadcastHex(hex); + return { + kind: "broadcast" as const, + ...(await stageTronBroadcast(gateway, scope, result)), + transaction: approval, + multiSignFeeSun, + }; + } + + async broadcastJson( + scope: TransactionScope, + network: NetworkDescriptor, + transaction: TronTransactionArtifact, + dryRun: boolean, + ) { + const gateway = this.gateways.get(network, "tron"); + return this.broadcastHex(scope, network, gateway.encodeTransactionHex(transaction), dryRun); + } + + async #assertBroadcastable(gateway: TronGateway, transaction: TronTransactionArtifact) { + assertNotExpired(transaction, this.now()); + const approval = await this.#approvalFor(gateway, transaction); + if (!approval.thresholdReached) { + throw new ChainError( + "not_authorized", + `signature threshold is not reached; missing ${approval.missingWeight} weight`, + ); + } + return approval; + } + + async #approvalFor(gateway: TronGateway, transaction: TronTransactionArtifact): Promise { + const { weight, approved, permission } = await authorizationState(gateway, transaction); + const expiration = expirationOf(transaction); + const contractType = transactionContract(transaction).type; + const decoded = gateway.decodeTransaction(transaction); + const keyWeights = new Map(permission.keys.map((key) => [key.address, key.weight])); + return { + txId: transaction.txID, + contractType, + operation: operationForContractType(contractType)?.label, + from: decoded.from, + to: decoded.to, + rawAmount: decoded.rawAmount, + tokenContract: decoded.tokenContract, + permission: { + id: permission.id, + name: permission.name, + threshold: permission.threshold, + }, + currentWeight: weight.currentWeight, + missingWeight: Math.max(0, permission.threshold - weight.currentWeight), + thresholdReached: weight.currentWeight >= permission.threshold, + approved: approved.map((address) => ({ address, weight: keyWeights.get(address)! })), + expiration, + expired: expiration <= this.now(), + signatures: transaction.signature?.length ?? 0, + }; + } + + #sign(signer: Signer, transaction: TronTransactionArtifact, scope: TransactionScope) { + return obtainSignature(signer, scope, (options) => signer.sign(transaction, options)); + } +} diff --git a/ts/src/application/use-cases/tron/reward-service.ts b/ts/src/application/use-cases/tron/reward-service.ts index 58228d591..1d49dbe4e 100644 --- a/ts/src/application/use-cases/tron/reward-service.ts +++ b/ts/src/application/use-cases/tron/reward-service.ts @@ -4,8 +4,14 @@ 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 { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; const WITHDRAW_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -31,7 +37,7 @@ export class TronRewardService { } async withdraw(scope: TransactionScope, network: NetworkDescriptor, input: TransactionModeInput) { - this.pipeline.assertCanSign(scope.activeAccount, "tron"); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); const address = scope.resolveAddress("tron"); const [rewardSun, account] = await Promise.all([ @@ -51,6 +57,7 @@ export class TronRewardService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), confirm: tronConfirmation(gateway, scope), build: (owner) => gateway.buildWithdrawBalance(owner), estimate: async () => ({ feeModel: "tron-resource", note: "reward withdrawal uses bandwidth only" }), diff --git a/ts/src/application/use-cases/tron/stake-service.ts b/ts/src/application/use-cases/tron/stake-service.ts index 4203e66f7..aa6febe11 100644 --- a/ts/src/application/use-cases/tron/stake-service.ts +++ b/ts/src/application/use-cases/tron/stake-service.ts @@ -5,8 +5,14 @@ import type { AccountScope, TransactionScope } from "../../contracts/execution-s 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 { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; export interface StakeAmountInput extends TransactionModeInput { amountSun: string; @@ -203,7 +209,7 @@ export class TronStakeService { build: (gateway: TronGateway, owner: string) => Promise, opts?: { requireSoftware?: boolean }, ) { - this.pipeline.assertCanSign(scope.activeAccount, "tron", opts); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron", opts); const gateway = this.gateways.get(network, "tron"); const outcome = await this.pipeline.run({ ctx: scope, @@ -211,6 +217,8 @@ export class TronStakeService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), + signerOptions: opts, confirm: tronConfirmation(gateway, scope), build: (owner) => build(gateway, owner), estimate: async () => ({ feeModel: "tron-resource", note: "staking ops cost bandwidth" }), diff --git a/ts/src/application/use-cases/tron/transaction-service.ts b/ts/src/application/use-cases/tron/transaction-service.ts index 615050d62..0cf62c444 100644 --- a/ts/src/application/use-cases/tron/transaction-service.ts +++ b/ts/src/application/use-cases/tron/transaction-service.ts @@ -6,8 +6,14 @@ import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js import type { DecodedTronTransaction, TronGateway, TronTxInfo } from "../../ports/chain/tron-gateway.js"; import type { TokenRepository } from "../../ports/token-repository.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; -import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; import { stageTronBroadcast, tronConfirmation } from "../../services/tron-confirmation.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; export interface TronSendInput extends TransactionModeInput { to: string; @@ -27,7 +33,7 @@ export class TronTransactionService { ) {} async send(scope: TransactionScope, network: NetworkDescriptor, input: TronSendInput) { - this.pipeline.assertCanSign(scope.activeAccount, "tron"); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); const resolved = await this.resolveTransfer( gateway, @@ -41,6 +47,7 @@ export class TronTransactionService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), confirm: tronConfirmation(gateway, scope), build: (from) => resolved.contract ? gateway.buildTrc20Transfer(from, input.to, resolved.contract, resolved.rawAmount, input.feeLimit) diff --git a/ts/src/application/use-cases/tron/vote-service.ts b/ts/src/application/use-cases/tron/vote-service.ts index eaf5af069..4eea133de 100644 --- a/ts/src/application/use-cases/tron/vote-service.ts +++ b/ts/src/application/use-cases/tron/vote-service.ts @@ -12,9 +12,15 @@ import type { 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 { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; import { TronStakeService } from "./stake-service.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; const MAX_VOTE_ITEMS = 30; const MAX_REWARDED_RANK = 127; @@ -70,7 +76,7 @@ export class TronVoteService { ) {} async cast(scope: TransactionScope, network: NetworkDescriptor, input: VoteCastInput) { - this.pipeline.assertCanSign(scope.activeAccount, "tron"); + if (transactionRequiresSigner(input)) 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); @@ -91,6 +97,7 @@ export class TronVoteService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), + ...tronTransactionHooks(gateway), confirm: tronConfirmation(gateway, scope), build: (ownerAddress) => gateway.buildVoteWitness(ownerAddress, votes), estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "voting uses bandwidth only" }), diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index cfd1628eb..cda2b4b7c 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -36,6 +36,8 @@ import { import { txBroadcastSpec, txBroadcastTronBinding, + txApprovalsSpec, + txApprovalsTronBinding, txInfoSpec, txInfoTronBinding, txSendSpec, @@ -84,12 +86,14 @@ import { TronBlockService } from "../../application/use-cases/tron/block-service import { MessageService } from "../../application/use-cases/message-service.js"; import { TypedDataService } from "../../application/use-cases/typed-data-service.js"; import { TronPermissionService } from "../../application/use-cases/tron/permission-service.js"; +import { TronMultisigService } from "../../application/use-cases/tron/multisig-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 { AccountStore } from "../../application/ports/account-store.js"; +import { TransactionArtifactWriter } from "../../adapters/outbound/persistence/transaction-artifact-writer.js"; import type { FamilyPlugin } from "./types.js"; export const tronFamily: FamilyPlugin<"tron"> = { @@ -119,6 +123,7 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC const message = new MessageService(deps.signers); const typedData = new TypedDataService(deps.signers); const transaction = new TronTransactionService(deps.gateways, deps.tokens, deps.transactions); + const multisig = new TronMultisigService(deps.gateways, deps.signers); const permission = new TronPermissionService(deps.gateways, deps.accounts, deps.transactions); const stake = new TronStakeService(deps.gateways, deps.transactions); const vote = new TronVoteService(deps.gateways, deps.transactions, stake); @@ -139,8 +144,13 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC reg.addChain(messageSignSpec, "tron", messageSignBinding(message)); reg.addChain(typedDataSignSpec, "tron", typedDataSignBinding(typedData)); reg.addChain(txSendSpec, "tron", txSendTronBinding(transaction)); - reg.addChain(txSignSpec, "tron", txSignTronBinding(transaction)); - reg.addChain(txBroadcastSpec, "tron", txBroadcastTronBinding(transaction)); + reg.addChain(txSignSpec, "tron", txSignTronBinding( + transaction, + multisig, + new TransactionArtifactWriter(), + )); + reg.addChain(txApprovalsSpec, "tron", txApprovalsTronBinding(multisig)); + reg.addChain(txBroadcastSpec, "tron", txBroadcastTronBinding(multisig)); reg.addChain(txStatusSpec, "tron", txStatusTronBinding(transaction)); reg.addChain(txInfoSpec, "tron", txInfoTronBinding(transaction)); reg.addChain(permissionShowSpec, "tron", permissionShowTronBinding(permission)); diff --git a/ts/src/domain/permission/index.ts b/ts/src/domain/permission/index.ts index d2a33a803..4d2c908f2 100644 --- a/ts/src/domain/permission/index.ts +++ b/ts/src/domain/permission/index.ts @@ -325,3 +325,7 @@ export function permissionSafetyWarnings( } return warnings; } + +export function operationForContractType(contractType: string): TronOperation | undefined { + return operationByType.get(contractType); +} diff --git a/ts/src/domain/types/index.ts b/ts/src/domain/types/index.ts index 04204fbd1..5dc9a3cfa 100644 --- a/ts/src/domain/types/index.ts +++ b/ts/src/domain/types/index.ts @@ -28,4 +28,5 @@ export * from "./token.js"; export * from "./keystore.js"; export * from "./tx.js"; export * from "./permission.js"; +export * from "./multisig.js"; export * from "./primitives.js"; diff --git a/ts/src/domain/types/multisig.ts b/ts/src/domain/types/multisig.ts new file mode 100644 index 000000000..37b398573 --- /dev/null +++ b/ts/src/domain/types/multisig.ts @@ -0,0 +1,35 @@ +export interface ApprovedSignerView { + address: string; + weight: number; +} + +export interface TxApprovalView { + txId: string; + contractType: string; + operation?: string; + from?: string; + to?: string; + rawAmount?: string; + tokenContract?: string; + permission: { + id: number; + name: string; + threshold: number; + }; + currentWeight: number; + missingWeight: number; + thresholdReached: boolean; + approved: ApprovedSignerView[]; + expiration: number; + expired: boolean; + signatures: number; +} + +export interface TxSignView { + kind: "tx-sign"; + signer: string; + signerWeight: number; + hex: string; + out?: string; + transaction: TxApprovalView; +} diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index 914245054..5585cb6cc 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -109,6 +109,8 @@ export interface TxReceiptView { tx?: UnsignedTx; signed?: SignedTx; hex?: string; + transaction?: import("./multisig.js").TxApprovalView; + multiSignFeeSun?: number; // transfer / stake inputs rawAmount?: string; amountSun?: string | number; From e84edec080dae93f125c7a378cf0f674299bc1d1 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 12:31:46 +0800 Subject: [PATCH 03/24] feat(ts): add TronLink multisig collaboration --- ts/package-lock.json | 18 +- ts/package.json | 4 +- .../inbound/cli/commands/tx.sign.test.ts | 16 + ts/src/adapters/inbound/cli/commands/tx.ts | 89 ++++ .../adapters/inbound/cli/render/multisig.ts | 59 ++- ts/src/adapters/outbound/config/builtins.ts | 4 + .../adapters/outbound/config/config.test.ts | 30 +- ts/src/adapters/outbound/config/index.ts | 43 +- .../adapters/outbound/tronlink/auth.test.ts | 30 ++ ts/src/adapters/outbound/tronlink/auth.ts | 64 +++ .../adapters/outbound/tronlink/client.test.ts | 147 +++++++ ts/src/adapters/outbound/tronlink/client.ts | 366 +++++++++++++++ .../ports/tronlink-collaboration.ts | 59 +++ .../use-cases/config-service.test.ts | 37 ++ .../application/use-cases/config-service.ts | 41 +- .../tron/tronlink-multisig-service.test.ts | 213 +++++++++ .../tron/tronlink-multisig-service.ts | 415 ++++++++++++++++++ ts/src/bootstrap/composition.ts | 12 +- ts/src/bootstrap/families/tron.ts | 7 + ts/src/domain/types/multisig.ts | 70 +++ ts/src/domain/types/network.ts | 6 + 21 files changed, 1713 insertions(+), 17 deletions(-) create mode 100644 ts/src/adapters/outbound/tronlink/auth.test.ts create mode 100644 ts/src/adapters/outbound/tronlink/auth.ts create mode 100644 ts/src/adapters/outbound/tronlink/client.test.ts create mode 100644 ts/src/adapters/outbound/tronlink/client.ts create mode 100644 ts/src/application/ports/tronlink-collaboration.ts create mode 100644 ts/src/application/use-cases/tron/tronlink-multisig-service.test.ts create mode 100644 ts/src/application/use-cases/tron/tronlink-multisig-service.ts diff --git a/ts/package-lock.json b/ts/package-lock.json index 79864b8c5..1a4433c3d 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -21,6 +21,7 @@ "axios": "^1.18.1", "lossless-json": "^4.3.0", "tronweb": "6.4.0", + "ws": "8.21.1", "yaml": "^2.9.0", "yargs": "^18.0.0", "zod": "^4.4.3" @@ -30,6 +31,7 @@ }, "devDependencies": { "@types/node": "^25.9.3", + "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", "dependency-cruiser": "^17.4.3", "tsup": "^8.5.1", @@ -1495,6 +1497,16 @@ "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -4591,9 +4603,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/ts/package.json b/ts/package.json index 1511cde28..a521961fc 100644 --- a/ts/package.json +++ b/ts/package.json @@ -62,17 +62,19 @@ "axios": "^1.18.1", "lossless-json": "^4.3.0", "tronweb": "6.4.0", + "ws": "8.21.1", "yaml": "^2.9.0", "yargs": "^18.0.0", "zod": "^4.4.3" }, "overrides": { "axios": "^1.18.1", - "ws": "^8.18.3", + "ws": "8.21.1", "esbuild": "^0.28.1" }, "devDependencies": { "@types/node": "^25.9.3", + "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", "dependency-cruiser": "^17.4.3", "tsup": "^8.5.1", diff --git a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts index 9e3c7be4d..6ec74c2b9 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts @@ -4,6 +4,7 @@ import { txBroadcastTronBinding, txSignSpec, txSignTronBinding, + txTronLinkMultisigSpec, } from "./tx.js"; const ctx = { activeAccount: "main" } as never; @@ -118,3 +119,18 @@ describe("tx broadcast binding", () => { .rejects.toMatchObject({ code: "invalid_option" }); }); }); + +describe("tx multisig spec", () => { + it("supports list, unsigned create, sign, and WebSocket watch modes", () => { + expect(txTronLinkMultisigSpec.baseFields.safeParse({}).success).toBe(true); + expect(txTronLinkMultisigSpec.baseFields.safeParse({ create: true, hex: "aabb" }).success).toBe(true); + expect(txTronLinkMultisigSpec.baseFields.safeParse({ sign: "ab".repeat(32) }).success).toBe(true); + expect(txTronLinkMultisigSpec.baseFields.safeParse({ watch: true }).success).toBe(true); + }); + + it("declares no broadcast and uses lazy signing authentication", () => { + expect(txTronLinkMultisigSpec.broadcasts).toBeFalsy(); + expect(txTronLinkMultisigSpec.auth).toBe("required"); + expect(txTronLinkMultisigSpec.passwordMode).toBeUndefined(); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index 1875eac6d..218dcd04b 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -3,6 +3,7 @@ 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 type { TronMultisigService } from "../../../../application/use-cases/tron/multisig-service.js"; +import type { TronLinkMultisigService } from "../../../../application/use-cases/tron/tronlink-multisig-service.js"; import type { TransactionArtifactWriter } from "../../../outbound/persistence/transaction-artifact-writer.js"; import { Schemas } from "../schemas/index.js"; import { @@ -197,6 +198,65 @@ export const txSignTronBinding = ( }, }); +const tronLinkMultisigFields = z.object({ + create: z.boolean().default(false) + .describe("upload one unsigned transaction and open a TronLink signature collection"), + hex: z.string().min(2).optional().describe("unsigned protocol.Transaction hex used with --create"), + file: z.string().min(1).optional().describe("file containing unsigned transaction hex used with --create"), + sign: z.string().regex(/^(?:0x)?[0-9a-fA-F]{64}$/).optional() + .describe("fetch and co-sign one pending TronLink transaction by txId"), + watch: z.boolean().default(false) + .describe("keep a WebSocket open and report only the count of transactions awaiting this account"), +}); + +export const txTronLinkMultisigSpec: ChainSpec = { + path: ["tx", "multisig"], + network: "optional", wallet: "optional", auth: "required", + capability: "tx.multisig.tronlink", + summary: "Coordinate multi-signature collection through the TronLink service", + description: + "With no mode flag, list service-managed transactions for the selected account. --create\n" + + "uploads an UNSIGNED transaction; --sign fetches the accumulated transaction, signs locally,\n" + + "and submits it; --watch opens the official WebSocket count-only notification channel.", + requires: [ + "TronLink service credentials — config tronlinkSecretId / tronlinkSecretKey / tronlinkChannel", + ], + baseFields: tronLinkMultisigFields, + baseRefine: tronLinkMultisigRefine, + examples: [ + { cmd: "wallet-cli tx multisig" }, + { cmd: "wallet-cli tx multisig --create --file tx.unsigned.hex" }, + { cmd: "wallet-cli tx multisig --sign 9c1... --password-stdin" }, + { cmd: "wallet-cli tx multisig --watch" }, + ], + formatText: TextFormatters.txTronLinkMultisig, +}; + +export const txTronLinkMultisigBinding = (service: TronLinkMultisigService): FamilyBinding => ({ + run: async (ctx, network, input) => { + const address = ctx.resolveAddress("tron"); + if (input.create) return service.create(network, address, hexInput(input)); + if (input.sign) return service.sign(ctx, network, input.sign); + if (!input.watch) return service.list(network, address); + + const controller = new AbortController(); + const stop = () => controller.abort(); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + ctx.streams.event(`Watching TronLink multi-sig service for ${network.id} … (Ctrl-C to stop)`); + try { + return await service.watch(network, address, controller.signal, (count) => { + ctx.streams.event( + `🔔 You have ${count} transaction(s) to sign — view them with: wallet-cli tx multisig`, + ); + }); + } finally { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + } + }, +}); + const statusFields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") }); export const txStatusSpec: ChainSpec = { @@ -252,3 +312,32 @@ function hexInput(input: { hex?: string; file?: string }): string { exactlyOne([input.hex, input.file], "provide exactly one of --hex or --file"); return input.hex ?? readBoundedTextFile(input.file!, 1024 * 1024 + 4096, "transaction hex file"); } + +function tronLinkMultisigRefine( + value: z.infer, + context: z.RefinementCtx, +): void { + const modes = [value.create, value.sign !== undefined, value.watch].filter(Boolean).length; + if (modes > 1) { + context.addIssue({ + code: "custom", + path: ["create"], + message: "--create, --sign, and --watch are mutually exclusive", + }); + } + const artifacts = [value.hex, value.file].filter((entry) => entry !== undefined).length; + if (value.create && artifacts !== 1) { + context.addIssue({ + code: "custom", + path: ["hex"], + message: "--create requires exactly one of --hex or --file", + }); + } + if (!value.create && artifacts !== 0) { + context.addIssue({ + code: "custom", + path: ["hex"], + message: "--hex/--file are only valid with --create", + }); + } +} diff --git a/ts/src/adapters/inbound/cli/render/multisig.ts b/ts/src/adapters/inbound/cli/render/multisig.ts index 3c4ad0119..6e9eb8fcd 100644 --- a/ts/src/adapters/inbound/cli/render/multisig.ts +++ b/ts/src/adapters/inbound/cli/render/multisig.ts @@ -1,4 +1,8 @@ -import type { TxApprovalView, TxSignView } from "../../../../domain/types/index.js"; +import type { + TronLinkMultisigView, + TxApprovalView, + TxSignView, +} from "../../../../domain/types/index.js"; import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; import { formatAtWithRelative, formatInt, formatSun } from "./scalars.js"; import { ok, query, receipt, table } from "./layout.js"; @@ -12,7 +16,7 @@ function transactionType(value: TxApprovalView): string { : `${label} — ${value.rawAmount} base units`; } -function renderApproval(value: TxApprovalView): string { +export function renderApproval(value: TxApprovalView): string { const permissionKind = value.permission.id === 0 ? "owner" : value.permission.id === 1 ? "witness" : "active"; const expires = `${formatAtWithRelative(value.expiration)}${value.expired ? " [EXPIRED]" : ""}`; const transaction = query([ @@ -36,6 +40,55 @@ function renderApproval(value: TxApprovalView): string { return `Transaction\n${transaction.split("\n").map((line) => ` ${line}`).join("\n")}\n\n${progress}\n${approved}${expired}`; } +function renderTronLink(value: TronLinkMultisigView): string { + if ("transactions" in value) { + const heading = `Multi-sig transactions — TronLink service (${value.total} total)`; + if (value.transactions.length === 0) return `${heading}\nNo transactions found.`; + const rows = value.transactions.map((transaction) => { + const amount = transaction.rawAmount + ? transaction.contractType === "TransferContract" + ? `${formatSun(transaction.rawAmount)} TRX` + : `${transaction.rawAmount} base units` + : ""; + const state = transaction.awaitingMySignature ? "awaiting you" : transaction.state; + return [ + transaction.txId, + transaction.contractType, + amount, + state, + `${formatInt(transaction.currentWeight)} / ${formatInt(transaction.permission.threshold)}`, + formatAtWithRelative(transaction.expiration), + ]; + }); + const hint = value.transactions.some((transaction) => transaction.awaitingMySignature) + ? "\n! Co-sign one with: wallet-cli tx multisig --sign " + : ""; + return `${heading}\n${table( + ["TxID", "Type", "Amount", "State", "Progress", "Expires"], + rows, + )}${hint}`; + } + if (value.action === "watch") { + return receipt(ok(), "Stopped watching TronLink multi-sig service", [ + ["Address", value.address], + ["Notifications", formatInt(value.notifications)], + ]); + } + if (value.action === "create") { + return `${receipt(ok(), "Created on TronLink multi-sig service", [ + ["TxID", value.transaction.txId], + ])}\n\n${renderApproval(value.transaction)}\n! Each signer signs it with: wallet-cli tx multisig --sign ${value.transaction.txId}`; + } + const signed = receipt(ok(), "Signed & submitted", [ + ["Signer", `${value.signer} (weight ${formatInt(value.signerWeight)})`], + ["Hex", value.hex], + ]); + const broadcast = value.transaction.thresholdReached + ? `\n! Broadcast it: wallet-cli tx broadcast --hex ${value.hex}` + : ""; + return `${signed}\n\n${renderApproval(value.transaction)}${broadcast}`; +} + function renderSign(value: TxSignView): string { const artifact = value.out ? `written to ${value.out}` : value.hex; const action = receipt(ok(), "Signature added", [ @@ -54,4 +107,6 @@ export const MultisigFormatters = { value.kind === "tx-sign" ? renderSign(value) : TxFormatters.txReceipt(value, ctx)) satisfies TextFormatter, + txTronLinkMultisig: ((value: TronLinkMultisigView) => + renderTronLink(value)) satisfies TextFormatter, }; diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index 0b2832090..00aa7266f 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -22,6 +22,7 @@ export const CAP_SUMMARIES: Record = { "tx.send": "transfer native / token", "tx.broadcast": "broadcast a presigned transaction", "tx.multisig.local": "inspect and append local multi-sign approvals", + "tx.multisig.tronlink": "coordinate multi-sign approvals through TronLink", "message.sign": "sign a message", "contract.call": "constant + state-changing contract calls", "contract.deploy": "deploy a smart contract", @@ -43,6 +44,7 @@ export const BUILTIN_NETWORKS: Record = { chainId: "mainnet", aliases: ["tron"], httpEndpoint: "https://api.trongrid.io", + tronlinkHttpEndpoint: "https://api.walletadapter.org", feeModel: "tron-resource", capabilities: [], }, @@ -52,6 +54,7 @@ export const BUILTIN_NETWORKS: Record = { chainId: "nile", aliases: ["nile"], httpEndpoint: "https://nile.trongrid.io", + tronlinkHttpEndpoint: "https://apinile.walletadapter.org", feeModel: "tron-resource", capabilities: [], }, @@ -61,6 +64,7 @@ export const BUILTIN_NETWORKS: Record = { chainId: "shasta", aliases: ["shasta"], httpEndpoint: "https://api.shasta.trongrid.io", + tronlinkHttpEndpoint: "https://apishasta.walletadapter.org", feeModel: "tron-resource", capabilities: [], }, diff --git a/ts/src/adapters/outbound/config/config.test.ts b/ts/src/adapters/outbound/config/config.test.ts index 3d967681d..6a6676ac7 100644 --- a/ts/src/adapters/outbound/config/config.test.ts +++ b/ts/src/adapters/outbound/config/config.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { ConfigLoader, NetworkRegistry } from "./index.js"; -function envWithConfig(yaml: string): NodeJS.ProcessEnv { +function envWithConfig(yaml: string, mode?: number): NodeJS.ProcessEnv { const root = mkdtempSync(join(tmpdir(), "wcli-config-")); - writeFileSync(join(root, "config.yaml"), yaml); + const path = join(root, "config.yaml"); + writeFileSync(path, yaml); + if (mode !== undefined) chmodSync(path, mode); return { ...process.env, WALLET_CLI_HOME: root }; } @@ -57,3 +59,25 @@ describe("NetworkRegistry.resolve case-insensitivity", () => { expect(() => registry().resolve("dogechain")).toThrow(/unknown network/); }); }); + +describe("ConfigLoader TronLink credentials", () => { + const credentials = [ + "tronlinkSecretId: TEST", + "tronlinkSecretKey: TESTTESTTEST", + "tronlinkChannel: test", + "", + ].join("\n"); + + it("loads credentials only from a private config file", () => { + expect(ConfigLoader.load(envWithConfig(credentials, 0o600))).toMatchObject({ + tronlinkSecretId: "TEST", + tronlinkSecretKey: "TESTTESTTEST", + tronlinkChannel: "test", + }); + }); + + it.runIf(process.platform !== "win32")("rejects credentials in a group/world-readable file", () => { + expect(() => ConfigLoader.load(envWithConfig(credentials, 0o644))) + .toThrow(/mode 0600/); + }); +}); diff --git a/ts/src/adapters/outbound/config/index.ts b/ts/src/adapters/outbound/config/index.ts index 09bd7a913..de21600fa 100644 --- a/ts/src/adapters/outbound/config/index.ts +++ b/ts/src/adapters/outbound/config/index.ts @@ -3,7 +3,7 @@ * build the network registry, and resolve canonical network ids. The descriptor stays pure data; * live RPC clients are owned by the chain gateway provider, not attached here. */ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; @@ -35,10 +35,16 @@ export class ConfigLoader { let timeoutMs = DEFAULT_CONFIG.timeoutMs; let waitTimeoutMs = DEFAULT_CONFIG.waitTimeoutMs; let price: Config["price"]; + let tronlinkSecretId: string | undefined; + let tronlinkSecretKey: string | undefined; + let tronlinkChannel: string | undefined; const path = ConfigLoader.configPath(env); if (existsSync(path)) { const raw = parseYaml(readFileSync(path, "utf8")) ?? {}; + if (typeof raw.tronlinkSecretKey === "string" && raw.tronlinkSecretKey !== "") { + assertSecretConfigPermissions(path); + } if (typeof raw.defaultNetwork === "string" && raw.defaultNetwork.trim() !== "") { defaultNetwork = raw.defaultNetwork; } @@ -53,13 +59,46 @@ export class ConfigLoader { price = { provider }; if (typeof p.baseUrl === "string" && p.baseUrl.trim() !== "") price.baseUrl = p.baseUrl; } + if (validCredential(raw.tronlinkSecretId)) tronlinkSecretId = raw.tronlinkSecretId; + if (validCredential(raw.tronlinkSecretKey)) tronlinkSecretKey = raw.tronlinkSecretKey; + if (validCredential(raw.tronlinkChannel)) tronlinkChannel = raw.tronlinkChannel; if (raw.networks && typeof raw.networks === "object") { for (const [id, d] of Object.entries(raw.networks as Record>)) { networks[id] = { ...(networks[id] ?? {}), ...d, id } as NetworkDescriptor; } } } - return { defaultNetwork, defaultOutput, timeoutMs, waitTimeoutMs, networks, price }; + return { + defaultNetwork, + defaultOutput, + timeoutMs, + waitTimeoutMs, + networks, + price, + tronlinkSecretId, + tronlinkSecretKey, + tronlinkChannel, + }; + } +} + +function validCredential(value: unknown): value is string { + return typeof value === "string" + && value.length > 0 + && value.length <= 256 + && !/[\u0000-\u001f\u007f]/.test(value); +} + +function assertSecretConfigPermissions(path: string): void { + if (process.platform === "win32") return; + if (lstatSync(path).isSymbolicLink()) { + throw new UsageError("insecure_config", "config.yaml containing service credentials must not be a symbolic link"); + } + if ((statSync(path).mode & 0o077) !== 0) { + throw new UsageError( + "insecure_config", + "config.yaml containing service credentials must have mode 0600; run chmod 600 on the file", + ); } } diff --git a/ts/src/adapters/outbound/tronlink/auth.test.ts b/ts/src/adapters/outbound/tronlink/auth.test.ts new file mode 100644 index 000000000..3d1d3c14c --- /dev/null +++ b/ts/src/adapters/outbound/tronlink/auth.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalTronLinkQuery, + encodeTronLinkQuery, + signTronLinkRequest, + tronLinkAuthParameters, +} from "./auth.js"; + +describe("official TronLink multi-sign HMAC protocol", () => { + it("matches the Java sorted-parameter signing fixture", () => { + const auth = tronLinkAuthParameters( + { secretId: "sid-fixture", secretKey: "key-fixture", channel: "wallet-cli" }, + () => 1_900_000_000_000, + () => "00000000-0000-4000-8000-000000000001", + ); + const parameters = { ...auth, address: "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7" }; + expect(canonicalTronLinkQuery(parameters)).toBe( + "address=TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7&channel=wallet-cli&secret_id=sid-fixture&sign_version=v1&ts=1900000000000&uuid=00000000-0000-4000-8000-000000000001", + ); + expect(signTronLinkRequest("get", "/multi/list", parameters, "key-fixture")).toEqual({ + canonical: "GET/multi/list?address=TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7&channel=wallet-cli&secret_id=sid-fixture&sign_version=v1&ts=1900000000000&uuid=00000000-0000-4000-8000-000000000001", + signature: "+Gs+Mg1+QpaPCd6/LjVDdvfcyCC7MOYpb+G1Z2QFqwU=", + }); + }); + + it("URL-encodes only after signing using java.net.URLEncoder rules", () => { + expect(encodeTronLinkQuery({ sign: "+/=" })).toBe("sign=%2B%2F%3D"); + expect(encodeTronLinkQuery({ value: "a b~c" })).toBe("value=a+b%7Ec"); + }); +}); diff --git a/ts/src/adapters/outbound/tronlink/auth.ts b/ts/src/adapters/outbound/tronlink/auth.ts new file mode 100644 index 000000000..cb67d2a9a --- /dev/null +++ b/ts/src/adapters/outbound/tronlink/auth.ts @@ -0,0 +1,64 @@ +import { createHmac } from "node:crypto"; +import { ChainError } from "../../../domain/errors/index.js"; + +export interface TronLinkCredentials { + secretId: string; + secretKey: string; + channel: string; +} + +export function canonicalTronLinkQuery(parameters: Readonly>): string { + const entries = Object.entries(parameters); + if (entries.length === 0) { + throw new ChainError("provider_error", "TronLink signature parameters are empty"); + } + return entries + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .map(([key, value]) => `${key}=${value}`) + .join("&"); +} + +/** Canonical form used by Java MultiSignService; values are signed before URL encoding. */ +export function signTronLinkRequest( + method: string, + path: string, + parameters: Readonly>, + secretKey: string, +): { canonical: string; signature: string } { + if (!secretKey) throw new ChainError("provider_error", "TronLink multi-sign secret key is not configured"); + if (!/^\/[a-z0-9/_-]+$/i.test(path)) { + throw new ChainError("provider_error", "invalid TronLink request path"); + } + const canonical = `${method.toUpperCase()}${path}?${canonicalTronLinkQuery(parameters)}`; + return { + canonical, + signature: createHmac("sha256", secretKey).update(canonical, "utf8").digest("base64"), + }; +} + +export function tronLinkAuthParameters( + credentials: TronLinkCredentials, + clock: () => number, + uuid: () => string, +): Record { + return { + sign_version: "v1", + channel: credentials.channel, + secret_id: credentials.secretId, + ts: String(clock()), + uuid: uuid(), + }; +} + +export function encodeTronLinkQuery(parameters: Readonly>): string { + return Object.entries(parameters) + .map(([key, value]) => `${javaUrlEncode(key)}=${javaUrlEncode(value)}`) + .join("&"); +} + +/** java.net.URLEncoder compatibility: form encoding uses '+' for spaces and escapes '~'. */ +function javaUrlEncode(value: string): string { + return encodeURIComponent(value) + .replace(/[!'()~]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`) + .replace(/%20/g, "+"); +} diff --git a/ts/src/adapters/outbound/tronlink/client.test.ts b/ts/src/adapters/outbound/tronlink/client.test.ts new file mode 100644 index 000000000..2231d0081 --- /dev/null +++ b/ts/src/adapters/outbound/tronlink/client.test.ts @@ -0,0 +1,147 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import type { Config, NetworkDescriptor, TronTransactionArtifact } from "../../../domain/types/index.js"; +import { signTronLinkRequest } from "./auth.js"; +import { TronLinkClient } from "./client.js"; + +const ADDRESS = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const UUID = "00000000-0000-4000-8000-000000000001"; +const NOW = 1_900_000_000_000; +const CONFIG = { + tronlinkSecretId: "sid", + tronlinkSecretKey: "super-secret", + tronlinkChannel: "wallet-cli", +} as Config; +const NETWORK = { + id: "tron:mainnet", + family: "tron", + chainId: "mainnet", + tronlinkHttpEndpoint: "https://api.walletadapter.org", +} as NetworkDescriptor; + +function response(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); +} + +class FakeSocket extends EventEmitter { + readyState = 0; + sent: string[] = []; + send(data: string) { this.sent.push(data); } + close(code = 1000) { + this.readyState = 3; + this.emit("close", code); + } + terminate() { + this.readyState = 3; + this.emit("close", 1006); + } + open() { + this.readyState = 1; + this.emit("open"); + } +} + +describe("TronLink REST/WebSocket collaboration adapter", () => { + it("matches Java GET signing while leaving filters outside the signed set", async () => { + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => + response({ code: 0, data: { range_total: 0, data: [] } })); + const client = new TronLinkClient(CONFIG, 1_000, fetchMock as typeof fetch, () => NOW, () => UUID); + await client.list(NETWORK, ADDRESS, { state: 0, isSigned: false, start: 20, limit: 10 }); + + const calledUrl = new URL(String(fetchMock.mock.calls[0]![0])); + expect(calledUrl.pathname).toBe("/multi/list"); + expect(calledUrl.searchParams.get("state")).toBe("0"); + expect(calledUrl.searchParams.get("is_sign")).toBe("false"); + expect(calledUrl.searchParams.get("sign")).toBe( + signTronLinkRequest("GET", "/multi/list", { + sign_version: "v1", + channel: "wallet-cli", + secret_id: "sid", + ts: String(NOW), + uuid: UUID, + address: ADDRESS, + }, "super-secret").signature, + ); + }); + + it("uploads unsigned raw_data with the Java createTransaction query shape", async () => { + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => + response({ code: 0, data: {} })); + const client = new TronLinkClient(CONFIG, 1_000, fetchMock as typeof fetch, () => NOW, () => UUID); + await client.create(NETWORK, ADDRESS, { + permissionName: "finance", + txId: "ab".repeat(32), + rawDataJson: '{"contract":[]}', + contractType: "TransferContract", + }); + + const [rawUrl, init] = fetchMock.mock.calls[0]!; + const calledUrl = new URL(String(rawUrl)); + expect(calledUrl.searchParams.get("permission_name")).toBe("finance"); + expect(calledUrl.searchParams.get("tx_id")).toBe("ab".repeat(32)); + expect(JSON.parse(String(init?.body))).toEqual({ + raw_data: '{"contract":[]}', + extra: { type: "TransferContract" }, + }); + }); + + it("submits the complete accumulated transaction for a signing step", async () => { + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => + response({ code: 0, data: {} })); + const client = new TronLinkClient(CONFIG, 1_000, fetchMock as typeof fetch, () => NOW, () => UUID); + const transaction = { + visible: true, + txID: "ab".repeat(32), + raw_data: { contract: [] }, + raw_data_hex: "00", + } as TronTransactionArtifact; + await client.submit(NETWORK, ADDRESS, transaction); + expect(JSON.parse(String(fetchMock.mock.calls[0]![1]?.body))).toEqual({ address: ADDRESS, transaction }); + }); + + it("uses the official /multi/socket path and sends the v1 address subscription", async () => { + const socket = new FakeSocket(); + let connectedUrl: URL | undefined; + const factory = (url: URL) => { + connectedUrl = url; + queueMicrotask(() => socket.open()); + return socket as never; + }; + const controller = new AbortController(); + const messages: unknown[] = []; + const client = new TronLinkClient( + CONFIG, + 1_000, + globalThis.fetch, + () => NOW, + () => UUID, + factory, + ); + const watching = client.watch(NETWORK, ADDRESS, controller.signal, (message) => messages.push(message)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(connectedUrl?.pathname).toBe("/multi/socket"); + expect(connectedUrl?.searchParams.get("address")).toBe(ADDRESS); + expect(socket.sent).toEqual([JSON.stringify({ address: ADDRESS, version: "v1" })]); + + socket.emit("message", Buffer.from('[{"state":0,"is_sign":0}]')); + controller.abort(); + await watching; + expect(messages).toEqual([[{ state: 0, is_sign: 0 }]]); + }); + + it("requires complete credentials and rejects insecure endpoints before a request", async () => { + const fetchMock = vi.fn(); + await expect(new TronLinkClient({} as Config, 1_000, fetchMock as typeof fetch) + .list(NETWORK, ADDRESS, { state: 255, start: 0, limit: 20 })) + .rejects.toMatchObject({ code: "tronlink_credentials_missing" }); + + const insecure = { ...NETWORK, tronlinkHttpEndpoint: "http://api.walletadapter.org" }; + await expect(new TronLinkClient(CONFIG, 1_000, fetchMock as typeof fetch) + .list(insecure, ADDRESS, { state: 255, start: 0, limit: 20 })) + .rejects.toMatchObject({ code: "invalid_config" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/adapters/outbound/tronlink/client.ts b/ts/src/adapters/outbound/tronlink/client.ts new file mode 100644 index 000000000..33f4e3e00 --- /dev/null +++ b/ts/src/adapters/outbound/tronlink/client.ts @@ -0,0 +1,366 @@ +import { randomUUID } from "node:crypto"; +import WebSocket, { type ClientOptions, type RawData } from "ws"; +import { isLosslessNumber, parse as parseLosslessJson } from "lossless-json"; +import type { + TronLinkCollaborationPort, + TronLinkCreateRequest, + TronLinkListFilter, + TronLinkRemotePage, + TronLinkRemoteRecord, +} from "../../../application/ports/tronlink-collaboration.js"; +import type { + Config, + NetworkDescriptor, + TronTransactionArtifact, +} from "../../../domain/types/index.js"; +import { CliError, ChainError, TransportError, UsageError } from "../../../domain/errors/index.js"; +import { + encodeTronLinkQuery, + signTronLinkRequest, + tronLinkAuthParameters, + type TronLinkCredentials, +} from "./auth.js"; + +const MAX_RESPONSE_BYTES = 1024 * 1024; +const MAX_REQUEST_BYTES = 1024 * 1024; +const SOCKET_PATH = "/multi/socket"; + +type Fetch = typeof globalThis.fetch; +type SocketOptions = ClientOptions; + +interface SocketLike { + readonly readyState: number; + once(event: "open", listener: () => void): this; + once(event: "error", listener: (error: Error) => void): this; + once(event: "close", listener: (code: number) => void): this; + on(event: "message", listener: (data: RawData) => void): this; + send(data: string): void; + close(code?: number, reason?: string): void; + terminate(): void; +} + +type SocketFactory = (url: URL, options: SocketOptions) => SocketLike; + +/** Bounded Java-compatible adapter for walletadapter.org's multi-sign REST/WebSocket protocol. */ +export class TronLinkClient implements TronLinkCollaborationPort { + constructor( + private readonly config: Config, + private readonly timeoutMs: number, + private readonly fetchImpl: Fetch = globalThis.fetch, + private readonly clock: () => number = () => Date.now(), + private readonly uuid: () => string = randomUUID, + private readonly socketFactory: SocketFactory = (url, options) => new WebSocket(url, options), + ) {} + + async list( + network: NetworkDescriptor, + address: string, + filter: TronLinkListFilter, + ): Promise { + const credentials = this.#credentials(); + const path = "/multi/list"; + const auth = tronLinkAuthParameters(credentials, this.clock, this.uuid); + // Java signs only auth + address for GET; filter and pagination fields are unsigned by protocol. + const signed = { ...auth, address }; + const sign = signTronLinkRequest("GET", path, signed, credentials.secretKey).signature; + const parameters: Record = { + address, + start: String(filter.start), + limit: String(filter.limit), + state: String(filter.state), + ...(filter.isSigned === undefined ? {} : { is_sign: String(filter.isSigned) }), + ...auth, + sign, + }; + return parsePage(await this.#request(network, path, parameters), filter.limit); + } + + async create( + network: NetworkDescriptor, + address: string, + request: TronLinkCreateRequest, + ): Promise { + const credentials = this.#credentials(); + const path = "/multi/transaction"; + const auth = tronLinkAuthParameters(credentials, this.clock, this.uuid); + const signed = { + ...auth, + address, + permission_name: request.permissionName, + tx_id: request.txId, + }; + const sign = signTronLinkRequest("POST", path, signed, credentials.secretKey).signature; + await this.#request(network, path, { ...signed, sign }, { + raw_data: request.rawDataJson, + extra: { type: request.contractType }, + }); + } + + async submit( + network: NetworkDescriptor, + address: string, + transaction: TronTransactionArtifact, + ): Promise { + const credentials = this.#credentials(); + const path = "/multi/transaction"; + const auth = tronLinkAuthParameters(credentials, this.clock, this.uuid); + const signed = { ...auth, address }; + const sign = signTronLinkRequest("POST", path, signed, credentials.secretKey).signature; + await this.#request(network, path, { ...signed, sign }, { address, transaction }); + } + + async watch( + network: NetworkDescriptor, + address: string, + signal: AbortSignal, + onMessage: (payload: unknown) => void, + ): Promise { + const credentials = this.#credentials(); + const endpoint = tronLinkEndpoint(network); + const auth = tronLinkAuthParameters(credentials, this.clock, this.uuid); + const signed = { ...auth, address }; + const sign = signTronLinkRequest("GET", SOCKET_PATH, signed, credentials.secretKey).signature; + const socketUrl = new URL(`${endpoint}${SOCKET_PATH}`); + socketUrl.protocol = "wss:"; + socketUrl.search = encodeTronLinkQuery({ ...signed, sign }); + + await new Promise((resolve, reject) => { + let opened = false; + let finished = false; + const socket = this.socketFactory(socketUrl, { + handshakeTimeout: this.timeoutMs, + maxPayload: MAX_RESPONSE_BYTES, + perMessageDeflate: false, + followRedirects: false, + }); + + const finish = (error?: CliError) => { + if (finished) return; + finished = true; + signal.removeEventListener("abort", abort); + if (error) reject(error); + else resolve(); + }; + const abort = () => { + if (socket.readyState === WebSocket.OPEN) socket.close(1000, "client shutdown"); + else socket.terminate(); + finish(); + }; + + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) return abort(); + + socket.once("open", () => { + opened = true; + socket.send(JSON.stringify({ address, version: "v1" })); + }); + socket.on("message", (data: RawData) => { + const bytes = rawDataBytes(data); + if (bytes.byteLength > MAX_RESPONSE_BYTES) { + socket.close(1009, "message too large"); + return finish(new ChainError("provider_error", "TronLink WebSocket message exceeds 1 MiB")); + } + try { + onMessage(normalizeLossless(parseLosslessJson(bytes.toString("utf8")))); + } catch { + // The Java client ignores non-JSON frames. Keep the connection alive. + } + }); + socket.once("error", () => { + finish(new TransportError("provider_error", "TronLink WebSocket connection failed")); + }); + socket.once("close", (code) => { + if (signal.aborted || code === 1000) return finish(); + finish(new TransportError( + "provider_error", + opened + ? `TronLink WebSocket closed unexpectedly (code ${code})` + : "TronLink WebSocket handshake failed", + )); + }); + }); + } + + #credentials(): TronLinkCredentials { + const { tronlinkSecretId, tronlinkSecretKey, tronlinkChannel } = this.config; + if (!tronlinkSecretId || !tronlinkSecretKey || !tronlinkChannel) { + throw new UsageError( + "tronlink_credentials_missing", + "TronLink credentials are incomplete; configure tronlinkSecretId, tronlinkSecretKey, and tronlinkChannel", + ); + } + return { + secretId: tronlinkSecretId, + secretKey: tronlinkSecretKey, + channel: tronlinkChannel, + }; + } + + async #request( + network: NetworkDescriptor, + path: string, + parameters: Readonly>, + body?: unknown, + ): Promise { + const endpoint = tronLinkEndpoint(network); + const url = `${endpoint}${path}?${encodeTronLinkQuery(parameters)}`; + const encodedBody = body === undefined ? undefined : JSON.stringify(body); + if (encodedBody !== undefined && Buffer.byteLength(encodedBody, "utf8") > MAX_REQUEST_BYTES) { + throw new UsageError("invalid_value", "TronLink request body exceeds the 1 MiB limit"); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetchImpl(url, { + method: body === undefined ? "GET" : "POST", + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: encodedBody, + signal: controller.signal, + redirect: "error", + referrerPolicy: "no-referrer", + cache: "no-store", + }); + if (!response.ok) throw httpError(response); + const contentLength = response.headers.get("content-length"); + if (contentLength && /^\d+$/.test(contentLength) && Number(contentLength) > MAX_RESPONSE_BYTES) { + throw new ChainError("provider_error", "TronLink response exceeds the 1 MiB limit"); + } + const text = await readBoundedText(response, MAX_RESPONSE_BYTES); + let root: unknown; + try { + root = normalizeLossless(parseLosslessJson(text)); + } catch { + throw new ChainError("provider_error", "TronLink collaboration service returned malformed JSON"); + } + return unwrapBusinessResponse(root); + } catch (error) { + if (error instanceof CliError) throw error; + if (controller.signal.aborted) { + throw new ChainError("timeout", `TronLink request timed out after ${this.timeoutMs}ms`); + } + throw new TransportError("provider_error", "TronLink collaboration service request failed"); + } finally { + clearTimeout(timeout); + } + } +} + +function httpError(response: Response): CliError { + if (response.status === 404) { + return new ChainError("not_found", "TronLink collaboration resource was not found"); + } + if (response.status === 429) { + return new ChainError("provider_error", "TronLink collaboration service rate limit exceeded", { + status: 429, + }); + } + return new TransportError("provider_error", `TronLink collaboration service returned HTTP ${response.status}`, { + status: response.status, + }); +} + +function tronLinkEndpoint(network: NetworkDescriptor): string { + if (!network.tronlinkHttpEndpoint) { + throw new UsageError("unsupported_network", `network ${network.id} has no TronLink collaboration endpoint`); + } + let parsed: URL; + try { + parsed = new URL(network.tronlinkHttpEndpoint); + } catch { + throw new UsageError("invalid_config", `network ${network.id} has an invalid TronLink endpoint`); + } + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new UsageError("invalid_config", "TronLink endpoint must be HTTPS without credentials, query, or fragment"); + } + return parsed.toString().replace(/\/+$/, ""); +} + +async function readBoundedText(response: Response, maximum: number): Promise { + if (!response.body) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let text = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maximum) throw new ChainError("provider_error", "TronLink response exceeds the 1 MiB limit"); + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); + } finally { + reader.releaseLock(); + } +} + +function unwrapBusinessResponse(value: unknown): unknown { + const root = record(value, "response"); + const code = safeInteger(root.code, "code", 0); + if (code !== 0) { + throw new ChainError("provider_error", "TronLink collaboration service rejected the request", { code }); + } + return root.data; +} + +function parsePage(value: unknown, requestedLimit: number): TronLinkRemotePage { + const data = record(value, "data"); + const total = safeInteger(data.range_total, "data.range_total", 0); + if (!Array.isArray(data.data) || data.data.length > requestedLimit) { + throw new ChainError("provider_error", "TronLink response contains an invalid transaction page"); + } + const records = data.data.map((value, index) => { + const item = record(value, `data.data[${index}]`); + return { + hash: item.hash, + contract_type: item.contract_type, + state: item.state, + is_sign: item.is_sign, + current_weight: item.current_weight, + threshold: item.threshold, + contract_data: item.contract_data, + originator_address: item.originator_address, + current_transaction: item.current_transaction, + signature_progress: item.signature_progress, + } satisfies TronLinkRemoteRecord; + }); + return { total, records }; +} + +function record(value: unknown, field: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ChainError("provider_error", `TronLink ${field} must be an object`); + } + return value as Record; +} + +function safeInteger(value: unknown, field: string, minimum: number): number { + if (typeof value === "number" && Number.isSafeInteger(value) && value >= minimum) return value; + if (typeof value === "string" && /^\d+$/.test(value)) { + const parsed = Number(value); + if (Number.isSafeInteger(parsed) && parsed >= minimum) return parsed; + } + throw new ChainError("provider_error", `TronLink ${field} must be a safe integer`); +} + +function normalizeLossless(value: unknown): unknown { + if (isLosslessNumber(value)) { + const exact = value.toString(); + const numeric = Number(exact); + return Number.isSafeInteger(numeric) ? numeric : exact; + } + if (Array.isArray(value)) return value.map(normalizeLossless); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizeLossless(item)])); + } + return value; +} + +function rawDataBytes(data: RawData): Buffer { + if (Buffer.isBuffer(data)) return data; + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (Array.isArray(data)) return Buffer.concat(data); + throw new ChainError("provider_error", "TronLink WebSocket returned an unsupported frame type"); +} diff --git a/ts/src/application/ports/tronlink-collaboration.ts b/ts/src/application/ports/tronlink-collaboration.ts new file mode 100644 index 000000000..8371a1c26 --- /dev/null +++ b/ts/src/application/ports/tronlink-collaboration.ts @@ -0,0 +1,59 @@ +import type { NetworkDescriptor, TronTransactionArtifact } from "../../domain/types/index.js"; + +export interface TronLinkListFilter { + state: number; + isSigned?: boolean; + start: number; + limit: number; +} + +/** Untrusted wire record. Application code validates every field before using or rendering it. */ +export interface TronLinkRemoteRecord { + hash: unknown; + contract_type: unknown; + state: unknown; + is_sign: unknown; + current_weight: unknown; + threshold: unknown; + contract_data: unknown; + originator_address: unknown; + current_transaction: unknown; + signature_progress: unknown; +} + +export interface TronLinkRemotePage { + total: number; + records: TronLinkRemoteRecord[]; +} + +export interface TronLinkCreateRequest { + permissionName: string; + txId: string; + rawDataJson: string; + contractType: string; +} + +/** Outbound boundary for the official walletadapter REST/WebSocket collaboration service. */ +export interface TronLinkCollaborationPort { + list( + network: NetworkDescriptor, + address: string, + filter: TronLinkListFilter, + ): Promise; + create( + network: NetworkDescriptor, + address: string, + request: TronLinkCreateRequest, + ): Promise; + submit( + network: NetworkDescriptor, + address: string, + transaction: TronTransactionArtifact, + ): Promise; + watch( + network: NetworkDescriptor, + address: string, + signal: AbortSignal, + onMessage: (payload: unknown) => void, + ): Promise; +} diff --git a/ts/src/application/use-cases/config-service.test.ts b/ts/src/application/use-cases/config-service.test.ts index af7b15917..4bf48c3ed 100644 --- a/ts/src/application/use-cases/config-service.test.ts +++ b/ts/src/application/use-cases/config-service.test.ts @@ -55,3 +55,40 @@ describe("ConfigService waitTimeoutMs", () => { expect(update).toHaveBeenCalledTimes(2); }); }); + +describe("ConfigService TronLink credentials", () => { + it("validates the exact public config keys and masks the secret key", () => { + const { svc } = service(); + expect(svc.execute( + { key: "tronlinkSecretId", value: "TEST" }, + effective, + networks, + )).toMatchObject({ key: "tronlinkSecretId", value: "TEST" }); + expect(svc.execute( + { key: "tronlinkSecretKey", value: "TESTTESTTEST" }, + effective, + networks, + )).toMatchObject({ key: "tronlinkSecretKey", value: "********" }); + }); + + it("never returns an effective secret key in config views", () => { + const configured = { ...effective, tronlinkSecretKey: "secret" }; + const { svc } = service(); + expect(svc.execute({}, configured, networks)) + .toMatchObject({ tronlinkSecretKey: "********" }); + expect(svc.execute({ key: "tronlinkSecretKey" }, configured, networks)) + .toEqual({ key: "tronlinkSecretKey", value: "********" }); + }); + + it("rejects empty, oversized, and control-character credentials", () => { + const { svc, update } = service(); + for (const value of ["", "x".repeat(257), "bad\nvalue"]) { + expect(() => svc.execute( + { key: "tronlinkChannel", value }, + effective, + networks, + )).toThrow(); + } + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/config-service.ts b/ts/src/application/use-cases/config-service.ts index 1b9aabbba..359f26e48 100644 --- a/ts/src/application/use-cases/config-service.ts +++ b/ts/src/application/use-cases/config-service.ts @@ -3,8 +3,26 @@ 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", "waitTimeoutMs", "networks"] as const; -export const WRITABLE_CONFIG_KEYS = ["defaultNetwork", "defaultOutput", "timeoutMs", "waitTimeoutMs"] as const; +export const TRONLINK_CONFIG_KEYS = [ + "tronlinkSecretId", + "tronlinkSecretKey", + "tronlinkChannel", +] as const; +export const CONFIG_KEYS = [ + "defaultNetwork", + "defaultOutput", + "timeoutMs", + "waitTimeoutMs", + "networks", + ...TRONLINK_CONFIG_KEYS, +] as const; +export const WRITABLE_CONFIG_KEYS = [ + "defaultNetwork", + "defaultOutput", + "timeoutMs", + "waitTimeoutMs", + ...TRONLINK_CONFIG_KEYS, +] as const; export type ConfigKey = (typeof CONFIG_KEYS)[number]; export type WritableConfigKey = (typeof WRITABLE_CONFIG_KEYS)[number]; @@ -27,6 +45,9 @@ export class ConfigService { timeoutMs: effective.timeoutMs, waitTimeoutMs: effective.waitTimeoutMs, networks: Object.keys(effective.networks), + tronlinkSecretId: effective.tronlinkSecretId, + tronlinkSecretKey: maskSecret(effective.tronlinkSecretKey), + tronlinkChannel: effective.tronlinkChannel, }; if (input.key === undefined) return view; if (input.value === undefined) return { key: input.key, value: view[input.key] }; @@ -36,6 +57,12 @@ export class ConfigService { const key = input.key as WritableConfigKey; const value = this.normalize(key, input.value, networks); + if (key === "tronlinkSecretKey") { + return this.documents.update((current) => ({ + document: { ...current, [key]: value }, + result: { key, value: maskSecret(String(value)), input: "********" }, + })); + } return this.documents.update((current) => ({ document: { ...current, [key]: value }, result: { key, value, input: input.value! }, @@ -67,6 +94,16 @@ export class ConfigService { } return raw; } + if ((TRONLINK_CONFIG_KEYS as readonly string[]).includes(key)) { + if (raw.length === 0 || raw.length > 256 || /[\u0000-\u001f\u007f]/.test(raw)) { + throw new UsageError("invalid_value", `${key} must be 1 to 256 characters without control characters`); + } + return raw; + } return networks.resolve(raw).id; } } + +function maskSecret(value: string | undefined): string | undefined { + return value ? "********" : undefined; +} diff --git a/ts/src/application/use-cases/tron/tronlink-multisig-service.test.ts b/ts/src/application/use-cases/tron/tronlink-multisig-service.test.ts new file mode 100644 index 000000000..7800e1a50 --- /dev/null +++ b/ts/src/application/use-cases/tron/tronlink-multisig-service.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + TronLinkCollaborationPort, + TronLinkRemoteRecord, +} from "../../ports/tronlink-collaboration.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import { + decodeTransactionHex, + encodeTransactionHex, +} from "../../../adapters/outbound/chain/tron/transaction-codec.js"; +import type { TronMultisigService } from "./multisig-service.js"; +import { TronLinkMultisigService } from "./tronlink-multisig-service.js"; + +const A = "TLZz5XKerAAebbRdScB3jmSPr5DHSpGJJP"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const OWNER_HEX = "417445076632894b7b844887d2bcd2e8c30bb6c6f2"; +const TO_HEX = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"; +const SIG = "ab".repeat(65); +const NOW = 1_900_000_000_000; +const NETWORK = { id: "tron:nile", family: "tron", chainId: "nile" } as never; + +function unsignedHex(): string { + return encodeTransactionHex({ + visible: false, + raw_data: { + contract: [{ + parameter: { + value: { owner_address: OWNER_HEX, to_address: TO_HEX, amount: 1 }, + type_url: "type.googleapis.com/protocol.TransferContract", + }, + type: "TransferContract", + Permission_id: 2, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: NOW, + expiration: NOW + 86_400_000, + }, + }); +} + +function remote(hex = unsignedHex(), overrides: Partial = {}): TronLinkRemoteRecord { + const transaction = decodeTransactionHex(hex); + const signed = transaction.signature?.length ?? 0; + return { + hash: transaction.txID, + contract_type: "TransferContract", + state: 0, + is_sign: signed > 0 ? 1 : 0, + current_weight: signed, + threshold: 2, + contract_data: { owner_address: A }, + originator_address: A, + current_transaction: transaction, + signature_progress: [ + { address: A, weight: 1, is_sign: signed > 0 ? 1 : 0, sign_time: signed > 0 ? 1_900_000_001 : 0 }, + { address: B, weight: 1, is_sign: 0, sign_time: 0 }, + ], + ...overrides, + }; +} + +function gateway(): TronGateway { + return { + encodeTransactionHex, + decodeTransactionHex, + decodeTransaction: () => ({ kind: "trx", from: A, to: B, rawAmount: "1" }), + getSignWeight: vi.fn(async () => ({ + permission: { + id: 2, + name: "finance", + threshold: 2, + keys: [{ address: A, weight: 1 }, { address: B, weight: 1 }], + }, + approvedList: [], + currentWeight: 0, + resultCode: "NOT_ENOUGH_PERMISSION", + })), + } as unknown as TronGateway; +} + +function scope(): TransactionScope { + return { + activeAccount: "local" as never, + resolveAddress: () => A, + timeoutMs: 1_000, + wait: false, + waitTimeoutMs: 1_000, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function approval(hex: string) { + const transaction = decodeTransactionHex(hex); + const signatures = transaction.signature?.length ?? 0; + return { + txId: transaction.txID, + contractType: "TransferContract", + permission: { id: 2, name: "finance", threshold: 2 }, + currentWeight: signatures, + missingWeight: 2 - signatures, + thresholdReached: signatures >= 2, + approved: signatures ? [{ address: A, weight: 1 }] : [], + expiration: transaction.raw_data.expiration!, + expired: false, + signatures, + }; +} + +function setup(record = remote()) { + const collaboration = { + list: vi.fn(async () => ({ total: 1, records: [record] })), + create: vi.fn(async () => {}), + submit: vi.fn(async () => {}), + watch: vi.fn(async (_network, _address, _signal, onMessage) => { + onMessage([{ state: 0, is_sign: 0 }, { state: 0, is_sign: 1 }]); + }), + } as unknown as TronLinkCollaborationPort; + const chain = gateway(); + const provider = { get: vi.fn(() => chain) } as unknown as ChainGatewayProvider; + const signedHex = encodeTransactionHex({ ...decodeTransactionHex(unsignedHex()), signature: [SIG] }); + const local = { + approvals: vi.fn(async (_network, hex: string) => approval(hex)), + sign: vi.fn(async () => ({ + kind: "tx-sign", + signer: A, + signerWeight: 1, + hex: signedHex, + transaction: approval(signedHex), + })), + } as unknown as TronMultisigService; + return { + service: new TronLinkMultisigService(collaboration, provider, local, () => NOW), + collaboration, + local, + signedHex, + }; +} + +describe("TronLink multi-sign collaboration workflow", () => { + it("projects only byte- and node-validated remote transactions", async () => { + const result = await setup().service.list(NETWORK, A); + expect(result).toMatchObject({ + total: 1, + transactions: [{ + txId: decodeTransactionHex(unsignedHex()).txID, + owner: A, + permission: { id: 2, name: "finance", threshold: 2 }, + currentWeight: 0, + awaitingMySignature: true, + }], + }); + }); + + it("rejects hash, owner, and progress metadata tampering", async () => { + await expect(setup(remote(unsignedHex(), { hash: "00".repeat(32) })).service.list(NETWORK, A)) + .rejects.toMatchObject({ code: "provider_error" }); + await expect(setup(remote(unsignedHex(), { contract_data: { owner_address: B } })).service.list(NETWORK, A)) + .rejects.toMatchObject({ code: "provider_error" }); + await expect(setup(remote(unsignedHex(), { current_weight: 1 })).service.list(NETWORK, A)) + .rejects.toMatchObject({ code: "provider_error" }); + }); + + it("creates by uploading unsigned raw_data without invoking a signer", async () => { + const { service, collaboration, local } = setup(); + const result = await service.create(NETWORK, A, unsignedHex()); + expect(result).toMatchObject({ action: "create", accepted: true, transaction: { currentWeight: 0 } }); + expect(local.sign).not.toHaveBeenCalled(); + const request = vi.mocked(collaboration.create).mock.calls[0]![2]; + expect(request).toMatchObject({ + permissionName: "finance", + txId: decodeTransactionHex(unsignedHex()).txID, + contractType: "TransferContract", + }); + expect(JSON.parse(request.rawDataJson)).toMatchObject({ + contract: [{ parameter: { value: { owner_address: A, to_address: B } } }], + }); + }); + + it("refuses create artifacts that already contain a signature", async () => { + const { service, collaboration, local, signedHex } = setup(); + await expect(service.create(NETWORK, A, signedHex)) + .rejects.toMatchObject({ code: "invalid_value" }); + expect(local.sign).not.toHaveBeenCalled(); + expect(collaboration.create).not.toHaveBeenCalled(); + }); + + it("fetches the accumulated transaction, adds one signature, and submits it", async () => { + const { service, collaboration, local, signedHex } = setup(); + const txId = decodeTransactionHex(unsignedHex()).txID; + const context = scope(); + const result = await service.sign(context, NETWORK, txId); + expect(result).toMatchObject({ action: "sign", accepted: true, hex: signedHex }); + expect(local.sign).toHaveBeenCalledWith(context, NETWORK, unsignedHex()); + expect(collaboration.submit).toHaveBeenCalledTimes(1); + }); + + it("counts only pending unsigned WebSocket records", async () => { + const { service } = setup(); + const counts: number[] = []; + const result = await service.watch( + NETWORK, + A, + new AbortController().signal, + (count) => counts.push(count), + ); + expect(counts).toEqual([1]); + expect(result).toMatchObject({ action: "watch", notifications: 1 }); + }); +}); diff --git a/ts/src/application/use-cases/tron/tronlink-multisig-service.ts b/ts/src/application/use-cases/tron/tronlink-multisig-service.ts new file mode 100644 index 000000000..9db263fb8 --- /dev/null +++ b/ts/src/application/use-cases/tron/tronlink-multisig-service.ts @@ -0,0 +1,415 @@ +import type { + TronLinkCollaborationPort, + TronLinkRemoteRecord, +} from "../../ports/tronlink-collaboration.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { + NetworkDescriptor, + TronLinkMultisigListView, + TronLinkMultisigState, + TronLinkMultisigTransactionView, + TronLinkSignatureProgressView, + TronTransactionArtifact, + TxApprovalView, +} from "../../../domain/types/index.js"; +import { ChainError, UsageError } from "../../../domain/errors/index.js"; +import { TronAddress, tronHexToBase58 } from "../../../domain/address/index.js"; +import { TronMultisigService } from "./multisig-service.js"; + +const LIST_LIMIT = 20; +const MAX_LOOKUP_RECORDS = 1000; +const LOOKUP_PAGE_SIZE = 50; + +interface ValidatedRemoteRecord { + view: TronLinkMultisigTransactionView; + hex: string; +} + +/** Secure collaboration workflow: the remote coordinator is never treated as a trust root. */ +export class TronLinkMultisigService { + readonly #address = new TronAddress(); + + constructor( + private readonly collaboration: TronLinkCollaborationPort, + private readonly gateways: ChainGatewayProvider, + private readonly local: TronMultisigService, + private readonly now: () => number = () => Date.now(), + ) {} + + async list(network: NetworkDescriptor, address: string): Promise { + const page = await this.collaboration.list(network, address, { + state: 255, + start: 0, + limit: LIST_LIMIT, + }); + const gateway = this.gateways.get(network, "tron"); + const validated = page.records.map((record) => this.#validate(gateway, address, record)); + await mapWithConcurrency(validated, 4, (transaction) => this.#verifyOnChain(network, transaction)); + return { + address, + total: page.total, + transactions: validated.map((transaction) => transaction.view), + }; + } + + async create( + network: NetworkDescriptor, + address: string, + unsignedHex: string, + ) { + const gateway = this.gateways.get(network, "tron"); + const transaction = gateway.decodeTransactionHex(unsignedHex); + if ((transaction.signature?.length ?? 0) !== 0) { + throw new UsageError("invalid_value", "TronLink --create requires an unsigned transaction"); + } + const approval = await this.local.approvals(network, unsignedHex); + if (approval.expired) throw new ChainError("tx_expired", "transaction has expired"); + + const weight = await gateway.getSignWeight(transaction); + const permission = weight.permission; + if (!permission + || permission.id !== approval.permission.id + || !permission.keys.some((key) => key.address === address)) { + throw new ChainError("not_authorized", "selected account is not a key in the transaction permission"); + } + + const visible = visibleTransaction(gateway, unsignedHex); + await this.collaboration.create(network, address, { + permissionName: permission.name, + txId: approval.txId, + rawDataJson: JSON.stringify(visible.raw_data), + contractType: approval.contractType, + }); + return { + action: "create" as const, + accepted: true as const, + hex: unsignedHex.trim().replace(/^0x/i, "").toLowerCase(), + transaction: approval, + }; + } + + async sign( + scope: TransactionScope, + network: NetworkDescriptor, + txId: string, + ) { + const address = scope.resolveAddress("tron"); + const remote = await this.#find(network, address, normalizeTxId(txId)); + if (remote.view.state !== "pending") { + if (remote.view.signedByCurrentAccount) { + throw new ChainError("already_signed", "this account has already signed the TronLink transaction"); + } + throw new ChainError("invalid_value", `TronLink transaction is ${remote.view.state}, not pending`); + } + if (remote.view.expired) throw new ChainError("tx_expired", "transaction has expired"); + + const signed = await this.local.sign(scope, network, remote.hex); + const gateway = this.gateways.get(network, "tron"); + await this.collaboration.submit(network, signed.signer, visibleTransaction(gateway, signed.hex)); + return { + action: "sign" as const, + accepted: true as const, + signer: signed.signer, + signerWeight: signed.signerWeight, + hex: signed.hex, + transaction: signed.transaction, + }; + } + + async watch( + network: NetworkDescriptor, + address: string, + signal: AbortSignal, + onPending: (count: number) => void, + ) { + let notifications = 0; + await this.collaboration.watch(network, address, signal, (payload) => { + if (!Array.isArray(payload)) return; + const count = payload.filter((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; + const record = entry as Record; + return booleanFlag(record.is_sign, "is_sign") === false + && safeInteger(record.state, "state", 0) === 0; + }).length; + if (count > 0) { + notifications += 1; + onPending(count); + } + }); + return { action: "watch" as const, address, notifications }; + } + + async #find( + network: NetworkDescriptor, + address: string, + txId: string, + ): Promise { + const gateway = this.gateways.get(network, "tron"); + let start = 0; + let total = Number.MAX_SAFE_INTEGER; + while (start < total && start < MAX_LOOKUP_RECORDS) { + const page = await this.collaboration.list(network, address, { + state: 255, + start, + limit: LOOKUP_PAGE_SIZE, + }); + total = page.total; + for (const record of page.records) { + const validated = this.#validate(gateway, address, record); + if (validated.view.txId === txId) { + await this.#verifyOnChain(network, validated); + return validated; + } + } + if (page.records.length === 0) break; + start += page.records.length; + } + if (total > MAX_LOOKUP_RECORDS) { + throw new ChainError( + "not_found", + `transaction was not found in the newest ${MAX_LOOKUP_RECORDS} TronLink records`, + ); + } + throw new ChainError("not_found", `TronLink transaction not found: ${txId}`); + } + + #validate( + gateway: TronGateway, + currentAddress: string, + remote: TronLinkRemoteRecord, + ): ValidatedRemoteRecord { + const hash = normalizeTxId(stringValue(remote.hash, "hash")); + let hex: string; + let transaction: TronTransactionArtifact; + try { + hex = gateway.encodeTransactionHex(remote.current_transaction); + transaction = gateway.decodeTransactionHex(hex); + } catch { + throw new ChainError("provider_error", "TronLink returned a transaction that is not losslessly encodable"); + } + if (transaction.txID !== hash) { + throw new ChainError("provider_error", "TronLink record hash does not match transaction raw_data"); + } + + const contract = transaction.raw_data.contract[0]; + const contractType = stringValue(remote.contract_type, "contract_type"); + if (!contract || contract.type !== contractType) { + throw new ChainError("provider_error", "TronLink contract_type does not match transaction raw_data"); + } + const txOwner = normalizedAddress( + contract.parameter?.value?.owner_address, + "transaction owner_address", + this.#address, + ); + const contractData = objectValue(remote.contract_data, "contract_data"); + const metadataOwner = normalizedAddress( + contractData.owner_address, + "contract_data.owner_address", + this.#address, + ); + if (metadataOwner !== txOwner) { + throw new ChainError("provider_error", "TronLink owner metadata does not match transaction raw_data"); + } + + const originator = normalizedAddress(remote.originator_address, "originator_address", this.#address); + const stateCode = safeInteger(remote.state, "state", 0); + const isSigned = booleanFlag(remote.is_sign, "is_sign"); + const state = recordState(stateCode, isSigned); + const currentWeight = safeInteger(remote.current_weight, "current_weight", 0); + const threshold = safeInteger(remote.threshold, "threshold", 1); + const signatureProgress = progress(remote.signature_progress, this.#address); + const currentProgress = signatureProgress.find((item) => item.address === currentAddress); + if (!currentProgress) { + throw new ChainError("not_authorized", "selected account is not a signer in this TronLink transaction"); + } + if (currentProgress.signed !== isSigned) { + throw new ChainError("provider_error", "TronLink is_sign disagrees with signature_progress"); + } + const signedWeight = signatureProgress + .filter((item) => item.signed) + .reduce((sum, item) => sum + item.weight, 0); + if (!Number.isSafeInteger(signedWeight) || signedWeight !== currentWeight) { + throw new ChainError("provider_error", "TronLink current_weight disagrees with signature_progress"); + } + const signatures = transaction.signature?.length ?? 0; + if (signatureProgress.filter((item) => item.signed).length !== signatures) { + throw new ChainError("provider_error", "TronLink signature_progress disagrees with transaction signatures"); + } + + const createdAt = safeInteger(transaction.raw_data.timestamp, "transaction timestamp", 0); + const expiration = safeInteger(transaction.raw_data.expiration, "transaction expiration", 1); + const decoded = gateway.decodeTransaction(transaction); + return { + hex, + view: { + txId: hash, + state, + contractType, + originator, + owner: txOwner, + permission: { + id: contract.Permission_id ?? 0, + name: "", + threshold, + }, + currentWeight, + missingWeight: Math.max(0, threshold - currentWeight), + thresholdReached: currentWeight >= threshold, + awaitingMySignature: stateCode === 0 && !isSigned, + signedByCurrentAccount: isSigned, + createdAt, + expiration, + expired: expiration <= this.now(), + signatures, + signatureProgress, + from: decoded.from, + to: decoded.to, + rawAmount: decoded.rawAmount, + }, + }; + } + + async #verifyOnChain(network: NetworkDescriptor, remote: ValidatedRemoteRecord): Promise { + const approval = await this.local.approvals(network, remote.hex); + const signedProgress = remote.view.signatureProgress + .filter((entry) => entry.signed) + .map((entry) => entry.address); + const approved = new Set(approval.approved.map((entry) => entry.address)); + if ( + approval.txId !== remote.view.txId + || approval.contractType !== remote.view.contractType + || approval.currentWeight !== remote.view.currentWeight + || approval.permission.id !== remote.view.permission.id + || approval.permission.threshold !== remote.view.permission.threshold + || approval.signatures !== remote.view.signatures + || approval.expiration !== remote.view.expiration + || approved.size !== signedProgress.length + || signedProgress.some((address) => !approved.has(address)) + ) { + throw new ChainError( + "provider_error", + "TronLink transaction metadata or signatures disagree with the selected network", + ); + } + remote.view.permission.name = approval.permission.name; + } +} + +/** Convert address fields to visible=true JSON, then prove that protobuf bytes are unchanged. */ +function visibleTransaction(gateway: TronGateway, hex: string): TronTransactionArtifact { + const transaction = structuredClone(gateway.decodeTransactionHex(hex)); + convertAddressFields(transaction); + transaction.visible = true; + if (gateway.encodeTransactionHex(transaction) !== hex.trim().replace(/^0x/i, "").toLowerCase()) { + throw new ChainError("provider_error", "visible transaction projection changed transaction bytes"); + } + return transaction; +} + +function convertAddressFields(value: unknown): void { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) convertAddressFields(item); + return; + } + for (const [field, item] of Object.entries(value as Record)) { + if (typeof item === "string" && (field === "address" || field.endsWith("_address"))) { + (value as Record)[field] = tronHexToBase58(item); + } else { + convertAddressFields(item); + } + } +} + +function progress(value: unknown, addressCodec: TronAddress): TronLinkSignatureProgressView[] { + if (!Array.isArray(value)) { + throw new ChainError("provider_error", "TronLink signature_progress must be an array"); + } + const addresses = new Set(); + return value.map((entry, index) => { + const item = objectValue(entry, `signature_progress[${index}]`); + const address = normalizedAddress(item.address, `signature_progress[${index}].address`, addressCodec); + if (addresses.has(address)) { + throw new ChainError("provider_error", "TronLink signature_progress contains duplicate addresses"); + } + addresses.add(address); + const weight = safeInteger(item.weight, `signature_progress[${index}].weight`, 1); + const signed = booleanFlag(item.is_sign, `signature_progress[${index}].is_sign`); + const signTime = safeInteger(item.sign_time ?? 0, `signature_progress[${index}].sign_time`, 0); + const signedAt = signTime === 0 ? null : signTime * 1000; + if (!Number.isSafeInteger(signedAt ?? 0) || (!signed && signTime !== 0)) { + throw new ChainError("provider_error", "TronLink signature timestamp is inconsistent"); + } + return { address, weight, signed, signedAt }; + }); +} + +function recordState(code: number, signed: boolean): TronLinkMultisigState { + if (code === 0) return signed ? "signed" : "pending"; + if (code === 1) return "success"; + if (code === 2) return "failed"; + throw new ChainError("provider_error", `TronLink returned unsupported transaction state ${code}`); +} + +function normalizeTxId(value: string): string { + const normalized = value.replace(/^0x/i, "").toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalized)) { + throw new UsageError("invalid_value", "TronLink txId must be a 32-byte hex string"); + } + return normalized; +} + +function normalizedAddress(value: unknown, field: string, codec: TronAddress): string { + const normalized = tronHexToBase58(stringValue(value, field)); + if (!codec.validate(normalized)) { + throw new ChainError("provider_error", `TronLink ${field} is not a valid TRON address`); + } + return normalized; +} + +function objectValue(value: unknown, field: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ChainError("provider_error", `TronLink ${field} must be an object`); + } + return value as Record; +} + +function stringValue(value: unknown, field: string): string { + if (typeof value !== "string" || value.length === 0 || value.length > 256) { + throw new ChainError("provider_error", `TronLink ${field} must be a non-empty string`); + } + return value; +} + +function safeInteger(value: unknown, field: string, minimum: number): number { + if (typeof value === "number" && Number.isSafeInteger(value) && value >= minimum) return value; + if (typeof value === "string" && /^\d+$/.test(value)) { + const parsed = Number(value); + if (Number.isSafeInteger(parsed) && parsed >= minimum) return parsed; + } + throw new ChainError("provider_error", `TronLink ${field} must be a safe integer`); +} + +function booleanFlag(value: unknown, field: string): boolean { + if (value === true || value === 1 || value === "1" || value === "true") return true; + if (value === false || value === 0 || value === "0" || value === "false") return false; + throw new ChainError("provider_error", `TronLink ${field} must be a boolean flag`); +} + +async function mapWithConcurrency( + values: readonly T[], + concurrency: number, + operation: (value: T) => Promise, +): Promise { + let next = 0; + const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => { + while (next < values.length) { + const index = next; + next += 1; + await operation(values[index]!); + } + }); + await Promise.all(workers); +} diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index 8b72db188..2736462f6 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -29,6 +29,7 @@ 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"; +import { TronLinkClient } from "../adapters/outbound/tronlink/client.js"; export interface BootstrapOptions { readonly globals: Globals; @@ -86,14 +87,17 @@ export function composeCliRuntime(options: BootstrapOptions) { transactions: txPipeline, accounts: keystore, timeoutMs, + tronlink: new TronLinkClient(config, timeoutMs), }); const capabilitiesByFamily = registry.capabilityKeysByFamily(); for (const network of Object.values(config.networks)) { - const commandCapabilities = (capabilitiesByFamily.get(network.family) ?? []).map((key) => ({ - key, - summary: CAP_SUMMARIES[key] ?? key, - })); + const commandCapabilities = (capabilitiesByFamily.get(network.family) ?? []) + .filter((key) => key !== "tx.multisig.tronlink" || Boolean(network.tronlinkHttpEndpoint)) + .map((key) => ({ + key, + summary: CAP_SUMMARIES[key] ?? key, + })); const traits = network.capabilities.map((key) => ({ key, summary: TRAIT_SUMMARIES[key] ?? key, diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index cda2b4b7c..60dab2175 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -44,6 +44,8 @@ import { txSendTronBinding, txSignSpec, txSignTronBinding, + txTronLinkMultisigBinding, + txTronLinkMultisigSpec, txStatusSpec, txStatusTronBinding, } from "../../adapters/inbound/cli/commands/tx.js"; @@ -87,6 +89,7 @@ import { MessageService } from "../../application/use-cases/message-service.js"; import { TypedDataService } from "../../application/use-cases/typed-data-service.js"; import { TronPermissionService } from "../../application/use-cases/tron/permission-service.js"; import { TronMultisigService } from "../../application/use-cases/tron/multisig-service.js"; +import { TronLinkMultisigService } from "../../application/use-cases/tron/tronlink-multisig-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"; @@ -95,6 +98,7 @@ import type { TxPipeline } from "../../application/services/pipeline/index.js"; import type { AccountStore } from "../../application/ports/account-store.js"; import { TransactionArtifactWriter } from "../../adapters/outbound/persistence/transaction-artifact-writer.js"; import type { FamilyPlugin } from "./types.js"; +import type { TronLinkCollaborationPort } from "../../application/ports/tronlink-collaboration.js"; export const tronFamily: FamilyPlugin<"tron"> = { meta: FAMILIES.tron, @@ -110,6 +114,7 @@ export interface TronChainCommandDependencies { transactions: TxPipeline; accounts: AccountStore; timeoutMs: number; + tronlink: TronLinkCollaborationPort; } export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainCommandDependencies): void { @@ -124,6 +129,7 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC const typedData = new TypedDataService(deps.signers); const transaction = new TronTransactionService(deps.gateways, deps.tokens, deps.transactions); const multisig = new TronMultisigService(deps.gateways, deps.signers); + const tronlink = new TronLinkMultisigService(deps.tronlink, deps.gateways, multisig); const permission = new TronPermissionService(deps.gateways, deps.accounts, deps.transactions); const stake = new TronStakeService(deps.gateways, deps.transactions); const vote = new TronVoteService(deps.gateways, deps.transactions, stake); @@ -150,6 +156,7 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC new TransactionArtifactWriter(), )); reg.addChain(txApprovalsSpec, "tron", txApprovalsTronBinding(multisig)); + reg.addChain(txTronLinkMultisigSpec, "tron", txTronLinkMultisigBinding(tronlink)); reg.addChain(txBroadcastSpec, "tron", txBroadcastTronBinding(multisig)); reg.addChain(txStatusSpec, "tron", txStatusTronBinding(transaction)); reg.addChain(txInfoSpec, "tron", txInfoTronBinding(transaction)); diff --git a/ts/src/domain/types/multisig.ts b/ts/src/domain/types/multisig.ts index 37b398573..4192f4261 100644 --- a/ts/src/domain/types/multisig.ts +++ b/ts/src/domain/types/multisig.ts @@ -33,3 +33,73 @@ export interface TxSignView { out?: string; transaction: TxApprovalView; } + +export type TronLinkMultisigState = "pending" | "signed" | "success" | "failed"; + +export interface TronLinkSignatureProgressView { + address: string; + weight: number; + signed: boolean; + signedAt: number | null; +} + +/** Validated projection of an untrusted TronLink collaboration record. */ +export interface TronLinkMultisigTransactionView { + txId: string; + state: TronLinkMultisigState; + contractType: string; + originator: string; + owner: string; + permission: { + id: number; + name: string; + threshold: number; + }; + currentWeight: number; + missingWeight: number; + thresholdReached: boolean; + awaitingMySignature: boolean; + signedByCurrentAccount: boolean; + createdAt: number; + expiration: number; + expired: boolean; + signatures: number; + signatureProgress: TronLinkSignatureProgressView[]; + from?: string; + to?: string; + rawAmount?: string; +} + +export interface TronLinkMultisigListView { + address: string; + total: number; + transactions: TronLinkMultisigTransactionView[]; +} + +export interface TronLinkMultisigCreateView { + action: "create"; + accepted: true; + hex: string; + transaction: TxApprovalView; +} + +export interface TronLinkMultisigSignView { + action: "sign"; + accepted: true; + signer: string; + signerWeight: number; + hex: string; + transaction: TxApprovalView; +} + +export interface TronLinkMultisigWatchView { + action: "watch"; + address: string; + notifications: number; +} + +export type TronLinkMultisigView = + | TronLinkMultisigListView + | TronLinkMultisigCreateView + | TronLinkMultisigSignView + | TronLinkMultisigWatchView; diff --git a/ts/src/domain/types/network.ts b/ts/src/domain/types/network.ts index b83dc51b8..a3613389f 100644 --- a/ts/src/domain/types/network.ts +++ b/ts/src/domain/types/network.ts @@ -22,6 +22,8 @@ interface NetworkBase { export interface TronNetworkDescriptor extends NetworkBase { family: "tron"; httpEndpoint?: string; + /** Official walletadapter multi-sign service. Credentials are stored separately in Config. */ + tronlinkHttpEndpoint?: string; } /** Single family today (TRON). Kept as a named alias so adding a family later means re-introducing @@ -43,6 +45,10 @@ export interface Config { networks: Record; /** USD-valuation source for `account portfolio`. Missing → builtin CoinGecko. */ price?: PriceConfig; + /** TronLink collaboration credentials for the currently selected service environment. */ + tronlinkSecretId?: string; + tronlinkSecretKey?: string; + tronlinkChannel?: string; } /** price service config ; best-effort — failures never fail a balance read. */ From fdee0a963e793d4aea688f2085305c2cf3b3f3bb Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 12:44:47 +0800 Subject: [PATCH 04/24] feat(ts): add GasFree transfer workflow --- .../adapters/inbound/cli/commands/gasfree.ts | 114 ++++ ts/src/adapters/inbound/cli/render/gasfree.ts | 99 +++ ts/src/adapters/inbound/cli/render/index.ts | 2 + ts/src/adapters/outbound/config/builtins.ts | 15 + .../adapters/outbound/config/config.test.ts | 23 + ts/src/adapters/outbound/config/index.ts | 11 +- .../adapters/outbound/gasfree/client.test.ts | 127 ++++ ts/src/adapters/outbound/gasfree/client.ts | 544 ++++++++++++++++ ts/src/application/ports/gasfree-provider.ts | 20 + .../use-cases/config-service.test.ts | 25 + .../application/use-cases/config-service.ts | 15 +- .../use-cases/tron/gasfree-service.test.ts | 304 +++++++++ .../use-cases/tron/gasfree-service.ts | 611 ++++++++++++++++++ ts/src/bootstrap/composition.ts | 3 + ts/src/bootstrap/families/tron.ts | 15 + ts/src/domain/address/index.ts | 20 +- ts/src/domain/gasfree/gasfree.test.ts | 68 ++ ts/src/domain/gasfree/index.ts | 139 ++++ ts/src/domain/types/gasfree.ts | 135 ++++ ts/src/domain/types/index.ts | 1 + ts/src/domain/types/network.ts | 14 + 21 files changed, 2301 insertions(+), 4 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/gasfree.ts create mode 100644 ts/src/adapters/inbound/cli/render/gasfree.ts create mode 100644 ts/src/adapters/outbound/gasfree/client.test.ts create mode 100644 ts/src/adapters/outbound/gasfree/client.ts create mode 100644 ts/src/application/ports/gasfree-provider.ts create mode 100644 ts/src/application/use-cases/tron/gasfree-service.test.ts create mode 100644 ts/src/application/use-cases/tron/gasfree-service.ts create mode 100644 ts/src/domain/gasfree/gasfree.test.ts create mode 100644 ts/src/domain/gasfree/index.ts create mode 100644 ts/src/domain/types/gasfree.ts diff --git a/ts/src/adapters/inbound/cli/commands/gasfree.ts b/ts/src/adapters/inbound/cli/commands/gasfree.ts new file mode 100644 index 000000000..0aba4f53d --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/gasfree.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { GasFreeService } from "../../../../application/use-cases/tron/gasfree-service.js"; +import { TextFormatters } from "../render/index.js"; + +export const gasFreeInfoSpec: ChainSpec = { + path: ["gasfree", "info"], + network: "optional", + wallet: "optional", + auth: "none", + capability: "gasfree.info", + requires: [ + "config gasfreeApiKey / gasfreeApiSecret", + ], + summary: "Show GasFree address, activation status, nonce, balances, and fees", + description: + "Show this account's GasFree address, activation status, nonce, supported tokens, balances, and current token-denominated fees.", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli gasfree info" }], + formatText: TextFormatters.gasFreeInfo, +}; + +export const gasFreeInfoTronBinding = ( + service: GasFreeService, +): FamilyBinding => ({ + run: async (context, network) => service.info(context, network), +}); + +const transferFields = z.object({ + to: z.string().trim().min(1).max(128) + .describe("recipient TRON address or local contact name"), + amount: z.string().regex(/^\d+(\.\d+)?$/, "must be a positive decimal amount") + .refine( + (value) => !/^0+(\.0+)?$/.test(value), + "must be greater than zero", + ), + token: z.string().trim().min(1).max(32).default("USDT"), + dryRun: z.boolean().default(false), +}); + +export const gasFreeTransferSpec: ChainSpec = { + path: ["gasfree", "transfer"], + network: "optional", + wallet: "optional", + auth: "required", + broadcasts: true, + capability: "gasfree.transfer", + requires: [ + "config gasfreeApiKey / gasfreeApiSecret", + ], + summary: "Sign and submit a TIP-712 GasFree token transfer", + description: + "Sign a GasFree PermitTransfer and submit it to the provider. No TRX is needed; --dry-run checks the token balance and fee breakdown without unlocking or signing.", + baseFields: transferFields, + baseRefine: (value, context) => { + if (value.dryRun && value.wait) { + context.addIssue({ + code: "custom", + path: ["dryRun"], + message: "--dry-run cannot be combined with --wait", + }); + } + }, + examples: [ + { + cmd: "wallet-cli gasfree transfer --to T... --amount 25 --password-stdin", + }, + { + cmd: "wallet-cli gasfree transfer --to alice --amount 25 --wait --password-stdin", + }, + ], + formatText: TextFormatters.gasFreeTransfer, +}; + +export const gasFreeTransferTronBinding = ( + service: GasFreeService, +): FamilyBinding => ({ + run: async (context, network, input) => + service.transfer(context, network, input), +}); + +const traceFields = z.object({ + traceId: z.string().trim().min(1).max(128) + .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/), +}); + +export const gasFreeTraceSpec: ChainSpec = { + path: ["gasfree", "trace"], + network: "optional", + wallet: "none", + auth: "none", + capability: "gasfree.trace", + requires: [ + "config gasfreeApiKey / gasfreeApiSecret", + ], + positionals: [{ field: "traceId", placeholder: "traceId" }], + summary: "Track a GasFree transfer by provider trace id", + description: + "Track a submitted GasFree transfer through WAITING, INPROGRESS, CONFIRMING, and the SUCCEED or FAILED terminal state.", + baseFields: traceFields, + examples: [ + { + cmd: "wallet-cli gasfree trace 7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527", + }, + ], + formatText: TextFormatters.gasFreeTrace, +}; + +export const gasFreeTraceTronBinding = ( + service: GasFreeService, +): FamilyBinding => ({ + run: async (_context, network, input) => + service.trace(network, input.traceId), +}); diff --git a/ts/src/adapters/inbound/cli/render/gasfree.ts b/ts/src/adapters/inbound/cli/render/gasfree.ts new file mode 100644 index 000000000..7e45ac81e --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/gasfree.ts @@ -0,0 +1,99 @@ +import type { + GasFreeInfoView, + GasFreeTraceView, + GasFreeTransferView, +} from "../../../../domain/types/index.js"; +import { fromBaseUnits } from "../../../../domain/amounts/index.js"; +import { + fail, + ok, + pending, + query, + receipt, + table, +} from "./layout.js"; + +function amount(value: string, decimals: number, symbol: string): string { + return `${fromBaseUnits(value, decimals)} ${symbol}`; +} + +export const GasFreeFormatters = { + gasFreeInfo: (value: GasFreeInfoView): string => [ + query([ + ["Owner", value.ownerAddress], + ["GasFree address", value.gasFreeAddress], + ["Status", value.active ? "active" : "not activated"], + ["Nonce", value.nonce], + ]), + table( + ["Token", "Balance", "Activation fee", "Transfer fee"], + value.tokens.map((token) => [ + token.symbol, + amount(token.balance, token.decimals, token.symbol), + amount(token.activateFee, token.decimals, token.symbol), + amount(token.transferFee, token.decimals, token.symbol), + ]), + ), + ].join("\n\n"), + + gasFreeTransfer: (value: GasFreeTransferView): string => { + const marker = + value.stage === "confirmed" + ? ok() + : value.stage === "failed" + ? fail() + : pending(); + const title = + value.stage === "dry-run" + ? `Dry run — GasFree transfer ${amount(value.amount, value.decimals, value.token)} (not submitted)` + : value.stage === "confirmed" + ? `Sent ${amount(value.amount, value.decimals, value.token)} via GasFree` + : value.stage === "failed" + ? "GasFree transfer failed" + : `Submitted to GasFree — send ${amount(value.amount, value.decimals, value.token)}`; + const result = receipt(marker, title, [ + ["Trace ID", value.traceId ?? ""], + ["TxID", value.txId ?? ""], + ["From", value.from], + ["To", value.toContact ? `${value.toContact} (${value.to})` : value.to], + [ + "Service fee", + amount(value.serviceFee, value.decimals, value.token), + ], + [ + "Activation fee", + amount(value.activateFee, value.decimals, value.token), + ], + [ + "Authorized max fee", + amount(value.authorizedMaxFee, value.decimals, value.token), + ], + ["Total", amount(value.totalDeducted, value.decimals, value.token)], + [ + "Status", + value.state?.toLowerCase() + ?? (value.stage === "dry-run" ? "not submitted" : "accepted"), + ], + ["Reason", value.failureReason ?? ""], + ]); + return value.stage === "submitted" && value.traceId + ? `${result}\n! Track it: wallet-cli gasfree trace ${value.traceId}` + : result; + }, + + gasFreeTrace: (value: GasFreeTraceView): string => query([ + ["Trace ID", value.traceId], + ["Status", value.state.toLowerCase()], + ["TxID", value.txId ?? ""], + ["Token", value.token], + ["Amount", amount(value.amount, value.decimals, value.token)], + ["Service fee", amount(value.serviceFee, value.decimals, value.token)], + [ + "Activation fee", + amount(value.activateFee, value.decimals, value.token), + ], + ["Total", amount(value.totalDeducted, value.decimals, value.token)], + ["To", value.to], + ["Reason", value.failureReason ?? ""], + ]), +}; diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index d907b9241..1f81f07df 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -24,6 +24,7 @@ import { ChainFormatters } from "./chain.js" import { MiscFormatters } from "./misc.js" import { PermissionFormatters } from "./permission.js" import { MultisigFormatters } from "./multisig.js" +import { GasFreeFormatters } from "./gasfree.js" export { FAMILY_RENDER, renderFamily } from "./family.js" @@ -38,6 +39,7 @@ export const TextFormatters = { ...MiscFormatters, ...PermissionFormatters, ...MultisigFormatters, + ...GasFreeFormatters, } export function renderGenericText(command: string, net: NetworkDescriptor | undefined, data: unknown): string { diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index 00aa7266f..bfbc99ba2 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -35,6 +35,9 @@ export const CAP_SUMMARIES: Record = { "reward.withdraw": "withdraw voting/block rewards", "permission.read": "read account multi-sign permissions", "permission.update": "replace account multi-sign permissions", + "gasfree.info": "GasFree account, fee and nonce information", + "gasfree.transfer": "TIP-712 gas-free token transfer", + "gasfree.trace": "track a GasFree transfer", } export const BUILTIN_NETWORKS: Record = { @@ -45,6 +48,12 @@ export const BUILTIN_NETWORKS: Record = { aliases: ["tron"], httpEndpoint: "https://api.trongrid.io", tronlinkHttpEndpoint: "https://api.walletadapter.org", + gasfree: { + baseUrl: "https://open.gasfree.io", + apiPrefix: "/tron", + controllerChainId: "728126428", + verifyingContract: "TFFAMQLZybALaLb4uxHA9RBE7pxhUAjF3U", + }, feeModel: "tron-resource", capabilities: [], }, @@ -55,6 +64,12 @@ export const BUILTIN_NETWORKS: Record = { aliases: ["nile"], httpEndpoint: "https://nile.trongrid.io", tronlinkHttpEndpoint: "https://apinile.walletadapter.org", + gasfree: { + baseUrl: "https://open-test.gasfree.io", + apiPrefix: "/nile", + controllerChainId: "3448148188", + verifyingContract: "THQGuFzL87ZqhxkgqYEryRAd7gqFqL5rdc", + }, feeModel: "tron-resource", capabilities: [], }, diff --git a/ts/src/adapters/outbound/config/config.test.ts b/ts/src/adapters/outbound/config/config.test.ts index 6a6676ac7..51c55193d 100644 --- a/ts/src/adapters/outbound/config/config.test.ts +++ b/ts/src/adapters/outbound/config/config.test.ts @@ -81,3 +81,26 @@ describe("ConfigLoader TronLink credentials", () => { .toThrow(/mode 0600/); }); }); + +describe("ConfigLoader GasFree credentials", () => { + const credentials = [ + "gasfreeApiKey: TEST", + "gasfreeApiSecret: TESTTESTTEST", + "", + ].join("\n"); + + it("loads credentials only from a private config file", () => { + expect(ConfigLoader.load(envWithConfig(credentials, 0o600))).toMatchObject({ + gasfreeApiKey: "TEST", + gasfreeApiSecret: "TESTTESTTEST", + }); + }); + + it.runIf(process.platform !== "win32")( + "rejects credentials in a group/world-readable file", + () => { + expect(() => ConfigLoader.load(envWithConfig(credentials, 0o644))) + .toThrow(/mode 0600/); + }, + ); +}); diff --git a/ts/src/adapters/outbound/config/index.ts b/ts/src/adapters/outbound/config/index.ts index de21600fa..5e5584e66 100644 --- a/ts/src/adapters/outbound/config/index.ts +++ b/ts/src/adapters/outbound/config/index.ts @@ -38,11 +38,16 @@ export class ConfigLoader { let tronlinkSecretId: string | undefined; let tronlinkSecretKey: string | undefined; let tronlinkChannel: string | undefined; + let gasfreeApiKey: string | undefined; + let gasfreeApiSecret: string | undefined; const path = ConfigLoader.configPath(env); if (existsSync(path)) { const raw = parseYaml(readFileSync(path, "utf8")) ?? {}; - if (typeof raw.tronlinkSecretKey === "string" && raw.tronlinkSecretKey !== "") { + if ( + (typeof raw.tronlinkSecretKey === "string" && raw.tronlinkSecretKey !== "") + || (typeof raw.gasfreeApiSecret === "string" && raw.gasfreeApiSecret !== "") + ) { assertSecretConfigPermissions(path); } if (typeof raw.defaultNetwork === "string" && raw.defaultNetwork.trim() !== "") { @@ -62,6 +67,8 @@ export class ConfigLoader { if (validCredential(raw.tronlinkSecretId)) tronlinkSecretId = raw.tronlinkSecretId; if (validCredential(raw.tronlinkSecretKey)) tronlinkSecretKey = raw.tronlinkSecretKey; if (validCredential(raw.tronlinkChannel)) tronlinkChannel = raw.tronlinkChannel; + if (validCredential(raw.gasfreeApiKey)) gasfreeApiKey = raw.gasfreeApiKey; + if (validCredential(raw.gasfreeApiSecret)) gasfreeApiSecret = raw.gasfreeApiSecret; if (raw.networks && typeof raw.networks === "object") { for (const [id, d] of Object.entries(raw.networks as Record>)) { networks[id] = { ...(networks[id] ?? {}), ...d, id } as NetworkDescriptor; @@ -78,6 +85,8 @@ export class ConfigLoader { tronlinkSecretId, tronlinkSecretKey, tronlinkChannel, + gasfreeApiKey, + gasfreeApiSecret, }; } } diff --git a/ts/src/adapters/outbound/gasfree/client.test.ts b/ts/src/adapters/outbound/gasfree/client.test.ts new file mode 100644 index 000000000..aa21fc448 --- /dev/null +++ b/ts/src/adapters/outbound/gasfree/client.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + Config, + NetworkDescriptor, +} from "../../../domain/types/index.js"; +import { GasFreeClient } from "./client.js"; + +const NETWORK = { + id: "tron:nile", + family: "tron", + chainId: "nile", + aliases: ["nile"], + capabilities: [], + gasfree: { + baseUrl: "https://open-test.gasfree.io", + apiPrefix: "/nile", + controllerChainId: "3448148188", + verifyingContract: "THQGuFzL87ZqhxkgqYEryRAd7gqFqL5rdc", + }, +} satisfies NetworkDescriptor; +const CONFIG = { + gasfreeApiKey: "test-key", + gasfreeApiSecret: "test-secret", +} as Config; + +function response(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("GasFreeClient", () => { + it("signs the exact Java path and parses uints losslessly", async () => { + const fetcher = vi.fn( + async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.headers).toMatchObject({ + Timestamp: "1700000000", + Authorization: + "ApiKey test-key:0nyrTDAIARSlT+WxH69ILaVpOZ7UTkEBwEH8uxR5B2I=", + }); + return new Response( + '{"code":200,"data":{"tokens":[{"tokenAddress":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","activateFee":900719925474099312345,"transferFee":2000,"symbol":"USDT","decimals":6}]}}', + { status: 200 }, + ); + }, + ); + const client = new GasFreeClient( + CONFIG, + 1000, + fetcher as typeof fetch, + () => 1_700_000_000_000, + ); + const tokens = await client.listTokens(NETWORK); + expect(fetcher.mock.calls[0]?.[0]).toBe( + "https://open-test.gasfree.io/nile/api/v1/config/token/all", + ); + expect(tokens[0]?.activateFee).toBe("900719925474099312345"); + }); + + it("serializes uint256 values as JSON numbers and never retries POST", async () => { + const fetcher = vi.fn( + async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.body).toContain('"value":900719925474099312345'); + return response({ + code: 200, + data: { + id: "6c3ff67e-0bf4-4c09-91ca-0c7c254b01a0", + state: "WAITING", + tokenAddress: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + providerAddress: "TKtWbdzEq5ss9vTS9kwRhBp5mXmBfBns3E", + accountAddress: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + gasFreeAddress: "TNER12mMVWruqopsW9FQtKxCGfZcEtb3ER", + targetAddress: "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT", + amount: 900719925474099312345n.toString(), + maxFee: 2000000, + nonce: 8, + expiredAt: 1747909695000, + }, + }); + }, + ); + const client = new GasFreeClient( + CONFIG, + 1000, + fetcher as typeof fetch, + () => 1_700_000_000_000, + ); + const record = await client.submitTransfer(NETWORK, { + token: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + serviceProvider: "TKtWbdzEq5ss9vTS9kwRhBp5mXmBfBns3E", + user: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + receiver: "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT", + value: "900719925474099312345", + maxFee: "2000000", + deadline: "1747909695", + version: "1", + nonce: "8", + sig: "00".repeat(64) + "1b", + }); + expect(record.expiredAt).toBe("1747909695000"); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("maps authentication errors without leaking credentials", async () => { + const client = new GasFreeClient( + CONFIG, + 1000, + vi.fn(async () => response({}, 401)) as typeof fetch, + ); + await expect(client.listTokens(NETWORK)).rejects.toMatchObject({ + code: "gasfree_auth_failed", + }); + await expect(client.listTokens(NETWORK)).rejects.not.toThrow( + /test-secret|test-key/, + ); + }); + + it("rejects unsupported networks before sending credentials", async () => { + const fetcher = vi.fn(); + const client = new GasFreeClient(CONFIG, 1000, fetcher as typeof fetch); + await expect( + client.listTokens({ ...NETWORK, gasfree: undefined }), + ).rejects.toMatchObject({ code: "unsupported_network" }); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/adapters/outbound/gasfree/client.ts b/ts/src/adapters/outbound/gasfree/client.ts new file mode 100644 index 000000000..c015edde8 --- /dev/null +++ b/ts/src/adapters/outbound/gasfree/client.ts @@ -0,0 +1,544 @@ +import { createHmac } from "node:crypto"; +import { + isLosslessNumber, + LosslessNumber, + parse as parseLosslessJson, + stringify as stringifyLosslessJson, +} from "lossless-json"; +import type { GasFreeProvider } from "../../../application/ports/gasfree-provider.js"; +import type { + Config, + GasFreeAddressInfo, + GasFreeProviderConfig, + GasFreeState, + GasFreeTokenConfig, + GasFreeTransferRecord, + NetworkDescriptor, + SignedGasFreeAuthorization, +} from "../../../domain/types/index.js"; +import { + ChainError, + CliError, + TransportError, + UsageError, +} from "../../../domain/errors/index.js"; +import { TronAddress } from "../../../domain/address/index.js"; + +const MAX_BYTES = 1024 * 1024; +const ADDRESS = new TronAddress(); +const STATES = new Set([ + "WAITING", + "INPROGRESS", + "CONFIRMING", + "SUCCEED", + "FAILED", +]); +type Fetch = typeof globalThis.fetch; + +/** Java-compatible adapter for open.gasfree.io. Mutating POST requests are never retried. */ +export class GasFreeClient implements GasFreeProvider { + constructor( + private readonly config: Config, + private readonly timeoutMs: number, + private readonly fetchImpl: Fetch = globalThis.fetch, + private readonly clock: () => number = () => Date.now(), + ) {} + + async listTokens(network: NetworkDescriptor): Promise { + const data = object( + await this.#request(network, "GET", "/api/v1/config/token/all"), + "data", + ); + return array(data.tokens, "data.tokens").map((entry, index) => { + const item = object(entry, `data.tokens[${index}]`); + const symbol = optionalText(item.symbol, 32, `data.tokens[${index}].symbol`); + return { + tokenAddress: address(item.tokenAddress, `data.tokens[${index}].tokenAddress`), + activateFee: uint(item.activateFee, `data.tokens[${index}].activateFee`), + transferFee: uint(item.transferFee, `data.tokens[${index}].transferFee`), + ...(symbol === undefined ? {} : { symbol }), + ...(item.decimals === undefined + ? {} + : { decimals: smallUInt(item.decimals, `data.tokens[${index}].decimals`, 255) }), + }; + }); + } + + async listProviders(network: NetworkDescriptor): Promise { + const data = object( + await this.#request(network, "GET", "/api/v1/config/provider/all"), + "data", + ); + return array(data.providers, "data.providers").map((entry, index) => { + const item = object(entry, `data.providers[${index}]`); + const providerConfig = object(item.config, `data.providers[${index}].config`); + return { + address: address(item.address, `data.providers[${index}].address`), + defaultDeadlineDuration: uint( + providerConfig.defaultDeadlineDuration, + `data.providers[${index}].config.defaultDeadlineDuration`, + ), + ...(typeof item.default === "boolean" ? { isDefault: item.default } : {}), + }; + }); + } + + async getAddress( + network: NetworkDescriptor, + ownerAddress: string, + ): Promise { + if (!ADDRESS.validate(ownerAddress)) { + throw new UsageError("invalid_value", "ownerAddress must be a valid TRON address"); + } + const data = object( + await this.#request( + network, + "GET", + `/api/v1/address/${encodeURIComponent(ownerAddress)}`, + ), + "data", + ); + return { + ownerAddress, + gasFreeAddress: address(data.gasFreeAddress, "data.gasFreeAddress"), + active: boolean(data.active, "data.active"), + nonce: uint(data.nonce, "data.nonce"), + ...(typeof data.allowSubmit === "boolean" ? { allowSubmit: data.allowSubmit } : {}), + assets: array(data.assets, "data.assets").map((entry, index) => { + const item = object(entry, `data.assets[${index}]`); + return { + tokenAddress: address(item.tokenAddress, `data.assets[${index}].tokenAddress`), + activateFee: uint(item.activateFee, `data.assets[${index}].activateFee`), + transferFee: uint(item.transferFee, `data.assets[${index}].transferFee`), + }; + }), + }; + } + + async submitTransfer( + network: NetworkDescriptor, + authorization: SignedGasFreeAuthorization, + ): Promise { + const body = { + token: authorization.token, + serviceProvider: authorization.serviceProvider, + user: authorization.user, + receiver: authorization.receiver, + value: new LosslessNumber(authorization.value), + maxFee: new LosslessNumber(authorization.maxFee), + deadline: new LosslessNumber(authorization.deadline), + version: new LosslessNumber(authorization.version), + nonce: new LosslessNumber(authorization.nonce), + sig: authorization.sig, + }; + return transferRecord( + await this.#request(network, "POST", "/api/v1/gasfree/submit", body), + ); + } + + async trace( + network: NetworkDescriptor, + traceId: string, + ): Promise { + const normalized = traceIdentifier(traceId); + return transferRecord( + await this.#request( + network, + "GET", + `/api/v1/gasfree/${encodeURIComponent(normalized)}`, + ), + ); + } + + async #request( + network: NetworkDescriptor, + method: "GET" | "POST", + suffix: string, + body?: unknown, + ): Promise { + const metadata = endpoint(network); + const credentials = this.#credentials(); + const path = `${metadata.apiPrefix}${suffix}`; + const timestamp = String(Math.floor(this.clock() / 1000)); + const signature = createHmac("sha256", credentials.apiSecret) + .update(`${method}${path}${timestamp}`, "utf8") + .digest("base64"); + const encoded = body === undefined ? undefined : stringifyLosslessJson(body); + if (encoded !== undefined && Buffer.byteLength(encoded, "utf8") > MAX_BYTES) { + throw new UsageError("invalid_value", "GasFree request exceeds the 1 MiB limit"); + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetchImpl(`${metadata.baseUrl}${path}`, { + method, + headers: { + Timestamp: timestamp, + Authorization: `ApiKey ${credentials.apiKey}:${signature}`, + Accept: "application/json", + ...(encoded === undefined ? {} : { "content-type": "application/json" }), + }, + body: encoded, + signal: controller.signal, + redirect: "error", + referrerPolicy: "no-referrer", + cache: "no-store", + }); + if (!response.ok) throw httpError(response); + const contentLength = response.headers.get("content-length"); + if ( + contentLength + && /^\d+$/.test(contentLength) + && Number(contentLength) > MAX_BYTES + ) { + throw new ChainError( + "provider_error", + "GasFree response exceeds the 1 MiB limit", + ); + } + const text = await readBoundedText(response, MAX_BYTES); + let decoded: unknown; + try { + decoded = normalizeLossless(parseLosslessJson(text)); + } catch { + throw new ChainError( + "provider_error", + "GasFree service returned malformed JSON", + ); + } + return unwrap(decoded); + } catch (error) { + if (error instanceof CliError) throw error; + if (controller.signal.aborted) { + throw new ChainError( + "timeout", + `GasFree request timed out after ${this.timeoutMs}ms`, + ); + } + throw new TransportError( + "provider_error", + "GasFree service request failed", + ); + } finally { + clearTimeout(timeout); + } + } + + #credentials(): { apiKey: string; apiSecret: string } { + const { gasfreeApiKey: apiKey, gasfreeApiSecret: apiSecret } = this.config; + if (!apiKey || !apiSecret) { + throw new UsageError( + "gasfree_credentials_missing", + "GasFree credentials are incomplete; configure gasfreeApiKey and gasfreeApiSecret", + ); + } + return { apiKey, apiSecret }; + } +} + +function endpoint( + network: NetworkDescriptor, +): { baseUrl: string; apiPrefix: string } { + const value = network.gasfree; + if (!value) { + throw new UsageError( + "unsupported_network", + `network ${network.id} does not support GasFree`, + ); + } + let url: URL; + try { + url = new URL(value.baseUrl); + } catch { + throw new UsageError("invalid_config", "GasFree baseUrl is invalid"); + } + if ( + url.protocol !== "https:" + || url.username + || url.password + || url.search + || url.hash + || url.pathname !== "/" + ) { + throw new UsageError( + "invalid_config", + "GasFree baseUrl must be an HTTPS origin without credentials, path, query, or fragment", + ); + } + if (!/^\/[a-z0-9-]+$/.test(value.apiPrefix)) { + throw new UsageError( + "invalid_config", + "GasFree apiPrefix must be one absolute path segment", + ); + } + return { baseUrl: url.origin, apiPrefix: value.apiPrefix }; +} + +function httpError(response: Response): CliError { + if (response.status === 401 || response.status === 403) { + return new ChainError( + "gasfree_auth_failed", + "GasFree credentials were rejected", + { status: response.status }, + ); + } + if (response.status === 404) { + return new ChainError("not_found", "GasFree resource was not found", { + status: 404, + }); + } + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return new ChainError( + "provider_rate_limited", + "GasFree service rate limit exceeded", + { + status: 429, + ...(retryAfter && /^[\x20-\x7e]{1,128}$/.test(retryAfter) + ? { retryAfter } + : {}), + }, + ); + } + if (response.status >= 500) { + return new TransportError( + "provider_error", + `GasFree service returned HTTP ${response.status}`, + ); + } + return new ChainError( + "gasfree_rejected", + `GasFree service returned HTTP ${response.status}`, + { status: response.status }, + ); +} + +function unwrap(value: unknown): unknown { + const root = object(value, "response"); + const code = smallUInt(root.code, "code", 999_999); + if (code !== 200) { + throw new ChainError( + "gasfree_rejected", + "GasFree service rejected the request", + { code }, + ); + } + if (root.data === null || root.data === undefined) { + throw new ChainError("not_found", "GasFree resource was not found"); + } + return root.data; +} + +function transferRecord(value: unknown): GasFreeTransferRecord { + const data = object(value, "data"); + const state = text(data.state, "data.state", 32) as GasFreeState; + if (!STATES.has(state)) throw invalid("data.state", "a known GasFree state"); + return { + id: traceIdentifier(text(data.id, "data.id", 128)), + state, + tokenAddress: address(data.tokenAddress, "data.tokenAddress"), + providerAddress: address(data.providerAddress, "data.providerAddress"), + accountAddress: address(data.accountAddress, "data.accountAddress"), + gasFreeAddress: address(data.gasFreeAddress, "data.gasFreeAddress"), + targetAddress: address(data.targetAddress, "data.targetAddress"), + amount: uint(data.amount, "data.amount"), + nonce: uint(data.nonce, "data.nonce"), + ...optionalUintFields(data, [ + "maxFee", + "expiredAt", + "estimatedTransferFee", + "estimatedActivateFee", + "estimatedTotalFee", + "estimatedTotalCost", + "txnAmount", + "txnTransferFee", + "txnActivateFee", + "txnTotalFee", + "txnTotalCost", + "txnBlockNum", + "txnBlockTimestamp", + ]), + ...(data.txnHash === undefined || data.txnHash === null + ? {} + : { txnHash: transactionHash(data.txnHash) }), + ...(data.txnState === undefined || data.txnState === null + ? {} + : { txnState: text(data.txnState, "data.txnState", 64) }), + ...(data.failureReason === undefined || data.failureReason === null + ? {} + : { + failureReason: text( + data.failureReason, + "data.failureReason", + 256, + ), + }), + }; +} + +function optionalUintFields( + data: Record, + fields: readonly string[], +): Record { + return Object.fromEntries( + fields + .filter((field) => data[field] !== undefined && data[field] !== null) + .map((field) => [field, uint(data[field], `data.${field}`)]), + ); +} + +function transactionHash(value: unknown): string { + const result = text(value, "data.txnHash", 66) + .replace(/^0x/, "") + .toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(result)) { + throw invalid("data.txnHash", "a 32-byte transaction hash"); + } + return result; +} + +function traceIdentifier(value: string): string { + const normalized = value.trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(normalized)) { + throw new UsageError( + "invalid_value", + "traceId contains unsupported characters", + ); + } + return normalized; +} + +function address(value: unknown, field: string): string { + const result = text(value, field, 64); + if (!ADDRESS.validate(result)) throw invalid(field, "a valid TRON address"); + return result; +} + +function boolean(value: unknown, field: string): boolean { + if (typeof value !== "boolean") throw invalid(field, "a boolean"); + return value; +} + +function uint(value: unknown, field: string): string { + let result: string; + if ( + typeof value === "number" + && Number.isSafeInteger(value) + && value >= 0 + ) { + result = String(value); + } else if ( + typeof value === "string" + && /^(0|[1-9]\d*)$/.test(value) + ) { + result = value; + } else { + throw invalid(field, "an unsigned decimal integer"); + } + // All values entering GasFree authorization/hash math are uint256. Bound length before BigInt + // conversion so an authenticated-but-malicious response cannot trigger huge integer parsing. + if (result.length > 78 || BigInt(result) >= 1n << 256n) { + throw invalid(field, "an unsigned uint256 decimal integer"); + } + return result; +} + +function smallUInt(value: unknown, field: string, maximum: number): number { + const parsed = Number(uint(value, field)); + if (!Number.isSafeInteger(parsed) || parsed > maximum) { + throw invalid(field, `an integer no greater than ${maximum}`); + } + return parsed; +} + +function text(value: unknown, field: string, maximum: number): string { + if ( + typeof value !== "string" + || value.length === 0 + || value.length > maximum + || /[\u0000-\u001f\u007f]/.test(value) + ) { + throw invalid( + field, + `non-empty text no longer than ${maximum} characters`, + ); + } + return value; +} + +function optionalText( + value: unknown, + maximum: number, + field: string, +): string | undefined { + return value === undefined || value === null + ? undefined + : text(value, field, maximum); +} + +function object(value: unknown, field: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw invalid(field, "an object"); + } + return value as Record; +} + +function array(value: unknown, field: string): unknown[] { + if (!Array.isArray(value) || value.length > 1000) { + throw invalid(field, "an array with at most 1000 entries"); + } + return value; +} + +function invalid(field: string, expected: string): ChainError { + return new ChainError( + "provider_error", + `GasFree ${field} must be ${expected}`, + ); +} + +async function readBoundedText( + response: Response, + maximum: number, +): Promise { + if (!response.body) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let output = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maximum) { + throw new ChainError( + "provider_error", + "GasFree response exceeds the 1 MiB limit", + ); + } + output += decoder.decode(value, { stream: true }); + } + return output + decoder.decode(); + } finally { + reader.releaseLock(); + } +} + +function normalizeLossless(value: unknown): unknown { + if (isLosslessNumber(value)) { + const exact = value.toString(); + const numeric = Number(exact); + return Number.isSafeInteger(numeric) ? numeric : exact; + } + if (Array.isArray(value)) return value.map(normalizeLossless); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + normalizeLossless(item), + ]), + ); + } + return value; +} diff --git a/ts/src/application/ports/gasfree-provider.ts b/ts/src/application/ports/gasfree-provider.ts new file mode 100644 index 000000000..06e630e0c --- /dev/null +++ b/ts/src/application/ports/gasfree-provider.ts @@ -0,0 +1,20 @@ +import type { + GasFreeAddressInfo, + GasFreeProviderConfig, + GasFreeTokenConfig, + GasFreeTransferRecord, + NetworkDescriptor, + SignedGasFreeAuthorization, +} from "../../domain/types/index.js"; + +/** Outbound boundary for the official GasFree Open Platform. */ +export interface GasFreeProvider { + listTokens(network: NetworkDescriptor): Promise; + listProviders(network: NetworkDescriptor): Promise; + getAddress(network: NetworkDescriptor, ownerAddress: string): Promise; + submitTransfer( + network: NetworkDescriptor, + authorization: SignedGasFreeAuthorization, + ): Promise; + trace(network: NetworkDescriptor, traceId: string): Promise; +} diff --git a/ts/src/application/use-cases/config-service.test.ts b/ts/src/application/use-cases/config-service.test.ts index 4bf48c3ed..f3eb40efd 100644 --- a/ts/src/application/use-cases/config-service.test.ts +++ b/ts/src/application/use-cases/config-service.test.ts @@ -92,3 +92,28 @@ describe("ConfigService TronLink credentials", () => { expect(update).not.toHaveBeenCalled(); }); }); + +describe("ConfigService GasFree credentials", () => { + it("writes the documented flat keys and masks the API secret", () => { + const { svc } = service(); + expect(svc.execute( + { key: "gasfreeApiKey", value: "TEST" }, + effective, + networks, + )).toMatchObject({ key: "gasfreeApiKey", value: "TEST" }); + expect(svc.execute( + { key: "gasfreeApiSecret", value: "TESTTESTTEST" }, + effective, + networks, + )).toMatchObject({ key: "gasfreeApiSecret", value: "********" }); + }); + + it("never returns an effective API secret in config views", () => { + const configured = { ...effective, gasfreeApiSecret: "secret" }; + const { svc } = service(); + expect(svc.execute({}, configured, networks)) + .toMatchObject({ gasfreeApiSecret: "********" }); + expect(svc.execute({ key: "gasfreeApiSecret" }, configured, networks)) + .toEqual({ key: "gasfreeApiSecret", value: "********" }); + }); +}); diff --git a/ts/src/application/use-cases/config-service.ts b/ts/src/application/use-cases/config-service.ts index 359f26e48..d37f09d35 100644 --- a/ts/src/application/use-cases/config-service.ts +++ b/ts/src/application/use-cases/config-service.ts @@ -8,6 +8,10 @@ export const TRONLINK_CONFIG_KEYS = [ "tronlinkSecretKey", "tronlinkChannel", ] as const; +export const GASFREE_CONFIG_KEYS = [ + "gasfreeApiKey", + "gasfreeApiSecret", +] as const; export const CONFIG_KEYS = [ "defaultNetwork", "defaultOutput", @@ -15,6 +19,7 @@ export const CONFIG_KEYS = [ "waitTimeoutMs", "networks", ...TRONLINK_CONFIG_KEYS, + ...GASFREE_CONFIG_KEYS, ] as const; export const WRITABLE_CONFIG_KEYS = [ "defaultNetwork", @@ -22,6 +27,7 @@ export const WRITABLE_CONFIG_KEYS = [ "timeoutMs", "waitTimeoutMs", ...TRONLINK_CONFIG_KEYS, + ...GASFREE_CONFIG_KEYS, ] as const; export type ConfigKey = (typeof CONFIG_KEYS)[number]; export type WritableConfigKey = (typeof WRITABLE_CONFIG_KEYS)[number]; @@ -48,6 +54,8 @@ export class ConfigService { tronlinkSecretId: effective.tronlinkSecretId, tronlinkSecretKey: maskSecret(effective.tronlinkSecretKey), tronlinkChannel: effective.tronlinkChannel, + gasfreeApiKey: effective.gasfreeApiKey, + gasfreeApiSecret: maskSecret(effective.gasfreeApiSecret), }; if (input.key === undefined) return view; if (input.value === undefined) return { key: input.key, value: view[input.key] }; @@ -57,7 +65,7 @@ export class ConfigService { const key = input.key as WritableConfigKey; const value = this.normalize(key, input.value, networks); - if (key === "tronlinkSecretKey") { + if (key === "tronlinkSecretKey" || key === "gasfreeApiSecret") { return this.documents.update((current) => ({ document: { ...current, [key]: value }, result: { key, value: maskSecret(String(value)), input: "********" }, @@ -94,7 +102,10 @@ export class ConfigService { } return raw; } - if ((TRONLINK_CONFIG_KEYS as readonly string[]).includes(key)) { + if ( + (TRONLINK_CONFIG_KEYS as readonly string[]).includes(key) + || (GASFREE_CONFIG_KEYS as readonly string[]).includes(key) + ) { if (raw.length === 0 || raw.length > 256 || /[\u0000-\u001f\u007f]/.test(raw)) { throw new UsageError("invalid_value", `${key} must be 1 to 256 characters without control characters`); } diff --git a/ts/src/application/use-cases/tron/gasfree-service.test.ts b/ts/src/application/use-cases/tron/gasfree-service.test.ts new file mode 100644 index 000000000..6822cfa12 --- /dev/null +++ b/ts/src/application/use-cases/tron/gasfree-service.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GasFreeProvider } from "../../ports/gasfree-provider.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { SignerResolver } from "../../services/signer/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { + NetworkDescriptor, + SignedGasFreeAuthorization, +} from "../../../domain/types/index.js"; +import { GasFreeService } from "./gasfree-service.js"; + +const OWNER = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const TOKEN = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"; +const PROVIDER = "TKtWbdzEq5ss9vTS9kwRhBp5mXmBfBns3E"; +const GASFREE = "TNER12mMVWruqopsW9FQtKxCGfZcEtb3ER"; +const RECEIVER = "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT"; +const DIGEST = + "0x006c1bfb7e397bc2975949b80aa099a33a69a9b8835d84a9714723d25652f5ff"; +const SIGNATURE = + "0x580aab9832cf56d8f418711aa55653a02e51fb97a04fcd09c3bd4cd41cd376f73336a48257ca0d74bbcb8f70cc1bf6e310ca31f167a2ef8b2b93317d9f9a68e31b"; +const NETWORK = { + id: "tron:nile", + family: "tron", + chainId: "nile", + aliases: ["nile"], + capabilities: [], + gasfree: { + baseUrl: "https://open-test.gasfree.io", + apiPrefix: "/nile", + controllerChainId: "3448148188", + verifyingContract: "THQGuFzL87ZqhxkgqYEryRAd7gqFqL5rdc", + }, +} satisfies NetworkDescriptor; + +function scope(wait = false): TransactionScope { + return { + activeAccount: "wlt_test", + resolveAddress: () => OWNER, + timeoutMs: 1000, + wait, + waitTimeoutMs: 1000, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function fixture(options: { + active?: boolean; + balance?: string; + mismatchReceipt?: boolean; + badDigest?: boolean; + expiredAtSeconds?: boolean; + terminal?: "success" | "fee-exceeded" | "mutated-recipient"; +} = {}) { + let submittedAuthorization: SignedGasFreeAuthorization | undefined; + const submitTransfer = vi.fn( + async ( + _network: NetworkDescriptor, + authorization: SignedGasFreeAuthorization, + ) => { + submittedAuthorization = authorization; + return { + id: "trace-1", + state: "WAITING" as const, + tokenAddress: authorization.token, + providerAddress: authorization.serviceProvider, + accountAddress: authorization.user, + gasFreeAddress: GASFREE, + targetAddress: options.mismatchReceipt + ? OWNER + : authorization.receiver, + amount: authorization.value, + maxFee: authorization.maxFee, + nonce: authorization.nonce, + expiredAt: options.expiredAtSeconds + ? authorization.deadline + : `${authorization.deadline}000`, + }; + }, + ); + const provider = { + listTokens: vi.fn(async () => [{ + tokenAddress: TOKEN, + activateFee: "1000000", + transferFee: "500000", + symbol: "USDT", + decimals: 6, + }]), + listProviders: vi.fn(async () => [{ + address: PROVIDER, + defaultDeadlineDuration: "60", + }]), + getAddress: vi.fn(async () => ({ + ownerAddress: OWNER, + gasFreeAddress: GASFREE, + active: options.active ?? false, + nonce: "8", + allowSubmit: true, + assets: [{ + tokenAddress: TOKEN, + activateFee: "1000000", + transferFee: "500000", + }], + })), + submitTransfer, + trace: vi.fn(async () => { + const authorization = submittedAuthorization!; + const transferFee = + options.terminal === "fee-exceeded" ? "1600000" : "400000"; + const activateFee = options.active ? "0" : "900000"; + const totalFee = (BigInt(transferFee) + BigInt(activateFee)).toString(); + return { + id: "trace-1", + state: "SUCCEED" as const, + tokenAddress: authorization.token, + providerAddress: authorization.serviceProvider, + accountAddress: authorization.user, + gasFreeAddress: GASFREE, + targetAddress: + options.terminal === "mutated-recipient" + ? OWNER + : authorization.receiver, + amount: authorization.value, + maxFee: authorization.maxFee, + nonce: authorization.nonce, + expiredAt: `${authorization.deadline}000`, + txnHash: "ab".repeat(32), + txnAmount: authorization.value, + txnTransferFee: transferFee, + txnActivateFee: activateFee, + txnTotalFee: totalFee, + txnTotalCost: + (BigInt(authorization.value) + BigInt(totalFee)).toString(), + }; + }), + } as unknown as GasFreeProvider; + const gateway = { + getTokenInfo: vi.fn(async () => ({ symbol: "USDT", decimals: 6 })), + getTrc20Balance: vi.fn( + async () => options.balance ?? "100000000", + ), + }; + const gateways = { + get: () => gateway, + } as unknown as ChainGatewayProvider; + const signer = { + kind: "software" as const, + address: OWNER, + sign: vi.fn(), + signMessage: vi.fn(), + signTypedData: vi.fn(async () => ({ + signature: SIGNATURE, + digest: options.badDigest ? `0x${"00".repeat(32)}` : DIGEST, + primaryType: "PermitTransfer", + })), + }; + const signers = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => signer), + } as unknown as SignerResolver; + let now = 1_700_000_000_000; + const service = new GasFreeService( + provider, + gateways, + signers, + () => now, + async (milliseconds) => { + now += milliseconds; + }, + ); + return { service, provider, submitTransfer, signer, signers }; +} + +describe("GasFreeService", () => { + it("dry-run checks the complete first-transfer cost without signing", async () => { + const fixtureValue = fixture({ active: false }); + const result = await fixtureValue.service.transfer(scope(), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: true, + }); + expect(result).toMatchObject({ + stage: "dry-run", + amount: "25000000", + serviceFee: "500000", + activateFee: "1000000", + authorizedMaxFee: "1500000", + totalDeducted: "26500000", + nonce: "8", + deadline: "1700000060", + }); + expect(fixtureValue.signers.assertCanSign).not.toHaveBeenCalled(); + expect(fixtureValue.signer.signTypedData).not.toHaveBeenCalled(); + expect(fixtureValue.submitTransfer).not.toHaveBeenCalled(); + }); + + it("signs the exact TIP-712 digest and accepts Java millisecond expiry", async () => { + const fixtureValue = fixture({ active: true }); + const result = await fixtureValue.service.transfer(scope(), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }); + expect(result).toMatchObject({ + stage: "submitted", + traceId: "trace-1", + activateFee: "0", + totalDeducted: "25500000", + }); + expect(fixtureValue.signer.signTypedData).toHaveBeenCalledOnce(); + expect(fixtureValue.submitTransfer).toHaveBeenCalledWith( + NETWORK, + expect.objectContaining({ sig: SIGNATURE.slice(2) }), + ); + }); + + it("rejects a signer-reported digest mismatch before submission", async () => { + const fixtureValue = fixture({ badDigest: true }); + await expect( + fixtureValue.service.transfer(scope(), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "signing_rejected" }); + expect(fixtureValue.submitTransfer).not.toHaveBeenCalled(); + }); + + it("rejects a receipt that differs from the signed authorization", async () => { + const fixtureValue = fixture({ mismatchReceipt: true }); + await expect( + fixtureValue.service.transfer(scope(), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "gasfree_integrity" }); + }); + + it("fails before signing when the balance cannot cover amount plus fees", async () => { + const fixtureValue = fixture({ balance: "100" }); + await expect( + fixtureValue.service.transfer(scope(), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "insufficient_token_balance" }); + expect(fixtureValue.signer.signTypedData).not.toHaveBeenCalled(); + }); + + it("revalidates the polled record and returns final charged fees", async () => { + const fixtureValue = fixture({ + active: false, + terminal: "success", + }); + const result = await fixtureValue.service.transfer(scope(true), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }); + expect(result).toMatchObject({ + stage: "confirmed", + state: "SUCCEED", + txId: "ab".repeat(32), + serviceFee: "400000", + activateFee: "900000", + totalDeducted: "26300000", + }); + }); + + it("rejects a final provider fee above the signed maxFee", async () => { + const fixtureValue = fixture({ + active: true, + terminal: "fee-exceeded", + }); + await expect( + fixtureValue.service.transfer(scope(true), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "gasfree_integrity" }); + }); + + it("rejects signed transfer fields that mutate while polling", async () => { + const fixtureValue = fixture({ terminal: "mutated-recipient" }); + await expect( + fixtureValue.service.transfer(scope(true), NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "gasfree_integrity" }); + }); +}); diff --git a/ts/src/application/use-cases/tron/gasfree-service.ts b/ts/src/application/use-cases/tron/gasfree-service.ts new file mode 100644 index 000000000..bcc2ef926 --- /dev/null +++ b/ts/src/application/use-cases/tron/gasfree-service.ts @@ -0,0 +1,611 @@ +import { bytesToHex } from "@noble/hashes/utils.js"; +import type { GasFreeProvider } from "../../ports/gasfree-provider.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { SignerResolver } from "../../services/signer/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { + GasFreeAddressInfo, + GasFreeAuthorization, + GasFreeInfoView, + GasFreeProviderConfig, + GasFreeTokenConfig, + GasFreeTraceView, + GasFreeTransferRecord, + GasFreeTransferView, + NetworkDescriptor, +} from "../../../domain/types/index.js"; +import { ChainError, UsageError } from "../../../domain/errors/index.js"; +import { + gasFreeDigest, + gasFreeTypedData, + normalizeGasFreeSignature, + recoverGasFreeSigner, +} from "../../../domain/gasfree/index.js"; +import { toBaseUnits } from "../../../domain/amounts/index.js"; +import { TronAddress } from "../../../domain/address/index.js"; +import { obtainSignature } from "../../services/signing/obtain-signature.js"; + +const MAX_DEADLINE_SECONDS = 86_400n; +const POLL_MS = 1_000; +const ADDRESS = new TronAddress(); + +export interface GasFreeTransferInput { + to: string; + amount: string; + token: string; + dryRun: boolean; +} + +interface ResolvedGasFreeToken extends GasFreeTokenConfig { + symbol: string; + decimals: number; +} + +export class GasFreeService { + constructor( + private readonly provider: GasFreeProvider, + private readonly gateways: ChainGatewayProvider, + private readonly signers: SignerResolver, + private readonly clock: () => number = () => Date.now(), + private readonly sleep: (milliseconds: number) => Promise = ( + milliseconds, + ) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + ) {} + + async info( + scope: TransactionScope, + network: NetworkDescriptor, + ): Promise { + const owner = scope.resolveAddress("tron"); + const gateway = this.gateways.get(network, "tron"); + const [addressInfo, tokens] = await Promise.all([ + this.provider.getAddress(network, owner), + this.#tokens(network), + ]); + if (addressInfo.ownerAddress !== owner) { + throw new ChainError( + "gasfree_integrity", + "GasFree address response owner does not match the selected account", + ); + } + const byAddress = new Map( + tokens.map((token) => [token.tokenAddress, token]), + ); + const views = await Promise.all( + addressInfo.assets.map(async (asset) => { + const token = byAddress.get(asset.tokenAddress); + if ( + !token + || token.activateFee !== asset.activateFee + || token.transferFee !== asset.transferFee + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree token and address fee metadata disagree", + ); + } + return { + symbol: token.symbol, + address: token.tokenAddress, + decimals: token.decimals, + activateFee: token.activateFee, + transferFee: token.transferFee, + balance: await gateway.getTrc20Balance( + token.tokenAddress, + addressInfo.gasFreeAddress, + ), + }; + }), + ); + return { + ownerAddress: owner, + gasFreeAddress: addressInfo.gasFreeAddress, + active: addressInfo.active, + nonce: addressInfo.nonce, + tokens: views, + }; + } + + async transfer( + scope: TransactionScope, + network: NetworkDescriptor, + input: GasFreeTransferInput, + ): Promise { + if (input.dryRun && scope.wait) { + throw new UsageError( + "invalid_option", + "--wait cannot be used with --dry-run", + ); + } + const metadata = network.gasfree; + if (!metadata) { + throw new UsageError( + "unsupported_network", + `network ${network.id} does not support GasFree`, + ); + } + const owner = scope.resolveAddress("tron"); + if (!ADDRESS.validate(input.to)) { + throw new UsageError( + "invalid_value", + "--to must be a valid TRON address or contact name", + ); + } + const [addressInfo, tokens, providers] = await Promise.all([ + this.provider.getAddress(network, owner), + this.#tokens(network), + this.provider.listProviders(network), + ]); + if (addressInfo.ownerAddress !== owner) { + throw new ChainError( + "gasfree_integrity", + "GasFree address response owner does not match the selected account", + ); + } + const token = selectToken(tokens, input.token); + const provider = selectProvider(providers); + const duration = BigInt(provider.defaultDeadlineDuration); + if (duration <= 0n || duration > MAX_DEADLINE_SECONDS) { + throw new ChainError( + "gasfree_integrity", + "GasFree provider deadline duration is outside the accepted range", + ); + } + const asset = selectAsset(addressInfo, token); + const value = toBaseUnits(input.amount, token.decimals, token.symbol); + if (BigInt(value) <= 0n) { + throw new UsageError( + "invalid_amount", + "--amount must be greater than zero", + ); + } + const activateFee = addressInfo.active ? "0" : asset.activateFee; + // Java signs the upper bound activateFee + transferFee even after activation. + const authorizedMaxFee = add(asset.activateFee, asset.transferFee); + const totalDeducted = add(value, activateFee, asset.transferFee); + const gateway = this.gateways.get(network, "tron"); + const balance = await gateway.getTrc20Balance( + token.tokenAddress, + addressInfo.gasFreeAddress, + ); + if (BigInt(balance) < BigInt(totalDeducted)) { + throw new ChainError( + "insufficient_token_balance", + "GasFree token balance is below transfer amount plus fees", + { balance, required: totalDeducted }, + ); + } + const authorization: GasFreeAuthorization = { + token: token.tokenAddress, + serviceProvider: provider.address, + user: owner, + receiver: input.to, + value, + maxFee: authorizedMaxFee, + deadline: String(BigInt(Math.floor(this.clock() / 1000)) + duration), + version: "1", + nonce: addressInfo.nonce, + }; + const base = transferView( + "dry-run", + authorization, + token, + addressInfo, + activateFee, + totalDeducted, + ); + if (input.dryRun) return base; + if (addressInfo.allowSubmit === false) { + throw new ChainError( + "gasfree_rejected", + "GasFree address is not currently allowed to submit transfers", + ); + } + + this.signers.assertCanSign(scope.activeAccount, "tron"); + const signer = this.signers.resolve(scope.activeAccount, "tron"); + if (signer.address !== owner) { + throw new UsageError( + "invalid_account", + "resolved signer address changed during GasFree construction", + ); + } + const domain = { + controllerChainId: metadata.controllerChainId, + verifyingContract: metadata.verifyingContract, + }; + const expectedDigest = gasFreeDigest(domain, authorization); + const signed = await obtainSignature( + signer, + scope, + (options) => signer.signTypedData( + gasFreeTypedData(domain, authorization), + options, + ), + ); + const reportedDigest = signed.digest.replace(/^0x/i, "").toLowerCase(); + if ( + !/^[0-9a-f]{64}$/.test(reportedDigest) + || reportedDigest !== bytesToHex(expectedDigest) + || signed.primaryType !== "PermitTransfer" + ) { + throw new ChainError( + "signing_rejected", + "GasFree signer did not sign the expected PermitTransfer digest", + ); + } + let signature: string; + let recovered: string; + try { + signature = normalizeGasFreeSignature(signed.signature); + recovered = recoverGasFreeSigner(expectedDigest, signature); + } catch { + throw new ChainError( + "signing_rejected", + "GasFree signer returned an invalid signature", + ); + } + if (recovered !== owner) { + throw new ChainError( + "signing_rejected", + "GasFree signature does not recover to the selected account", + ); + } + + const submitted = await this.provider.submitTransfer(network, { + ...authorization, + sig: signature, + }); + assertAccepted(submitted, authorization, addressInfo.gasFreeAddress); + const accepted: GasFreeTransferView = { + ...base, + stage: "submitted", + traceId: submitted.id, + state: submitted.state, + }; + if (!scope.wait) return accepted; + const terminal = await this.#wait( + network, + submitted, + authorization, + addressInfo.gasFreeAddress, + scope.waitTimeoutMs, + ); + if (!terminal) { + scope.warn( + `--wait: GasFree transfer ${submitted.id} did not finish within ${scope.waitTimeoutMs}ms; returning submitted`, + ); + return accepted; + } + const settledAmount = terminal.txnAmount ?? accepted.amount; + const settledServiceFee = + terminal.txnTransferFee ?? accepted.serviceFee; + const settledActivateFee = + terminal.txnActivateFee ?? accepted.activateFee; + return { + ...accepted, + stage: terminal.state === "SUCCEED" ? "confirmed" : "failed", + state: terminal.state, + amount: settledAmount, + serviceFee: settledServiceFee, + activateFee: settledActivateFee, + totalDeducted: + terminal.txnTotalCost + ?? add(settledAmount, settledServiceFee, settledActivateFee), + ...(terminal.txnHash ? { txId: terminal.txnHash } : {}), + ...(terminal.failureReason + ? { failureReason: terminal.failureReason } + : {}), + }; + } + + async trace( + network: NetworkDescriptor, + traceId: string, + ): Promise { + const record = await this.provider.trace(network, traceId); + assertFeeIntegrity(record, record.maxFee); + const token = (await this.#tokens(network)).find( + (item) => item.tokenAddress === record.tokenAddress, + ); + if (!token) { + throw new ChainError( + "gasfree_integrity", + "trace token is not in the current GasFree token configuration", + ); + } + const serviceFee = + record.txnTransferFee ?? record.estimatedTransferFee ?? "0"; + const activateFee = + record.txnActivateFee ?? record.estimatedActivateFee ?? "0"; + return { + traceId: record.id, + state: record.state, + ...(record.txnHash ? { txId: record.txnHash } : {}), + token: token.symbol, + tokenAddress: token.tokenAddress, + decimals: token.decimals, + amount: record.txnAmount ?? record.amount, + serviceFee, + activateFee, + totalDeducted: + record.txnTotalCost + ?? record.estimatedTotalCost + ?? add(record.amount, serviceFee, activateFee), + from: record.gasFreeAddress, + owner: record.accountAddress, + to: record.targetAddress, + nonce: record.nonce, + ...(record.failureReason + ? { failureReason: record.failureReason } + : {}), + }; + } + + async #tokens( + network: NetworkDescriptor, + ): Promise { + const gateway = this.gateways.get(network, "tron"); + const tokens = await this.provider.listTokens(network); + return Promise.all( + tokens.map(async (token) => { + const info = await gateway.getTokenInfo(token.tokenAddress); + const symbol = + typeof info.symbol === "string" ? info.symbol : undefined; + const decimals = info.decimals; + if ( + !symbol + || decimals === undefined + || !Number.isInteger(decimals) + || decimals < 0 + || decimals > 255 + ) { + throw new ChainError( + "gasfree_integrity", + `token metadata is incomplete for ${token.tokenAddress}`, + ); + } + if ( + (token.symbol !== undefined && token.symbol !== symbol) + || (token.decimals !== undefined && token.decimals !== decimals) + ) { + throw new ChainError( + "gasfree_integrity", + `provider token metadata disagrees with the TRC20 contract for ${token.tokenAddress}`, + ); + } + return { ...token, symbol, decimals }; + }), + ); + } + + async #wait( + network: NetworkDescriptor, + initial: GasFreeTransferRecord, + authorization: GasFreeAuthorization, + gasFreeAddress: string, + timeoutMs: number, + ): Promise { + const deadline = this.clock() + Math.max(0, timeoutMs); + let previous = initial.state; + if (previous === "SUCCEED" || previous === "FAILED") return initial; + for (;;) { + const remaining = deadline - this.clock(); + if (remaining <= 0) return undefined; + await this.sleep(Math.min(POLL_MS, remaining)); + const current = await this.provider.trace(network, initial.id); + if (current.id !== initial.id) { + throw new ChainError( + "gasfree_integrity", + "GasFree trace id changed while polling", + ); + } + assertAccepted(current, authorization, gasFreeAddress); + if (stateRank(current.state) < stateRank(previous)) { + throw new ChainError( + "gasfree_integrity", + "GasFree state regressed while polling", + ); + } + previous = current.state; + if (current.state === "SUCCEED" || current.state === "FAILED") { + return current; + } + } + } +} + +function selectToken( + tokens: ResolvedGasFreeToken[], + symbol: string, +): ResolvedGasFreeToken { + const matches = tokens.filter( + (token) => token.symbol.toLowerCase() === symbol.toLowerCase(), + ); + if (matches.length === 0) { + throw new UsageError( + "unsupported_token", + `GasFree provider does not support token: ${symbol}`, + ); + } + if (matches.length > 1) { + throw new ChainError( + "gasfree_integrity", + `GasFree token symbol is ambiguous: ${symbol}`, + ); + } + return matches[0]!; +} + +/** Java selects the first provider returned by the authenticated configuration endpoint. */ +function selectProvider( + providers: GasFreeProviderConfig[], +): GasFreeProviderConfig { + if (providers.length === 0) { + throw new ChainError( + "gasfree_integrity", + "GasFree provider configuration is empty", + ); + } + return providers[0]!; +} + +function selectAsset( + address: GasFreeAddressInfo, + token: ResolvedGasFreeToken, +) { + const matches = address.assets.filter( + (asset) => asset.tokenAddress === token.tokenAddress, + ); + if (matches.length !== 1) { + throw new ChainError( + "gasfree_integrity", + "GasFree address asset configuration is missing or ambiguous", + ); + } + const asset = matches[0]!; + if ( + asset.activateFee !== token.activateFee + || asset.transferFee !== token.transferFee + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree fee metadata disagrees across endpoints", + ); + } + return asset; +} + +function transferView( + stage: GasFreeTransferView["stage"], + authorization: GasFreeAuthorization, + token: ResolvedGasFreeToken, + address: GasFreeAddressInfo, + activateFee: string, + totalDeducted: string, +): GasFreeTransferView { + return { + kind: "gasfree-transfer", + stage, + token: token.symbol, + tokenAddress: token.tokenAddress, + decimals: token.decimals, + amount: authorization.value, + serviceFee: token.transferFee, + activateFee, + authorizedMaxFee: authorization.maxFee, + totalDeducted, + owner: authorization.user, + from: address.gasFreeAddress, + to: authorization.receiver, + serviceProvider: authorization.serviceProvider, + nonce: authorization.nonce, + deadline: authorization.deadline, + }; +} + +function assertAccepted( + record: GasFreeTransferRecord, + authorization: GasFreeAuthorization, + gasFreeAddress: string, +): void { + const deadlineMatches = + record.expiredAt === undefined + || record.expiredAt === authorization.deadline + || record.expiredAt === `${authorization.deadline}000`; + if ( + record.tokenAddress !== authorization.token + || record.providerAddress !== authorization.serviceProvider + || record.accountAddress !== authorization.user + || record.targetAddress !== authorization.receiver + || record.gasFreeAddress !== gasFreeAddress + || record.amount !== authorization.value + || record.nonce !== authorization.nonce + || (record.maxFee !== undefined + && record.maxFee !== authorization.maxFee) + || !deadlineMatches + || (record.txnAmount !== undefined + && record.txnAmount !== authorization.value) + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree submit receipt does not match the signed authorization", + ); + } + assertFeeIntegrity(record, authorization.maxFee); +} + +/** Provider estimates and final charges are untrusted until bounded by signed maxFee. */ +function assertFeeIntegrity( + record: GasFreeTransferRecord, + authorizedMaxFee?: string, +): void { + const feeSets = [ + [ + record.estimatedTransferFee, + record.estimatedActivateFee, + record.estimatedTotalFee, + record.estimatedTotalCost, + ], + [ + record.txnTransferFee, + record.txnActivateFee, + record.txnTotalFee, + record.txnTotalCost, + ], + ] as const; + for (const [transfer, activate, totalFee, totalCost] of feeSets) { + const componentTotal = + transfer !== undefined && activate !== undefined + ? BigInt(transfer) + BigInt(activate) + : undefined; + const observedTotal = + totalFee === undefined ? componentTotal : BigInt(totalFee); + if ( + observedTotal !== undefined + && authorizedMaxFee !== undefined + && observedTotal > BigInt(authorizedMaxFee) + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree provider fee exceeds the signed maxFee", + ); + } + if ( + componentTotal !== undefined + && totalFee !== undefined + && componentTotal !== BigInt(totalFee) + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree provider fee components do not match the total fee", + ); + } + if ( + observedTotal !== undefined + && totalCost !== undefined + && BigInt(record.amount) + observedTotal !== BigInt(totalCost) + ) { + throw new ChainError( + "gasfree_integrity", + "GasFree provider total cost does not match amount plus fees", + ); + } + } +} + +function add(...values: string[]): string { + return values + .reduce((sum, value) => sum + BigInt(value), 0n) + .toString(); +} + +function stateRank(state: GasFreeTransferRecord["state"]): number { + return { + WAITING: 0, + INPROGRESS: 1, + CONFIRMING: 2, + SUCCEED: 3, + FAILED: 3, + }[state]; +} diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index 2736462f6..3348ad9e9 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -30,6 +30,7 @@ import { WalletService } from "../application/use-cases/wallet-service.js"; import { FAMILY_REGISTRY, familyMap } from "./family-registry.js"; import { registerTronChainCommands } from "./families/tron.js"; import { TronLinkClient } from "../adapters/outbound/tronlink/client.js"; +import { GasFreeClient } from "../adapters/outbound/gasfree/client.js"; export interface BootstrapOptions { readonly globals: Globals; @@ -88,12 +89,14 @@ export function composeCliRuntime(options: BootstrapOptions) { accounts: keystore, timeoutMs, tronlink: new TronLinkClient(config, timeoutMs), + gasfree: new GasFreeClient(config, timeoutMs), }); const capabilitiesByFamily = registry.capabilityKeysByFamily(); for (const network of Object.values(config.networks)) { const commandCapabilities = (capabilitiesByFamily.get(network.family) ?? []) .filter((key) => key !== "tx.multisig.tronlink" || Boolean(network.tronlinkHttpEndpoint)) + .filter((key) => !key.startsWith("gasfree.") || Boolean(network.gasfree)) .map((key) => ({ key, summary: CAP_SUMMARIES[key] ?? key, diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 60dab2175..088fada50 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -99,6 +99,16 @@ import type { AccountStore } from "../../application/ports/account-store.js"; import { TransactionArtifactWriter } from "../../adapters/outbound/persistence/transaction-artifact-writer.js"; import type { FamilyPlugin } from "./types.js"; import type { TronLinkCollaborationPort } from "../../application/ports/tronlink-collaboration.js"; +import type { GasFreeProvider } from "../../application/ports/gasfree-provider.js"; +import { GasFreeService } from "../../application/use-cases/tron/gasfree-service.js"; +import { + gasFreeInfoSpec, + gasFreeInfoTronBinding, + gasFreeTraceSpec, + gasFreeTraceTronBinding, + gasFreeTransferSpec, + gasFreeTransferTronBinding, +} from "../../adapters/inbound/cli/commands/gasfree.js"; export const tronFamily: FamilyPlugin<"tron"> = { meta: FAMILIES.tron, @@ -115,6 +125,7 @@ export interface TronChainCommandDependencies { accounts: AccountStore; timeoutMs: number; tronlink: TronLinkCollaborationPort; + gasfree: GasFreeProvider; } export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainCommandDependencies): void { @@ -130,6 +141,7 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC const transaction = new TronTransactionService(deps.gateways, deps.tokens, deps.transactions); const multisig = new TronMultisigService(deps.gateways, deps.signers); const tronlink = new TronLinkMultisigService(deps.tronlink, deps.gateways, multisig); + const gasfree = new GasFreeService(deps.gasfree, deps.gateways, deps.signers); const permission = new TronPermissionService(deps.gateways, deps.accounts, deps.transactions); const stake = new TronStakeService(deps.gateways, deps.transactions); const vote = new TronVoteService(deps.gateways, deps.transactions, stake); @@ -157,6 +169,9 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC )); reg.addChain(txApprovalsSpec, "tron", txApprovalsTronBinding(multisig)); reg.addChain(txTronLinkMultisigSpec, "tron", txTronLinkMultisigBinding(tronlink)); + reg.addChain(gasFreeInfoSpec, "tron", gasFreeInfoTronBinding(gasfree)); + reg.addChain(gasFreeTransferSpec, "tron", gasFreeTransferTronBinding(gasfree)); + reg.addChain(gasFreeTraceSpec, "tron", gasFreeTraceTronBinding(gasfree)); reg.addChain(txBroadcastSpec, "tron", txBroadcastTronBinding(multisig)); reg.addChain(txStatusSpec, "tron", txStatusTronBinding(transaction)); reg.addChain(txInfoSpec, "tron", txInfoTronBinding(transaction)); diff --git a/ts/src/domain/address/index.ts b/ts/src/domain/address/index.ts index fe5cac927..29e1ab753 100644 --- a/ts/src/domain/address/index.ts +++ b/ts/src/domain/address/index.ts @@ -6,6 +6,7 @@ import { keccak_256 } from "@noble/hashes/sha3.js" import { sha256 } from "@noble/hashes/sha2.js" import { createBase58check } from "@scure/base" import { hexToBytes } from "@noble/hashes/utils.js" +import { secp256k1 } from "@noble/curves/secp256k1.js" import type { Bytes, ChainFamily } from "../types/index.js" export interface AddressCodec { @@ -18,7 +19,18 @@ const b58c = createBase58check(sha256) /** uncompressed (65B, 0x04…) or compressed pubkey → last-20-bytes keccak hash. */ function pubKeyHash20(pub: Bytes): Bytes { - const body = pub.length === 65 ? pub.slice(1) : pub // strip 0x04 prefix + let uncompressed = pub + if (pub.length === 33) { + try { + uncompressed = secp256k1.Point.fromBytes(pub).toBytes(false) + } catch { + throw new Error("invalid compressed secp256k1 public key") + } + } + if (uncompressed.length !== 65 || uncompressed[0] !== 0x04) { + throw new Error("public key must be compressed or uncompressed secp256k1") + } + const body = uncompressed.slice(1) return keccak_256(body).slice(-20) } @@ -41,6 +53,12 @@ export class TronAddress implements AddressCodec { } } +/** Decode and validate a Base58Check TRON address as its 21-byte 0x41-prefixed payload. */ +export function tronAddressBytes(address: string): Bytes { + if (!new TronAddress().validate(address)) throw new Error("invalid TRON address"); + return b58c.decode(address); +} + /** Convert a 41-prefixed TRON hex address to base58; preserve non-hex values unchanged. */ export function tronHexToBase58(address: unknown): string { const value = String(address ?? ""); diff --git a/ts/src/domain/gasfree/gasfree.test.ts b/ts/src/domain/gasfree/gasfree.test.ts new file mode 100644 index 000000000..5e6c9f3f0 --- /dev/null +++ b/ts/src/domain/gasfree/gasfree.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { utils as tronUtils } from "tronweb"; +import { TronAddress } from "../address/index.js"; +import type { GasFreeAuthorization } from "../types/index.js"; +import { + gasFreeDigest, + gasFreeDomainSeparator, + gasFreeMessageHash, + gasFreeTypedData, + normalizeGasFreeSignature, + recoverGasFreeSigner, +} from "./index.js"; + +const DOMAIN = { + controllerChainId: "3448148188", + verifyingContract: "THQGuFzL87ZqhxkgqYEryRAd7gqFqL5rdc", +}; +const AUTHORIZATION: GasFreeAuthorization = { + token: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + serviceProvider: "TKtWbdzEq5ss9vTS9kwRhBp5mXmBfBns3E", + user: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + receiver: "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT", + value: "25000000", + maxFee: "1500000", + deadline: "1700000060", + version: "1", + nonce: "8", +}; +const SIGNATURE = + "0x580aab9832cf56d8f418711aa55653a02e51fb97a04fcd09c3bd4cd41cd376f73336a48257ca0d74bbcb8f70cc1bf6e310ca31f167a2ef8b2b93317d9f9a68e31b"; + +describe("GasFree Java-compatible TIP-712", () => { + it("matches fixed domain, struct, and final digest vectors", () => { + expect(bytesToHex(gasFreeDomainSeparator(DOMAIN))).toBe( + "31a0a46f427dd040c91835228e4555951bde0a894cae6239869bb680ebc6ebea", + ); + expect(bytesToHex(gasFreeMessageHash(AUTHORIZATION))).toBe( + "e028dcd1dc81a93cb32a9ac32f1799614d4431c83f0ed5c6c2f932b48f22e614", + ); + expect(bytesToHex(gasFreeDigest(DOMAIN, AUTHORIZATION))).toBe( + "006c1bfb7e397bc2975949b80aa099a33a69a9b8835d84a9714723d25652f5ff", + ); + }); + + it("matches TronWeb TIP-712 hashing and recovers the Java r||s||v signer", () => { + const payload = gasFreeTypedData(DOMAIN, AUTHORIZATION); + const digest = tronUtils.typedData.TypedDataEncoder.hash( + payload.domain as never, + payload.types as never, + payload.message, + ); + expect(digest).toBe(`0x${bytesToHex(gasFreeDigest(DOMAIN, AUTHORIZATION))}`); + expect(normalizeGasFreeSignature(SIGNATURE)).toBe(SIGNATURE.slice(2)); + expect(recoverGasFreeSigner(gasFreeDigest(DOMAIN, AUTHORIZATION), SIGNATURE)) + .toBe(AUTHORIZATION.user); + }); + + it("derives the same TRON address from compressed and uncompressed keys", () => { + const privateKey = hexToBytes(`${"00".repeat(31)}01`); + const address = new TronAddress(); + expect(address.fromPublicKey(secp256k1.getPublicKey(privateKey, true))) + .toBe(AUTHORIZATION.user); + expect(address.fromPublicKey(secp256k1.getPublicKey(privateKey, false))) + .toBe(AUTHORIZATION.user); + }); +}); diff --git a/ts/src/domain/gasfree/index.ts b/ts/src/domain/gasfree/index.ts new file mode 100644 index 000000000..d86620e4b --- /dev/null +++ b/ts/src/domain/gasfree/index.ts @@ -0,0 +1,139 @@ +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { bytesToHex, concatBytes, hexToBytes } from "@noble/hashes/utils.js"; +import type { + Bytes, + GasFreeAuthorization, + TypedDataPayload, +} from "../types/index.js"; +import { TronAddress, tronAddressBytes } from "../address/index.js"; + +export const GASFREE_DOMAIN_TYPE = + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; +export const GASFREE_PERMIT_TYPE = + "PermitTransfer(address token,address serviceProvider,address user,address receiver,uint256 value,uint256 maxFee,uint256 deadline,uint256 version,uint256 nonce)"; +export const GASFREE_DOMAIN_NAME = "GasFreeController"; +export const GASFREE_DOMAIN_VERSION = "V1.0.0"; + +const UTF8 = new TextEncoder(); + +function uint256Word(value: string, field: string): Bytes { + if (!/^(0|[1-9][0-9]*)$/.test(value)) { + throw new Error(`${field} must be an unsigned decimal integer`); + } + const integer = BigInt(value); + if (integer >= 1n << 256n) throw new Error(`${field} exceeds uint256`); + return hexToBytes(integer.toString(16).padStart(64, "0")); +} + +function addressWord(address: string): Bytes { + const decoded = tronAddressBytes(address); + const result = new Uint8Array(32); + result.set(decoded.slice(1), 12); + return result; +} + +export interface GasFreeDomainInput { + controllerChainId: string; + verifyingContract: string; +} + +/** Java GasFreeApi.getDomainSeparator byte-for-byte equivalent. */ +export function gasFreeDomainSeparator(domain: GasFreeDomainInput): Bytes { + return keccak_256(concatBytes( + keccak_256(UTF8.encode(GASFREE_DOMAIN_TYPE)), + keccak_256(UTF8.encode(GASFREE_DOMAIN_NAME)), + keccak_256(UTF8.encode(GASFREE_DOMAIN_VERSION)), + uint256Word(domain.controllerChainId, "controllerChainId"), + addressWord(domain.verifyingContract), + )); +} + +/** Java GasFreeApi.buildMessage byte-for-byte equivalent. */ +export function gasFreeMessageHash(authorization: GasFreeAuthorization): Bytes { + return keccak_256(concatBytes( + keccak_256(UTF8.encode(GASFREE_PERMIT_TYPE)), + addressWord(authorization.token), + addressWord(authorization.serviceProvider), + addressWord(authorization.user), + addressWord(authorization.receiver), + uint256Word(authorization.value, "value"), + uint256Word(authorization.maxFee, "maxFee"), + uint256Word(authorization.deadline, "deadline"), + uint256Word(authorization.version, "version"), + uint256Word(authorization.nonce, "nonce"), + )); +} + +/** keccak256(0x1901 || domainSeparator || messageHash), matching Java signOffChain input. */ +export function gasFreeDigest( + domain: GasFreeDomainInput, + authorization: GasFreeAuthorization, +): Bytes { + return keccak_256(concatBytes( + Uint8Array.of(0x19, 0x01), + gasFreeDomainSeparator(domain), + gasFreeMessageHash(authorization), + )); +} + +/** Build the exact TIP-712 payload used by software and Ledger signers. */ +export function gasFreeTypedData( + domain: GasFreeDomainInput, + authorization: GasFreeAuthorization, +): TypedDataPayload { + return { + domain: { + name: GASFREE_DOMAIN_NAME, + version: GASFREE_DOMAIN_VERSION, + chainId: domain.controllerChainId, + verifyingContract: domain.verifyingContract, + }, + types: { + PermitTransfer: [ + { name: "token", type: "address" }, + { name: "serviceProvider", type: "address" }, + { name: "user", type: "address" }, + { name: "receiver", type: "address" }, + { name: "value", type: "uint256" }, + { name: "maxFee", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "version", type: "uint256" }, + { name: "nonce", type: "uint256" }, + ], + }, + primaryType: "PermitTransfer", + message: { ...authorization }, + }; +} + +/** Validate low-s r||s||v and return Java-compatible lowercase hex without 0x. */ +export function normalizeGasFreeSignature(signatureHex: string): string { + const normalized = signatureHex.replace(/^0x/i, "").toLowerCase(); + if (!/^[0-9a-f]{130}$/.test(normalized)) { + throw new Error("GasFree signature must be 65-byte hexadecimal r || s || v"); + } + const raw = hexToBytes(normalized); + const v = raw[64]!; + const recovery = v >= 27 ? v - 27 : v; + if (recovery !== 0 && recovery !== 1) { + throw new Error("GasFree signature recovery id must be 0/1 or 27/28"); + } + const signature = secp256k1.Signature.fromBytes(raw.slice(0, 64), "compact"); + if (signature.hasHighS()) throw new Error("GasFree signature must use low-s form"); + raw[64] = recovery + 27; + return bytesToHex(raw); +} + +export function recoverGasFreeSigner(digest: Bytes, signatureHex: string): string { + if (digest.length !== 32) throw new Error("GasFree digest must be 32 bytes"); + const canonical = hexToBytes(normalizeGasFreeSignature(signatureHex)); + const recovery = canonical[64]! - 27; + const recoveredSignature = concatBytes( + Uint8Array.of(recovery), + canonical.slice(0, 64), + ); + const compressed = secp256k1.recoverPublicKey(recoveredSignature, digest, { prehash: false }); + const uncompressed = secp256k1.Point.fromBytes(compressed).toBytes(false); + return new TronAddress().fromPublicKey(uncompressed); +} diff --git a/ts/src/domain/types/gasfree.ts b/ts/src/domain/types/gasfree.ts new file mode 100644 index 000000000..bf159650f --- /dev/null +++ b/ts/src/domain/types/gasfree.ts @@ -0,0 +1,135 @@ +export type GasFreeState = "WAITING" | "INPROGRESS" | "CONFIRMING" | "SUCCEED" | "FAILED"; + +export interface GasFreeTokenConfig { + tokenAddress: string; + activateFee: string; + transferFee: string; + symbol?: string; + decimals?: number; +} + +export interface GasFreeProviderConfig { + address: string; + defaultDeadlineDuration: string; + isDefault?: boolean; +} + +export interface GasFreeAssetConfig { + tokenAddress: string; + activateFee: string; + transferFee: string; +} + +export interface GasFreeAddressInfo { + ownerAddress: string; + gasFreeAddress: string; + active: boolean; + nonce: string; + allowSubmit?: boolean; + assets: GasFreeAssetConfig[]; +} + +/** Exact immutable TIP-712 PermitTransfer fields. uint256 values remain decimal strings. */ +export interface GasFreeAuthorization { + token: string; + serviceProvider: string; + user: string; + receiver: string; + value: string; + maxFee: string; + deadline: string; + version: string; + nonce: string; +} + +export interface SignedGasFreeAuthorization extends GasFreeAuthorization { + /** Java-compatible lowercase r || s || v hex without a 0x prefix. */ + sig: string; +} + +/** Untrusted provider receipt, normalized losslessly before application-layer validation. */ +export interface GasFreeTransferRecord { + id: string; + state: GasFreeState; + tokenAddress: string; + providerAddress: string; + accountAddress: string; + gasFreeAddress: string; + targetAddress: string; + amount: string; + maxFee?: string; + nonce: string; + /** GasFree returns this timestamp in milliseconds, while the signed deadline is in seconds. */ + expiredAt?: string; + estimatedTransferFee?: string; + estimatedActivateFee?: string; + estimatedTotalFee?: string; + estimatedTotalCost?: string; + txnHash?: string; + txnState?: string; + txnAmount?: string; + txnTransferFee?: string; + txnActivateFee?: string; + txnTotalFee?: string; + txnTotalCost?: string; + txnBlockNum?: string; + txnBlockTimestamp?: string; + failureReason?: string; +} + +export interface GasFreeInfoView { + ownerAddress: string; + gasFreeAddress: string; + active: boolean; + nonce: string; + tokens: Array<{ + symbol: string; + address: string; + decimals: number; + activateFee: string; + transferFee: string; + balance: string; + }>; +} + +export interface GasFreeTransferView { + kind: "gasfree-transfer"; + stage: "dry-run" | "submitted" | "confirmed" | "failed"; + traceId?: string; + state?: GasFreeState; + txId?: string; + token: string; + tokenAddress: string; + decimals: number; + amount: string; + serviceFee: string; + activateFee: string; + authorizedMaxFee: string; + totalDeducted: string; + owner: string; + from: string; + to: string; + toContact?: string; + serviceProvider: string; + nonce: string; + deadline: string; + failureReason?: string; +} + +export interface GasFreeTraceView { + traceId: string; + state: GasFreeState; + txId?: string; + token: string; + tokenAddress: string; + decimals: number; + amount: string; + serviceFee: string; + activateFee: string; + totalDeducted: string; + from: string; + owner: string; + to: string; + nonce: string; + failureReason?: string; +} diff --git a/ts/src/domain/types/index.ts b/ts/src/domain/types/index.ts index 5dc9a3cfa..be5229ad9 100644 --- a/ts/src/domain/types/index.ts +++ b/ts/src/domain/types/index.ts @@ -29,4 +29,5 @@ export * from "./keystore.js"; export * from "./tx.js"; export * from "./permission.js"; export * from "./multisig.js"; +export * from "./gasfree.js"; export * from "./primitives.js"; diff --git a/ts/src/domain/types/network.ts b/ts/src/domain/types/network.ts index a3613389f..f690131c7 100644 --- a/ts/src/domain/types/network.ts +++ b/ts/src/domain/types/network.ts @@ -24,6 +24,8 @@ export interface TronNetworkDescriptor extends NetworkBase { httpEndpoint?: string; /** Official walletadapter multi-sign service. Credentials are stored separately in Config. */ tronlinkHttpEndpoint?: string; + /** Official GasFree service plus the immutable TIP-712 controller domain. */ + gasfree?: GasFreeNetworkConfig; } /** Single family today (TRON). Kept as a named alias so adding a family later means re-introducing @@ -49,6 +51,18 @@ export interface Config { tronlinkSecretId?: string; tronlinkSecretKey?: string; tronlinkChannel?: string; + /** GasFree Open Platform credentials. The secret is never rendered in clear text. */ + gasfreeApiKey?: string; + gasfreeApiSecret?: string; +} + +export interface GasFreeNetworkConfig { + /** HTTPS origin only; request paths are appended by the GasFree adapter. */ + baseUrl: string; + apiPrefix: string; + /** Decimal uint256 value to avoid passing chain identifiers through floating point. */ + controllerChainId: string; + verifyingContract: string; } /** price service config ; best-effort — failures never fail a balance read. */ From 34ac350e85a46ae7b6b286108351b961e8994093 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 12:53:49 +0800 Subject: [PATCH 05/24] feat(ts): add account lifecycle commands --- .../adapters/inbound/cli/commands/account.ts | 92 ++++++ ts/src/adapters/inbound/cli/render/tx.ts | 16 ++ .../chain/tron/account-builders.test.ts | 72 +++++ .../chain/tron/transaction-codec.test.ts | 3 + ts/src/adapters/outbound/chain/tron/tron.ts | 73 +++++ ts/src/adapters/outbound/config/builtins.ts | 2 + .../application/ports/chain/tron-gateway.ts | 4 + .../use-cases/tron/account-service.test.ts | 232 ++++++++++++++- .../use-cases/tron/account-service.ts | 266 +++++++++++++++++- ts/src/bootstrap/families/tron.ts | 7 + ts/src/domain/types/tx.ts | 6 +- 11 files changed, 766 insertions(+), 7 deletions(-) create mode 100644 ts/src/adapters/outbound/chain/tron/account-builders.test.ts diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts index 8d3659562..cb0964654 100644 --- a/ts/src/adapters/inbound/cli/commands/account.ts +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -3,6 +3,98 @@ 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"; +import { txModeFields } from "./shared.js"; + +const transactionModeRefine = ( + input: { + dryRun?: boolean; + signOnly?: boolean; + buildOnly?: boolean; + expiration?: number; + }, + context: z.RefinementCtx, +): void => { + if ([input.dryRun, input.signOnly, input.buildOnly].filter(Boolean).length > 1) { + context.addIssue({ + code: "custom", + path: ["dryRun"], + message: "choose at most one of --dry-run, --sign-only, --build-only", + }); + } + if (input.expiration !== undefined && !input.signOnly && !input.buildOnly) { + context.addIssue({ + code: "custom", + path: ["expiration"], + message: "--expiration is only valid with --sign-only or --build-only", + }); + } +}; + +export const accountActivateSpec: ChainSpec = { + path: ["account", "activate"], + network: "optional", + wallet: "optional", + auth: "required", + broadcasts: true, + capability: "account.activate", + summary: "Activate a new TRON account", + description: + "Create an AccountCreateContract funded by the active account. The target must not already be active; use --dry-run to inspect current creation fees.", + baseFields: z.object({ + address: z.string().min(1).describe("unactivated TRON base58 address"), + ...txModeFields, + }), + baseRefine: transactionModeRefine, + examples: [ + { cmd: "wallet-cli account activate --address T... --dry-run" }, + { cmd: "wallet-cli account activate --address T... --wait --password-stdin" }, + ], + formatText: TextFormatters.txReceipt, +}; + +export const accountActivateTronBinding = ( + service: TronAccountService, +): FamilyBinding => ({ + run: async (ctx, network, input) => service.activate(ctx, network, input), +}); + +export const accountSetSpec: ChainSpec = { + path: ["account", "set"], + network: "optional", + wallet: "optional", + auth: "required", + broadcasts: true, + capability: "account.set", + summary: "Set the one-time on-chain account name or ID", + description: + "Set exactly one immutable account field. Names are 1-32 UTF-8 bytes; IDs are unique and 8-32 UTF-8 bytes.", + baseFields: z.object({ + name: z.string().min(1).optional().describe("one-time on-chain account name (1-32 UTF-8 bytes)"), + id: z.string().min(1).optional().describe("one-time unique account ID (8-32 UTF-8 bytes)"), + ...txModeFields, + }), + baseRefine: (input, context) => { + if ((input.name === undefined) === (input.id === undefined)) { + context.addIssue({ + code: "custom", + path: ["name"], + message: "provide exactly one of --name or --id", + }); + } + transactionModeRefine(input, context); + }, + examples: [ + { cmd: "wallet-cli account set --name alice --dry-run" }, + { cmd: "wallet-cli account set --id alice-001 --wait --password-stdin" }, + ], + formatText: TextFormatters.txReceipt, +}; + +export const accountSetTronBinding = ( + service: TronAccountService, +): FamilyBinding => ({ + run: async (ctx, network, input) => service.setOnChain(ctx, network, input), +}); export const accountBalanceSpec: ChainSpec = { path: ["account", "balance"], diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index 562311b18..8cf133509 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -34,6 +34,7 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { const family = renderFamily(ctx) if (r.mode === "dry-run") { return receipt(pending(), `Dry run ${actionLabel(r.kind)}`, [ + ...receiptRows(r), ["Fee", r.multiSignFeeSun === undefined ? formatFee(r.fee, family) : `${formatSun(r.multiSignFeeSun)} TRX multi-sign fee`], @@ -146,6 +147,10 @@ function receiptSummary(r: TxReceiptView, family: ChainFamily): string { return "Signed" case "permission-update": return "Permissions updated" + case "account-activate": + return "Account activated" + case "account-set": + return `On-chain ${r.field ?? "account field"} set` } } @@ -158,6 +163,13 @@ function receiptRows(r: TxReceiptView): Pair[] { 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.kind === "account-activate") { + rows.push(["Address", String(r.address ?? "")]) + rows.push(["Payer", String(r.payer ?? "")]) + } else if (r.kind === "account-set") { + rows.push(["Address", String(r.address ?? "")]) + rows.push([r.field === "id" ? "ID" : "Name", String(r.value ?? "")]) + } 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 @@ -212,6 +224,10 @@ function actionLabel(kind: TxReceiptKind): string { return "reward withdraw" case "permission-update": return "permission update" + case "account-activate": + return "account activate" + case "account-set": + return "account set" } } diff --git a/ts/src/adapters/outbound/chain/tron/account-builders.test.ts b/ts/src/adapters/outbound/chain/tron/account-builders.test.ts new file mode 100644 index 000000000..33034c9af --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/account-builders.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { TronRpcClient } from "./tron.js"; + +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const TARGET = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + +function client(): TronRpcClient { + const client = new TronRpcClient("https://node.invalid", 100); + client.tronweb.trx.getCurrentRefBlockParams = (async () => ({ + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + expiration: 1_900_000_060_000, + timestamp: 1_900_000_000_000, + })) as never; + return client; +} + +function valueOf(transaction: unknown): Record { + return ( + transaction as { + raw_data: { + contract: Array<{ parameter: { value: Record } }>; + }; + } + ).raw_data.contract[0]!.parameter.value; +} + +describe("TRON local account transaction builders", () => { + it("builds AccountCreateContract with the exact owner and target", async () => { + const gateway = client(); + const transaction = await gateway.buildAccountCreate(OWNER, TARGET); + const decoded = gateway.decodeTransactionHex( + gateway.encodeTransactionHex(transaction), + ); + + expect(decoded.raw_data.contract[0]?.type).toBe("AccountCreateContract"); + expect(valueOf(decoded)).toMatchObject({ + owner_address: gateway.tronweb.address.toHex(OWNER).toUpperCase(), + account_address: gateway.tronweb.address.toHex(TARGET).toUpperCase(), + }); + }); + + it("preserves a UTF-8 account name through complete protobuf encoding", async () => { + const gateway = client(); + const name = "金库-A"; + const transaction = await gateway.buildAccountUpdate(OWNER, name); + const decoded = gateway.decodeTransactionHex( + gateway.encodeTransactionHex(transaction), + ); + + expect(decoded.raw_data.contract[0]?.type).toBe("AccountUpdateContract"); + expect(String(valueOf(decoded).account_name).toLowerCase()).toBe( + Buffer.from(name, "utf8").toString("hex"), + ); + }); + + it("accepts and losslessly encodes a 30-byte ID despite TronWeb 6.4's hex-length bug", async () => { + const gateway = client(); + const accountId = "账".repeat(10); + expect(Buffer.byteLength(accountId, "utf8")).toBe(30); + + const transaction = await gateway.buildSetAccountId(OWNER, accountId); + const decoded = gateway.decodeTransactionHex( + gateway.encodeTransactionHex(transaction), + ); + + expect(decoded.raw_data.contract[0]?.type).toBe("SetAccountIdContract"); + expect(String(valueOf(decoded).account_id).toLowerCase()).toBe( + Buffer.from(accountId, "utf8").toString("hex"), + ); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts b/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts index c3d538620..f2757efef 100644 --- a/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts +++ b/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts @@ -31,6 +31,9 @@ function fixture(type: string, value: Record) { } const CASES: Array<[string, Record]> = [ + ["AccountCreateContract", { owner_address: OWNER, account_address: OTHER }], + ["AccountUpdateContract", { owner_address: OWNER, account_name: "41636d65205472656173757279" }], + ["SetAccountIdContract", { owner_address: OWNER, account_id: "61636d652d74726561737572792d3031" }], ["TransferContract", { owner_address: OWNER, to_address: OTHER, amount: 123 }], ["TransferAssetContract", { owner_address: OWNER, to_address: OTHER, asset_name: "31303030303031", amount: 456 }], ["TriggerSmartContract", { owner_address: OWNER, contract_address: OTHER, data: "a9059cbb", call_value: 0 }], diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 3641cf8e1..2ee4c726b 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -289,6 +289,65 @@ export class TronRpcClient implements TronGateway, Broadcaster { ); } + async buildAccountCreate(owner: string, target: string): Promise { + return this.#localAccountTransaction("AccountCreateContract", owner, { + owner_address: this.#tw.address.toHex(owner), + account_address: this.#tw.address.toHex(target), + }); + } + + async buildAccountUpdate(owner: string, accountName: string): Promise { + return this.#localAccountTransaction("AccountUpdateContract", owner, { + owner_address: this.#tw.address.toHex(owner), + account_name: Buffer.from(accountName, "utf8").toString("hex"), + }); + } + + async buildSetAccountId(owner: string, accountId: string): Promise { + // TronWeb 6.4 applies the 8..32 bound to the encoded hex string length, not the decoded + // UTF-8 byte length. Build the identical protobuf locally so valid 17..32-byte ids survive. + return this.#localAccountTransaction("SetAccountIdContract", owner, { + owner_address: this.#tw.address.toHex(owner), + account_id: Buffer.from(accountId, "utf8").toString("hex"), + }); + } + + async #localAccountTransaction( + type: "AccountCreateContract" | "AccountUpdateContract" | "SetAccountIdContract", + owner: string, + value: Record, + ): Promise { + return this.#wrap(`build ${type}`, async () => { + const block = await this.#tw.trx.getCurrentRefBlockParams(); + const transaction = refreshTransactionIdentity({ + visible: false, + txID: "", + raw_data_hex: "", + raw_data: { + contract: [{ + parameter: { + value, + type_url: `type.googleapis.com/protocol.${type}`, + }, + type, + }], + ...block, + }, + }); + const contract = transaction.raw_data.contract[0]?.parameter?.value; + const matchesIntent = Object.entries(value).every( + ([key, expected]) => contract?.[key] === expected, + ); + if (!matchesIntent) { + throw new ChainError( + "tx_integrity", + `locally built ${type} fields do not match the requested operation`, + ); + } + return assertBuiltTx(transaction, type); + }); + } + // ── generic error wrapper for node reads/builds ────────────────────────────── // Timeout wraps the guard (not the reverse) so a timed-out call surfaces as ChainError("timeout") // rather than being remapped to a generic rpc_error by the catch below. @@ -317,6 +376,20 @@ export class TronRpcClient implements TronGateway, Broadcaster { return parseTronAccountResponse(await response.text()); }); } + async getAccountById(accountId: string): Promise { + return this.#wrap("getAccountById", async () => { + const response = await fetch(`${this.#fullHost}/wallet/getaccountbyid`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + account_id: Buffer.from(accountId, "utf8").toString("hex"), + }), + signal: AbortSignal.timeout(this.#timeoutMs), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return parseTronAccountResponse(await response.text()); + }); + } async getAccountResources(address: string): Promise { return this.#wrap("getAccountResources", () => this.#tw.trx.getAccountResources(address)); } diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index bfbc99ba2..47f955c05 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -18,6 +18,8 @@ export const CAP_SUMMARIES: Record = { "account.balance.native": "native balance", "account.balance.token": "token balance", "account.portfolio": "holdings with USD valuation", + "account.activate": "activate a new TRON account", + "account.set": "set one-time on-chain account name or ID", "token.tokenbook": "token address-book (add/list/remove)", "tx.send": "transfer native / token", "tx.broadcast": "broadcast a presigned transaction", diff --git a/ts/src/application/ports/chain/tron-gateway.ts b/ts/src/application/ports/chain/tron-gateway.ts index 4b851dfc3..53e3ac808 100644 --- a/ts/src/application/ports/chain/tron-gateway.ts +++ b/ts/src/application/ports/chain/tron-gateway.ts @@ -177,6 +177,7 @@ export interface TronGateway extends Broadcaster { getMultiSignFee(): Promise; getNativeBalance(address: string): Promise; getAccount(address: string): Promise; + getAccountById(accountId: string): Promise; getAccountResources(address: string): Promise; getBlock(number?: string): Promise; getTransactionById(txid: string): Promise; @@ -196,6 +197,9 @@ export interface TronGateway extends Broadcaster { getTrc10Balance(assetId: string, address: string): Promise; getTrc10Info(assetId: string): Promise; buildNativeTransfer(from: string, to: string, amountSun: string): Promise; + buildAccountCreate(owner: string, target: string): Promise; + buildAccountUpdate(owner: string, accountName: string): Promise; + buildSetAccountId(owner: string, accountId: string): Promise; buildTrc20Transfer( from: string, to: string, diff --git a/ts/src/application/use-cases/tron/account-service.test.ts b/ts/src/application/use-cases/tron/account-service.test.ts index 4a131c756..c00824be8 100644 --- a/ts/src/application/use-cases/tron/account-service.test.ts +++ b/ts/src/application/use-cases/tron/account-service.test.ts @@ -1,14 +1,18 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { TronAccountService } from "./account-service.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; -import type { AccountScope } 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 { TronHistoryReader } from "../../ports/chain/tron-history-reader.js"; import type { TokenRepository } from "../../ports/token-repository.js"; import type { PriceProvider } from "../../ports/price-provider.js"; +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; const net: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: ["nile"], capabilities: [] }; const scope: AccountScope = { activeAccount: "wlt_test.0", resolveAddress: () => "TXaddress" }; +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const TARGET = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; function serviceWith(nativeRaw: string, nativePrice: number | null) { const gateway = { @@ -23,7 +27,7 @@ function serviceWith(nativeRaw: string, nativePrice: number | null) { nativeUsd: async () => nativePrice, tokenUsd: async () => new Map(), } as unknown as PriceProvider; - return new TronAccountService(gateways, {} as unknown as TronHistoryReader, tokens, prices); + return new TronAccountService(gateways, {} as unknown as TronHistoryReader, tokens, prices, {} as never); } describe("TronAccountService.balance (direction A shape)", () => { @@ -66,7 +70,7 @@ describe("TronAccountService.portfolio per-token best-effort (issue #9)", () => const gateways = { client: () => gateway, get: () => gateway } as unknown as ChainGatewayProvider; const tokens = { effective: () => [TOK] } as unknown as TokenRepository; const prices = { source: "test", nativeUsd: async () => 0.1, tokenUsd: async () => new Map([[TOK.id, 1]]) } as unknown as PriceProvider; - return new TronAccountService(gateways, {} as unknown as TronHistoryReader, tokens, prices); + return new TronAccountService(gateways, {} as unknown as TronHistoryReader, tokens, prices, {} as never); } it("one unreadable token degrades to a stable reason without leaking the raw error (I-06)", async () => { @@ -100,9 +104,227 @@ describe("TronAccountService.portfolio per-token best-effort (issue #9)", () => nativeUsd: async () => { throw new Error("request to https://api.provider.com/?key=SECRET failed"); }, tokenUsd: async () => new Map(), } as unknown as PriceProvider; - const result = await new TronAccountService(gateways, {} as unknown as TronHistoryReader, tokens, prices).portfolio(scope, net); + const result = await new TronAccountService( + gateways, + {} as unknown as TronHistoryReader, + tokens, + prices, + {} as never, + ).portfolio(scope, net); expect(result.priceUnavailable).toBe(true); expect(result.priceReason).toBe("price_provider_error"); expect(JSON.stringify(result)).not.toContain("SECRET"); }); }); + +function transactionScope(wait = false): TransactionScope { + return { + activeAccount: "wlt_test.0", + timeoutMs: 100, + wait, + waitTimeoutMs: 100, + resolveAddress: () => OWNER, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function writeService(options: { + balance?: string; + getAccount?: (address: string) => Promise>; + getAccountById?: (id: string) => Promise>; + outcome?: Record; +} = {}) { + const gateway = { + getAccount: vi.fn(options.getAccount ?? (async () => ({}))), + getAccountById: vi.fn(options.getAccountById ?? (async () => ({}))), + getChainParameters: vi.fn(async () => [ + { key: "getCreateAccountFee", value: 100_000 }, + { key: "getCreateNewAccountFeeInSystemContract", value: 1_000_000 }, + ]), + getNativeBalance: vi.fn(async () => options.balance ?? "2000000"), + buildAccountCreate: vi.fn(async () => ({ txID: "create" })), + buildAccountUpdate: vi.fn(async () => ({ txID: "name" })), + buildSetAccountId: vi.fn(async () => ({ txID: "id" })), + prepareTransaction: vi.fn((transaction) => transaction), + encodeTransactionHex: vi.fn(() => "abcd"), + } as unknown as TronGateway; + let captured: TxPipelineParams | undefined; + const pipeline = { + assertCanSign: vi.fn(), + run: vi.fn(async (params: TxPipelineParams) => { + captured = params; + if (options.outcome) return options.outcome; + const transaction = await params.build(OWNER); + const fee = await params.estimate(transaction); + if (params.buildOnly) { + return { + stage: "built" as const, + tx: transaction, + hex: params.artifact!(transaction), + fee, + }; + } + return { + stage: "plan" as const, + tx: transaction, + fee, + }; + }), + } as unknown as TxPipeline; + const provider = { + get: () => gateway, + } as unknown as ChainGatewayProvider; + const service = new TronAccountService( + provider, + {} as TronHistoryReader, + {} as TokenRepository, + {} as PriceProvider, + pipeline, + ); + return { service, gateway, pipeline, captured: () => captured }; +} + +describe("TronAccountService account lifecycle writes", () => { + it("dry-runs activation with both dynamic creation fees and no signer gate", async () => { + const { service, gateway, pipeline, captured } = writeService(); + const result = await service.activate(transactionScope(), net, { + address: TARGET, + dryRun: true, + permissionId: 2, + }); + + expect(result).toMatchObject({ + kind: "account-activate", + mode: "dry-run", + address: TARGET, + payer: OWNER, + fee: { + createAccountFeeSun: "100000", + systemContractFeeSun: "1000000", + minimumFeeSun: "1100000", + }, + }); + expect(gateway.buildAccountCreate).toHaveBeenCalledWith(OWNER, TARGET); + expect(pipeline.assertCanSign).not.toHaveBeenCalled(); + expect(captured()?.permissionId).toBe(2); + }); + + it("rejects an already activated target before transaction construction", async () => { + const { service, pipeline } = writeService({ + getAccount: async () => ({ address: TARGET }), + }); + + await expect(service.activate(transactionScope(), net, { + address: TARGET, + dryRun: true, + })).rejects.toMatchObject({ code: "account_already_active" }); + expect(pipeline.run).not.toHaveBeenCalled(); + }); + + it("enforces the complete activation fee for dry-run and broadcast", async () => { + const { service, pipeline } = writeService({ balance: "1099999" }); + + await expect(service.activate(transactionScope(), net, { + address: TARGET, + dryRun: true, + })).rejects.toMatchObject({ + code: "insufficient_balance", + details: { balance: "1099999", required: "1100000" }, + }); + expect(pipeline.run).not.toHaveBeenCalled(); + }); + + it("allows an offline account build even when the payer balance is currently low", async () => { + const { service } = writeService({ balance: "0" }); + const result = await service.activate(transactionScope(), net, { + address: TARGET, + buildOnly: true, + expiration: 60_000, + }); + + expect(result).toMatchObject({ + kind: "account-activate", + mode: "build-only", + hex: "abcd", + }); + }); + + it("builds the exact one-time account name after checking activation", async () => { + const { service, gateway } = writeService({ + getAccount: async () => ({ address: OWNER }), + }); + const result = await service.setOnChain(transactionScope(), net, { + name: "Acme Treasury", + dryRun: true, + }); + + expect(result).toMatchObject({ + kind: "account-set", + mode: "dry-run", + field: "name", + value: "Acme Treasury", + address: OWNER, + }); + expect(gateway.buildAccountUpdate).toHaveBeenCalledWith( + OWNER, + "Acme Treasury", + ); + expect(gateway.getAccountById).not.toHaveBeenCalled(); + }); + + it("rejects a previously set name and a globally occupied ID", async () => { + const named = writeService({ + getAccount: async () => ({ + address: OWNER, + account_name: Buffer.from("existing", "utf8").toString("hex"), + }), + }); + await expect(named.service.setOnChain(transactionScope(), net, { + name: "new-name", + dryRun: true, + })).rejects.toMatchObject({ code: "name_already_set" }); + + const occupied = writeService({ + getAccount: async () => ({ address: OWNER }), + getAccountById: async () => ({ address: TARGET }), + }); + await expect(occupied.service.setOnChain(transactionScope(), net, { + id: "acme-001", + dryRun: true, + })).rejects.toMatchObject({ code: "id_taken" }); + }); + + it("validates name and ID limits in UTF-8 bytes", async () => { + const { service } = writeService({ + getAccount: async () => ({ address: OWNER }), + }); + + await expect(service.setOnChain(transactionScope(), net, { + name: "账".repeat(11), + dryRun: true, + })).rejects.toMatchObject({ code: "invalid_value" }); + await expect(service.setOnChain(transactionScope(), net, { + id: "short", + dryRun: true, + })).rejects.toMatchObject({ code: "invalid_value" }); + await expect(service.setOnChain(transactionScope(), net, { + id: "账".repeat(10), + dryRun: true, + })).resolves.toMatchObject({ field: "id", value: "账".repeat(10) }); + }); + + it("fails a signing mode before RPC when the active account cannot sign", async () => { + const { service, gateway, pipeline } = writeService(); + vi.mocked(pipeline.assertCanSign).mockImplementation(() => { + throw Object.assign(new Error("watch only"), { + code: "watch_only_no_signer", + }); + }); + + await expect(service.activate(transactionScope(), net, { + address: TARGET, + })).rejects.toMatchObject({ code: "watch_only_no_signer" }); + expect(gateway.getAccount).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/tron/account-service.ts b/ts/src/application/use-cases/tron/account-service.ts index f3d3a7c42..0cb781b97 100644 --- a/ts/src/application/use-cases/tron/account-service.ts +++ b/ts/src/application/use-cases/tron/account-service.ts @@ -1,13 +1,26 @@ import type { ChainFamily, EffectiveTokenEntry, NetworkDescriptor } from "../../../domain/types/index.js"; +import { ChainError, UsageError } from "../../../domain/errors/index.js"; import { FAMILIES } from "../../../domain/family/index.js"; import { fromBaseUnits } from "../../../domain/amounts/index.js"; -import type { AccountScope } from "../../contracts/execution-scope.js"; +import { TronAddress, tronHexToBase58 } from "../../../domain/address/index.js"; +import type { AccountScope, TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronAccount, TronGateway } from "../../ports/chain/tron-gateway.js"; import type { TronHistoryQuery, TronHistoryReader } from "../../ports/chain/tron-history-reader.js"; import type { PriceProvider } from "../../ports/price-provider.js"; import type { TokenRepository } from "../../ports/token-repository.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; +import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { tronTransactionHooks } from "./multisig-authorization.js"; const round6 = (value: number): number => Math.round(value * 1e6) / 1e6; +const ADDRESS = new TronAddress(); function holding( kind: string, @@ -58,8 +71,154 @@ export class TronAccountService { private readonly history: TronHistoryReader, private readonly tokens: TokenRepository, private readonly prices: PriceProvider, + private readonly pipeline: TxPipeline, ) {} + async activate( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionModeInput & { address: string }, + ) { + if (!ADDRESS.validate(input.address)) { + throw new UsageError( + "invalid_value", + "--address must be a valid TRON address", + ); + } + if (transactionRequiresSigner(input)) { + this.pipeline.assertCanSign(scope.activeAccount, "tron"); + } + const mode = transactionMode(input); + const gateway = this.gateways.get(network, "tron"); + const existing = await gateway.getAccount(input.address); + if (accountExists(existing, input.address)) { + throw new ChainError( + "account_already_active", + `TRON account is already active: ${input.address}`, + ); + } + const payer = scope.resolveAddress("tron"); + const [fee, balanceSun] = await Promise.all([ + accountCreateFee(gateway), + gateway.getNativeBalance(payer), + ]); + if ( + (mode.mode === "dry-run" || mode.mode === "broadcast") + && BigInt(balanceSun) < BigInt(fee.minimumFeeSun) + ) { + throw new ChainError( + "insufficient_balance", + "payer balance is below the account creation fees", + { balance: balanceSun, required: fee.minimumFeeSun }, + ); + } + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...mode, + ...tronTransactionHooks(gateway), + confirm: tronConfirmation(gateway, scope), + build: (owner) => gateway.buildAccountCreate(owner, input.address), + estimate: async () => ({ ...fee, balanceSun }), + }); + if (outcome.stage === "confirmed" && !outcome.failed) { + const activated = await gateway.getAccount(input.address); + if (!accountExists(activated, input.address)) { + throw new ChainError( + "provider_error", + "confirmed account activation is not visible from the selected node", + ); + } + } + return { + kind: "account-activate" as const, + ...outcomeData(outcome), + address: input.address, + payer, + }; + } + + async setOnChain( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionModeInput & { name?: string; id?: string }, + ) { + if ((input.name === undefined) === (input.id === undefined)) { + throw new UsageError( + "invalid_option", + "provide exactly one of --name or --id", + ); + } + if (transactionRequiresSigner(input)) { + this.pipeline.assertCanSign(scope.activeAccount, "tron"); + } + const field = input.name !== undefined ? "name" as const : "id" as const; + const value = validateAccountText(field, input.name ?? input.id!); + const address = scope.resolveAddress("tron"); + const gateway = this.gateways.get(network, "tron"); + const account = await gateway.getAccount(address); + if (!accountExists(account, address)) { + throw new ChainError( + "not_found", + `TRON account is not activated: ${address}`, + ); + } + const current = + field === "name" ? account.account_name : account.account_id; + if (accountTextIsSet(current, field)) { + throw new ChainError( + field === "name" ? "name_already_set" : "id_already_set", + `on-chain account ${field} is already set`, + ); + } + if (field === "id") { + const taken = await gateway.getAccountById(value); + if (accountExists(taken)) { + throw new ChainError( + "id_taken", + `account id is already in use: ${value}`, + ); + } + } + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...transactionMode(input), + ...tronTransactionHooks(gateway), + confirm: tronConfirmation(gateway, scope), + build: (owner) => + field === "name" + ? gateway.buildAccountUpdate(owner, value) + : gateway.buildSetAccountId(owner, value), + estimate: async () => ({ + feeModel: "tron-resource", + note: "account metadata update consumes bandwidth", + }), + }); + if (outcome.stage === "confirmed" && !outcome.failed) { + const updated = await gateway.getAccount(address); + const raw = + field === "name" ? updated.account_name : updated.account_id; + if (decodeAccountText(raw, field) !== value) { + throw new ChainError( + "provider_error", + `confirmed account ${field} does not match the submitted value`, + ); + } + } + return { + kind: "account-set" as const, + ...outcomeData(outcome), + field, + value, + address, + }; + } + async balance(scope: AccountScope, network: NetworkDescriptor, family: ChainFamily) { const address = scope.resolveAddress(family); const meta = FAMILIES[family]; @@ -167,3 +326,108 @@ export class TronAccountService { }; } } + +async function accountCreateFee(gateway: TronGateway) { + const parameters = await gateway.getChainParameters(); + const find = (key: string): bigint => { + const value = parameters.find((entry) => entry.key === key)?.value; + if (!Number.isSafeInteger(value) || value! < 0) { + throw new ChainError( + "provider_error", + `chain parameter is unavailable: ${key}`, + ); + } + return BigInt(value!); + }; + const createAccountFeeSun = find("getCreateAccountFee"); + const systemContractFeeSun = find( + "getCreateNewAccountFeeInSystemContract", + ); + return { + feeModel: "tron-resource" as const, + createAccountFeeSun: createAccountFeeSun.toString(), + systemContractFeeSun: systemContractFeeSun.toString(), + minimumFeeSun: (createAccountFeeSun + systemContractFeeSun).toString(), + }; +} + +function validateAccountText(field: "name" | "id", input: string): string { + const bytes = Buffer.byteLength(input, "utf8"); + const minimum = field === "id" ? 8 : 1; + if ( + bytes < minimum + || bytes > 32 + || /[\p{Cc}\p{Cf}]/u.test(input) + ) { + throw new UsageError( + "invalid_value", + field === "id" + ? "--id must be 8-32 safe UTF-8 bytes" + : "--name must be 1-32 safe UTF-8 bytes", + ); + } + return input; +} + +function accountExists(account: TronAccount, expected?: string): boolean { + const raw = account.address; + if (raw === undefined || raw === null || raw === "") return false; + if (typeof raw !== "string") { + throw new ChainError( + "provider_error", + "TRON node returned a malformed account address", + ); + } + const normalized = tronHexToBase58(raw); + if (!ADDRESS.validate(normalized)) { + throw new ChainError( + "provider_error", + "TRON node returned an invalid account address", + ); + } + if (expected !== undefined && normalized !== expected) { + throw new ChainError( + "provider_error", + "TRON node returned an account address different from the query", + ); + } + return true; +} + +function accountTextIsSet( + value: unknown, + field: "name" | "id", +): boolean { + if (value === undefined || value === null || value === "") return false; + if (typeof value !== "string") { + throw new ChainError( + "provider_error", + `TRON node returned malformed account_${field}`, + ); + } + return true; +} + +function decodeAccountText( + value: unknown, + field: "name" | "id", +): string | undefined { + if (value === undefined || value === null || value === "") return undefined; + if ( + typeof value !== "string" + || !/^(?:[0-9a-fA-F]{2})+$/.test(value) + ) { + throw new ChainError( + "provider_error", + `TRON node returned malformed account_${field}`, + ); + } + const decoded = Buffer.from(value, "hex").toString("utf8"); + if (Buffer.from(decoded, "utf8").toString("hex") !== value.toLowerCase()) { + throw new ChainError( + "provider_error", + `TRON node returned invalid UTF-8 account_${field}`, + ); + } + return decoded; +} diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 088fada50..5966847db 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -4,6 +4,8 @@ import { TronRpcClient } from "../../adapters/outbound/chain/tron/tron.js"; import { TronGridHistoryReader } from "../../adapters/outbound/chain/tron/history-reader.js"; import { blockSpec, blockTronBinding } from "../../adapters/inbound/cli/commands/block.js"; import { + accountActivateSpec, + accountActivateTronBinding, accountBalanceSpec, accountBalanceTronBinding, accountHistorySpec, @@ -12,6 +14,8 @@ import { accountInfoTronBinding, accountPortfolioSpec, accountPortfolioTronBinding, + accountSetSpec, + accountSetTronBinding, } from "../../adapters/inbound/cli/commands/account.js"; import { tokenAddSpec, @@ -134,6 +138,7 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC new TronGridHistoryReader(deps.timeoutMs), deps.tokens, deps.prices, + deps.transactions, ); const token = new TronTokenService(deps.gateways, deps.tokens); const message = new MessageService(deps.signers); @@ -150,10 +155,12 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC const contract = new TronContractService(deps.gateways, deps.transactions); reg.addChain(blockSpec, "tron", blockTronBinding(new TronBlockService(deps.gateways))); + reg.addChain(accountActivateSpec, "tron", accountActivateTronBinding(account)); 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(accountSetSpec, "tron", accountSetTronBinding(account)); reg.addChain(tokenBalanceSpec, "tron", tokenBalanceTronBinding(token)); reg.addChain(tokenInfoSpec, "tron", tokenInfoTronBinding(token)); reg.addChain(tokenAddSpec, "tron", tokenAddTronBinding(token)); diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index 5585cb6cc..ba8946b16 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -88,7 +88,8 @@ export type TxReceiptKind = | "send" | "broadcast" | "sign" | "stake-freeze" | "stake-unfreeze" | "stake-delegate" | "stake-undelegate" | "stake-withdraw" | "stake-cancel" | "contract-send" | "contract-deploy" - | "vote-cast" | "reward-withdraw" | "permission-update"; + | "vote-cast" | "reward-withdraw" | "permission-update" + | "account-activate" | "account-set"; /** * Canonical tx receipt the signing commands return (dry-run / sign-only / broadcast stages). @@ -105,6 +106,9 @@ export interface TxReceiptView { // plan / sign-only /** address that produced the signature (sign-only outcomes). */ address?: string; + payer?: string; + field?: "name" | "id"; + value?: string; fee?: FeeReport; tx?: UnsignedTx; signed?: SignedTx; From 7a5245c84cea3ace69ef6d54c9bc310738e19861 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 13:01:50 +0800 Subject: [PATCH 06/24] feat(ts): add secure recipient contacts --- .../adapters/inbound/cli/commands/contact.ts | 70 ++++++ .../cli/commands/text-formatters.test.ts | 2 + ts/src/adapters/inbound/cli/commands/tx.ts | 3 +- ts/src/adapters/inbound/cli/render/contact.ts | 33 +++ ts/src/adapters/inbound/cli/render/index.ts | 2 + ts/src/adapters/inbound/cli/render/tx.ts | 5 +- ts/src/adapters/inbound/cli/shell/index.ts | 39 ++- .../adapters/inbound/cli/shell/shell.test.ts | 38 ++- .../outbound/contactbook/contactbook.test.ts | 104 ++++++++ ts/src/adapters/outbound/contactbook/index.ts | 226 ++++++++++++++++++ .../application/ports/contact-repository.ts | 8 + .../services/recipient-resolver.test.ts | 44 ++++ .../services/recipient-resolver.ts | 39 +++ .../application/use-cases/contact-service.ts | 39 +++ .../use-cases/tron/gasfree-service.test.ts | 38 ++- .../use-cases/tron/gasfree-service.ts | 38 +-- .../tron/transaction-service.send.test.ts | 83 +++++++ .../tron/transaction-service.status.test.ts | 7 +- .../use-cases/tron/transaction-service.ts | 16 +- ts/src/bootstrap/composition.ts | 8 + ts/src/bootstrap/families/tron.ts | 16 +- ts/src/domain/contact/contact.test.ts | 39 +++ ts/src/domain/contact/index.ts | 67 ++++++ ts/src/domain/types/contact.ts | 27 +++ ts/src/domain/types/index.ts | 1 + ts/src/domain/types/tx.ts | 1 + 26 files changed, 960 insertions(+), 33 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/contact.ts create mode 100644 ts/src/adapters/inbound/cli/render/contact.ts create mode 100644 ts/src/adapters/outbound/contactbook/contactbook.test.ts create mode 100644 ts/src/adapters/outbound/contactbook/index.ts create mode 100644 ts/src/application/ports/contact-repository.ts create mode 100644 ts/src/application/services/recipient-resolver.test.ts create mode 100644 ts/src/application/services/recipient-resolver.ts create mode 100644 ts/src/application/use-cases/contact-service.ts create mode 100644 ts/src/application/use-cases/tron/transaction-service.send.test.ts create mode 100644 ts/src/domain/contact/contact.test.ts create mode 100644 ts/src/domain/contact/index.ts create mode 100644 ts/src/domain/types/contact.ts diff --git a/ts/src/adapters/inbound/cli/commands/contact.ts b/ts/src/adapters/inbound/cli/commands/contact.ts new file mode 100644 index 000000000..218271339 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/contact.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../contracts/index.js"; +import type { CommandRegistry } from "../registry/index.js"; +import type { ContactService } from "../../../../application/use-cases/contact-service.js"; +import { TextFormatters } from "../render/index.js"; + +export function registerContactCommands( + registry: CommandRegistry, + service: ContactService, +): void { + const addFields = z.object({ + name: z.string().min(1).max(256), + address: z.string().min(1).max(128), + note: z.string().max(512).optional() + .describe("free-form note, up to 128 safe characters"), + }); + registry.add({ + path: ["contact", "add"], + network: "none", + wallet: "none", + auth: "none", + positionals: [{ field: "name" }, { field: "address" }], + summary: "Add a recipient", + description: + "Add a locally stored TRON recipient. The Base58Check address is validated and the name can then be used by tx send and gasfree transfer.", + fields: addFields, + input: addFields, + examples: [{ + cmd: "wallet-cli contact add alice TBy6... --note 'Alice mainnet'", + }], + formatText: TextFormatters.contactAdd, + run: async (_context, _network, input) => + service.add(input.name, input.address, input.note), + } satisfies CommandDefinition); + + const empty = z.object({}); + registry.add({ + path: ["contact", "list"], + network: "none", + wallet: "none", + auth: "none", + summary: "List recipients", + description: + "List every recipient in the local plaintext address book.", + fields: empty, + input: empty, + examples: [{ cmd: "wallet-cli contact list" }], + formatText: TextFormatters.contactList, + run: async () => service.list(), + } satisfies CommandDefinition); + + const removeFields = z.object({ + name: z.string().min(1).max(256), + }); + registry.add({ + path: ["contact", "remove"], + network: "none", + wallet: "none", + auth: "none", + positionals: [{ field: "name" }], + summary: "Remove a recipient", + description: + "Remove one recipient from the local address book without changing any on-chain state.", + fields: removeFields, + input: removeFields, + examples: [{ cmd: "wallet-cli contact remove alice" }], + formatText: TextFormatters.contactRemove, + run: async (_context, _network, input) => service.remove(input.name), + } satisfies CommandDefinition); +} 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 5278aa137..f658ef80b 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -9,6 +9,7 @@ import { 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"; +import { registerContactCommands } from "./contact.js"; const ctx = (over: Partial = {}): TextRenderContext => ({ command: "x", ...over }); @@ -18,6 +19,7 @@ describe("text formatters", () => { registerWalletCommands(registry, {} as Parameters[1]); registerConfigCommands(registry, {} as ConfigService); registerNetworkCommands(registry); + registerContactCommands(registry, {} as never); registerTronChainCommands(registry, {} as TronChainCommandDependencies); const missing = registry.all() diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index 218dcd04b..46cb00820 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -17,7 +17,8 @@ import { exactlyOne, readBoundedTextFile } from "./artifact.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"), + to: z.string().trim().min(1).max(128) + .describe("recipient TRON base58 address or local contact name"), 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() diff --git a/ts/src/adapters/inbound/cli/render/contact.ts b/ts/src/adapters/inbound/cli/render/contact.ts new file mode 100644 index 000000000..d3c8efc7b --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/contact.ts @@ -0,0 +1,33 @@ +import type { + ContactListView, + ContactView, +} from "../../../../domain/types/index.js"; +import { ok, receipt, table } from "./layout.js"; + +export const ContactFormatters = { + contactAdd: (value: ContactView): string => receipt( + ok(), + "Contact added", + [ + ["Name", value.name], + ["Address", value.address], + ["Note", value.note ?? "—"], + ], + ), + contactRemove: (value: ContactView): string => receipt( + ok(), + "Contact removed", + [ + ["Name", value.name], + ["Address", value.address], + ], + ), + contactList: (value: ContactListView): string => table( + ["Name", "Address", "Note"], + value.contacts.map((entry) => [ + entry.name, + entry.address, + entry.note ?? "—", + ]), + ), +}; diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index 1f81f07df..f059d78ce 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -25,6 +25,7 @@ import { MiscFormatters } from "./misc.js" import { PermissionFormatters } from "./permission.js" import { MultisigFormatters } from "./multisig.js" import { GasFreeFormatters } from "./gasfree.js" +import { ContactFormatters } from "./contact.js" export { FAMILY_RENDER, renderFamily } from "./family.js" @@ -40,6 +41,7 @@ export const TextFormatters = { ...PermissionFormatters, ...MultisigFormatters, ...GasFreeFormatters, + ...ContactFormatters, } export function renderGenericText(command: string, net: NetworkDescriptor | undefined, data: unknown): string { diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index 8cf133509..f8d478745 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -170,7 +170,10 @@ function receiptRows(r: TxReceiptView): Pair[] { rows.push(["Address", String(r.address ?? "")]) rows.push([r.field === "id" ? "ID" : "Name", String(r.value ?? "")]) } - else if (r.to ?? r.receiver) rows.push(["To", String(r.to ?? r.receiver)]) + else if (r.to ?? r.receiver) { + const address = String(r.to ?? r.receiver) + rows.push(["To", r.toContact ? `${r.toContact} (${address})` : address]) + } if (r.kind === "contract-send") rows.push(["Contract", String(r.contract ?? "")]) return rows } diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts index 8421a41ab..0feffe8cc 100644 --- a/ts/src/adapters/inbound/cli/shell/index.ts +++ b/ts/src/adapters/inbound/cli/shell/index.ts @@ -68,7 +68,7 @@ export function buildCli(opts: ShellOptions): Argv { if (hasVerbs) { // group with sub-verbs (e.g. import mnemonic|private-key|ledger|watch) cli.command( - `${head} [verb]`, + `${head} [verb] [args..]`, `${head} commands`, (y) => applyCommands(y, cmds.map((c) => c.fields)), (argv) => dispatchNeutral(opts, [head, typeof argv.verb === "string" ? argv.verb : undefined].filter(Boolean) as string[], argv), @@ -117,7 +117,7 @@ export function buildCli(opts: ShellOptions): Argv { continue } cli.command( - `${group} [verb]`, + `${group} [verb] [args..]`, `${group} commands`, (y) => applyCommands(y, fieldsOfLogicalGroup(group)), (argv) => dispatchLogical(opts, [group, typeof argv.verb === "string" ? argv.verb : undefined].filter(Boolean) as string[], argv), @@ -153,11 +153,45 @@ async function dispatchLogical(opts: ShellOptions, path: string[], argv: any): P throw new UsageError("unknown_command", `unknown command: ${path.join(" ")}`) } +/** + * A yargs group has one positional layout while each leaf can have a different one. + * Capture the group tail as args[], then bind it only after resolving the exact leaf. + */ +export function bindGroupedPositionals( + command: Pick, + argv: Record, +): Record { + if (!("args" in argv)) return argv + const bound = { ...argv } + const raw = bound.args + delete bound.args + const values = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw] + const declared = command.positionals ?? [] + if (values.length > declared.length) { + throw new UsageError( + "usage_error", + `too many positional arguments for ${command.path.join(" ")}: expected ${declared.length}, received ${values.length}`, + ) + } + values.forEach((value, index) => { + const field = declared[index]!.field + if (bound[field] !== undefined) { + throw new UsageError( + "invalid_option", + `${field} was provided both positionally and as --${camelToKebab(field)}`, + ) + } + bound[field] = value + }) + return bound +} + 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 } + argv = bindGroupedPositionals(spec, argv) const target = targetResolver.resolve(spec, globals) const net = requireResolvedNetwork(spec, target.network) @@ -196,6 +230,7 @@ async function executeChainCommand(opts: ShellOptions, def: ChainCommandDefiniti async function executeCommand(opts: ShellOptions, cmd: CommandDefinition, argv: any): Promise { const { globals, deps, targetResolver, caps, streams, formatter, session } = opts session.current = { commandId: commandId(cmd) } + argv = bindGroupedPositionals(cmd, argv) assertKnownFlags(cmd, argv) // Gate all interactive prompts (gap-fill, password, secret, account-select, delete confirm) on a diff --git a/ts/src/adapters/inbound/cli/shell/shell.test.ts b/ts/src/adapters/inbound/cli/shell/shell.test.ts index 2aecb8e54..d83684cab 100644 --- a/ts/src/adapters/inbound/cli/shell/shell.test.ts +++ b/ts/src/adapters/inbound/cli/shell/shell.test.ts @@ -1,10 +1,46 @@ import { describe, it, expect, vi } from "vitest"; import { z } from "zod"; -import { gapFillRequiredFields, isInteractiveCommand } from "./index.js"; +import { + bindGroupedPositionals, + gapFillRequiredFields, + isInteractiveCommand, +} from "./index.js"; import { accountRef } from "../arity/index.js"; import type { CommandDefinition } from "../contracts/index.js"; import type { Prompter as PrompterType } from "../input/prompt/index.js"; +describe("grouped leaf positionals", () => { + const command = { + path: ["contact", "add"], + positionals: [{ field: "name" }, { field: "address" }], + }; + + it("binds args to the resolved leaf's declared fields", () => { + expect(bindGroupedPositionals(command, { + verb: "add", + args: ["alice", "Taddress"], + })).toMatchObject({ + verb: "add", + name: "alice", + address: "Taddress", + }); + }); + + it("rejects too many or ambiguously duplicated arguments", () => { + expect(() => + bindGroupedPositionals(command, { + args: ["alice", "Taddress", "extra"], + }) + ).toThrow(/too many positional/); + expect(() => + bindGroupedPositionals(command, { + args: ["alice"], + name: "bob", + }) + ).toThrow(/both positionally/); + }); +}); + // ── fake prompter ───────────────────────────────────────────────────────────── interface FakePrompterOpts { diff --git a/ts/src/adapters/outbound/contactbook/contactbook.test.ts b/ts/src/adapters/outbound/contactbook/contactbook.test.ts new file mode 100644 index 000000000..f2b78fafd --- /dev/null +++ b/ts/src/adapters/outbound/contactbook/contactbook.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + chmodSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { ContactBook } from "./index.js"; +import { AtomicFileStore } from "../persistence/fs/index.js"; +import { createContact } from "../../../domain/contact/index.js"; + +const ADDRESS = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const OTHER = "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT"; +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function root(): string { + const value = mkdtempSync(join(tmpdir(), "wallet-cli-contacts-")); + roots.push(value); + return value; +} + +describe("ContactBook", () => { + it("stores atomically at 0600 and enforces normalized-name uniqueness", () => { + const directory = root(); + const book = new ContactBook(directory, new AtomicFileStore()); + book.add(createContact("tron", "Alice", ADDRESS, "Treasury")); + + expect(book.find("tron", "alice")?.name).toBe("Alice"); + expect(() => + book.add(createContact("tron", "ALICE", OTHER)) + ).toThrow(/already exists/); + const path = join(directory, "contacts.json"); + if (process.platform !== "win32") { + expect(lstatSync(path).mode & 0o777).toBe(0o600); + } + expect(readFileSync(path, "utf8")).toContain('"nameKey": "alice"'); + expect(book.remove("tron", "alice").address).toBe(ADDRESS); + }); + + it.runIf(process.platform !== "win32")( + "refuses a contact file with group/other permissions", + () => { + const directory = root(); + const book = new ContactBook(directory, new AtomicFileStore()); + book.add(createContact("tron", "Alice", ADDRESS)); + chmodSync(join(directory, "contacts.json"), 0o644); + + expect(() => book.list("tron")).toThrow(/mode 0600/); + }, + ); + + it.runIf(process.platform !== "win32")( + "refuses a symbolic-link contact file without following it", + () => { + const directory = root(); + const external = join(directory, "external.json"); + writeFileSync( + external, + JSON.stringify({ version: 1, entries: {} }), + { mode: 0o600 }, + ); + symlinkSync(external, join(directory, "contacts.json")); + + expect(() => + new ContactBook(directory, new AtomicFileStore()).list("tron") + ).toThrow(/symbolic link/); + }, + ); + + it("rejects tampered normalization fields instead of accepting aliases", () => { + const directory = root(); + writeFileSync( + join(directory, "contacts.json"), + JSON.stringify({ + version: 1, + entries: { + tron: [{ + family: "tron", + name: "Alice", + nameKey: "bob", + address: ADDRESS, + note: null, + }], + }, + }), + { mode: 0o600 }, + ); + + expect(() => + new ContactBook(directory, new AtomicFileStore()).list("tron") + ).toThrow(/invalid schema/); + }); +}); diff --git a/ts/src/adapters/outbound/contactbook/index.ts b/ts/src/adapters/outbound/contactbook/index.ts new file mode 100644 index 000000000..051efaac0 --- /dev/null +++ b/ts/src/adapters/outbound/contactbook/index.ts @@ -0,0 +1,226 @@ +import { + closeSync, + constants, + fstatSync, + openSync, + readFileSync, +} from "node:fs"; +import { join } from "node:path"; +import type { ContactRepository } from "../../../application/ports/contact-repository.js"; +import type { + ChainFamily, + ContactEntry, +} from "../../../domain/types/index.js"; +import { + ExecutionError, + UsageError, +} from "../../../domain/errors/index.js"; +import { createContact } from "../../../domain/contact/index.js"; +import { AtomicFileStore } from "../persistence/fs/index.js"; + +const MAX_CONTACT_FILE_BYTES = 4 * 1024 * 1024; +const MAX_CONTACTS = 10_000; + +interface ContactDocument { + version: 1; + entries: Partial>; +} + +export class ContactBook implements ContactRepository { + readonly #path: string; + + constructor(root: string, private readonly store: AtomicFileStore) { + this.#path = join(root, "contacts.json"); + } + + add(entry: ContactEntry): ContactEntry { + return this.store.withLock(this.#path, () => { + const document = this.#read(); + const entries = document.entries[entry.family] ?? []; + if (entries.some((item) => item.nameKey === entry.nameKey)) { + throw new UsageError( + "already_exists", + `contact already exists: ${entry.name}`, + ); + } + if (entries.length >= MAX_CONTACTS) { + throw new UsageError( + "limit_exceeded", + `contact book cannot exceed ${MAX_CONTACTS} entries`, + ); + } + entries.push(entry); + document.entries[entry.family] = entries.sort((a, b) => + a.nameKey.localeCompare(b.nameKey) + ); + this.store.writeJson(this.#path, document); + return entry; + }); + } + + list(family: ChainFamily): ContactEntry[] { + return [...(this.#read().entries[family] ?? [])].sort((a, b) => + a.nameKey.localeCompare(b.nameKey) + ); + } + + find( + family: ChainFamily, + nameKey: string, + ): ContactEntry | undefined { + return this.list(family).find((entry) => entry.nameKey === nameKey); + } + + remove(family: ChainFamily, nameKey: string): ContactEntry { + return this.store.withLock(this.#path, () => { + const document = this.#read(); + const entries = document.entries[family] ?? []; + const index = entries.findIndex((entry) => entry.nameKey === nameKey); + if (index < 0) { + throw new UsageError( + "not_found", + `contact not found: ${nameKey}`, + ); + } + const [removed] = entries.splice(index, 1); + document.entries[family] = entries; + this.store.writeJson(this.#path, document); + return removed!; + }); + } + + #read(): ContactDocument { + const raw = this.#readSecureJson(); + if (raw === null) return { version: 1, entries: {} }; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw corrupt(); + } + const root = raw as Record; + if ( + root.version !== 1 + || !root.entries + || typeof root.entries !== "object" + || Array.isArray(root.entries) + ) { + throw corrupt(); + } + const result: ContactDocument = { version: 1, entries: {} }; + for ( + const [family, items] + of Object.entries(root.entries as Record) + ) { + if ( + family !== "tron" + || !Array.isArray(items) + || items.length > MAX_CONTACTS + ) { + throw corrupt(); + } + const seen = new Set(); + result.entries.tron = items.map((value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw corrupt(); + } + const item = value as Record; + if ( + typeof item.name !== "string" + || typeof item.address !== "string" + || ( + item.note !== null + && item.note !== undefined + && typeof item.note !== "string" + ) + ) { + throw corrupt(); + } + const validated = createContact( + "tron", + item.name, + item.address, + item.note ?? undefined, + ); + if ( + item.nameKey !== validated.nameKey + || item.family !== "tron" + || seen.has(validated.nameKey) + ) { + throw corrupt(); + } + seen.add(validated.nameKey); + return validated; + }); + } + return result; + } + + /** Open with O_NOFOLLOW, then validate the opened inode to avoid lstat/read TOCTOU. */ + #readSecureJson(): unknown | null { + let descriptor: number | undefined; + try { + descriptor = openSync( + this.#path, + constants.O_RDONLY + | (constants.O_NOFOLLOW ?? 0) + | (constants.O_NONBLOCK ?? 0), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return null; + if (code === "ELOOP") { + throw new ExecutionError( + "insecure_permissions", + "contacts.json must not be a symbolic link", + ); + } + throw error; + } + try { + const stat = fstatSync(descriptor); + if (!stat.isFile()) { + throw new ExecutionError( + "encoding_error", + "contacts.json must be a regular file", + ); + } + if (stat.size > MAX_CONTACT_FILE_BYTES) { + throw new ExecutionError( + "encoding_error", + "contacts.json exceeds the 4 MiB limit", + ); + } + if (process.platform !== "win32") { + if ((stat.mode & 0o777) !== 0o600) { + throw new ExecutionError( + "insecure_permissions", + "contacts.json must have mode 0600", + ); + } + if ( + typeof process.getuid === "function" + && stat.uid !== process.getuid() + ) { + throw new ExecutionError( + "insecure_permissions", + "contacts.json must be owned by the current user", + ); + } + } + const text = readFileSync(descriptor, "utf8"); + if (text.trim() === "") return null; + try { + return JSON.parse(text) as unknown; + } catch { + throw corrupt(); + } + } finally { + closeSync(descriptor); + } + } +} + +function corrupt(): ExecutionError { + return new ExecutionError( + "encoding_error", + "contacts.json has an invalid schema", + ); +} diff --git a/ts/src/application/ports/contact-repository.ts b/ts/src/application/ports/contact-repository.ts new file mode 100644 index 000000000..81566a7bf --- /dev/null +++ b/ts/src/application/ports/contact-repository.ts @@ -0,0 +1,8 @@ +import type { ChainFamily, ContactEntry } from "../../domain/types/index.js"; + +export interface ContactRepository { + add(entry: ContactEntry): ContactEntry; + list(family: ChainFamily): ContactEntry[]; + find(family: ChainFamily, nameKey: string): ContactEntry | undefined; + remove(family: ChainFamily, nameKey: string): ContactEntry; +} diff --git a/ts/src/application/services/recipient-resolver.test.ts b/ts/src/application/services/recipient-resolver.test.ts new file mode 100644 index 000000000..5a5b17463 --- /dev/null +++ b/ts/src/application/services/recipient-resolver.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import type { ContactRepository } from "../ports/contact-repository.js"; +import { RecipientResolver } from "./recipient-resolver.js"; + +const VALID = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const ALICE = "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT"; + +describe("RecipientResolver", () => { + const repository = { + find: (_family: string, key: string) => key === "alice" + ? { + family: "tron", + name: "Alice", + nameKey: "alice", + address: ALICE, + note: null, + } + : undefined, + } as ContactRepository; + const resolver = new RecipientResolver(repository); + + it("uses a valid address first and otherwise resolves the canonical contact name", () => { + expect(resolver.resolve("tron", VALID)).toEqual({ address: VALID }); + expect(resolver.resolve("tron", "ALICE")).toEqual({ + address: ALICE, + contactName: "Alice", + }); + }); + + it("never falls back to a contact for a mistyped address-shaped value", () => { + expect(() => + resolver.resolve("tron", "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HX") + ).toThrow(/checksum/); + }); + + it("returns a stable contact_not_found error for an unknown name", () => { + try { + resolver.resolve("tron", "unknown"); + throw new Error("expected resolver to throw"); + } catch (error) { + expect(error).toMatchObject({ code: "contact_not_found" }); + } + }); +}); diff --git a/ts/src/application/services/recipient-resolver.ts b/ts/src/application/services/recipient-resolver.ts new file mode 100644 index 000000000..515d8231c --- /dev/null +++ b/ts/src/application/services/recipient-resolver.ts @@ -0,0 +1,39 @@ +import type { ContactRepository } from "../ports/contact-repository.js"; +import type { + ChainFamily, + ResolvedRecipient, +} from "../../domain/types/index.js"; +import { TronAddress } from "../../domain/address/index.js"; +import { + contactNameKey, + resemblesTronAddress, +} from "../../domain/contact/index.js"; +import { UsageError } from "../../domain/errors/index.js"; + +export class RecipientResolver { + readonly #tron = new TronAddress(); + + constructor(private readonly contacts: ContactRepository) {} + + resolve(family: ChainFamily, input: string): ResolvedRecipient { + const value = input.trim(); + if (family === "tron" && this.#tron.validate(value)) { + return { address: value }; + } + // Never let a checksum typo fall through to a same-looking contact alias. + if (family === "tron" && resemblesTronAddress(value)) { + throw new UsageError( + "invalid_value", + "recipient resembles a TRON address but has an invalid length or checksum", + ); + } + const entry = this.contacts.find(family, contactNameKey(value)); + if (!entry) { + throw new UsageError( + "contact_not_found", + `contact not found: ${value}`, + ); + } + return { address: entry.address, contactName: entry.name }; + } +} diff --git a/ts/src/application/use-cases/contact-service.ts b/ts/src/application/use-cases/contact-service.ts new file mode 100644 index 000000000..ec38f2a5b --- /dev/null +++ b/ts/src/application/use-cases/contact-service.ts @@ -0,0 +1,39 @@ +import type { ContactRepository } from "../ports/contact-repository.js"; +import type { + ContactEntry, + ContactListView, + ContactView, +} from "../../domain/types/index.js"; +import { + contactNameKey, + createContact, +} from "../../domain/contact/index.js"; + +export class ContactService { + constructor(private readonly contacts: ContactRepository) {} + + add(name: string, address: string, note?: string): ContactView { + return publicContact( + this.contacts.add(createContact("tron", name, address, note)), + ); + } + + list(): ContactListView { + return { contacts: this.contacts.list("tron").map(publicContact) }; + } + + remove(name: string): ContactView { + return publicContact( + this.contacts.remove("tron", contactNameKey(name)), + ); + } +} + +function publicContact(entry: ContactEntry): ContactView { + return { + name: entry.name, + address: entry.address, + note: entry.note, + family: entry.family, + }; +} diff --git a/ts/src/application/use-cases/tron/gasfree-service.test.ts b/ts/src/application/use-cases/tron/gasfree-service.test.ts index 6822cfa12..6cee55a5d 100644 --- a/ts/src/application/use-cases/tron/gasfree-service.test.ts +++ b/ts/src/application/use-cases/tron/gasfree-service.test.ts @@ -158,17 +158,32 @@ function fixture(options: { assertCanSign: vi.fn(), resolve: vi.fn(() => signer), } as unknown as SignerResolver; + const recipients = { + resolve: vi.fn((_family: string, input: string) => + input === "alice" + ? { address: RECEIVER, contactName: "Alice" } + : { address: input } + ), + }; let now = 1_700_000_000_000; const service = new GasFreeService( provider, gateways, signers, + recipients as never, () => now, - async (milliseconds) => { + async (milliseconds: number) => { now += milliseconds; }, ); - return { service, provider, submitTransfer, signer, signers }; + return { + service, + provider, + submitTransfer, + signer, + signers, + recipients, + }; } describe("GasFreeService", () => { @@ -195,6 +210,25 @@ describe("GasFreeService", () => { expect(fixtureValue.submitTransfer).not.toHaveBeenCalled(); }); + it("resolves a contact before signing and binds its address into TIP-712", async () => { + const fixtureValue = fixture({ active: true }); + const result = await fixtureValue.service.transfer(scope(), NETWORK, { + to: "alice", + amount: "25", + token: "USDT", + dryRun: false, + }); + + expect(result).toMatchObject({ + to: RECEIVER, + toContact: "Alice", + }); + expect(fixtureValue.submitTransfer).toHaveBeenCalledWith( + NETWORK, + expect.objectContaining({ receiver: RECEIVER }), + ); + }); + it("signs the exact TIP-712 digest and accepts Java millisecond expiry", async () => { const fixtureValue = fixture({ active: true }); const result = await fixtureValue.service.transfer(scope(), NETWORK, { diff --git a/ts/src/application/use-cases/tron/gasfree-service.ts b/ts/src/application/use-cases/tron/gasfree-service.ts index bcc2ef926..d8fec88d7 100644 --- a/ts/src/application/use-cases/tron/gasfree-service.ts +++ b/ts/src/application/use-cases/tron/gasfree-service.ts @@ -22,12 +22,11 @@ import { recoverGasFreeSigner, } from "../../../domain/gasfree/index.js"; import { toBaseUnits } from "../../../domain/amounts/index.js"; -import { TronAddress } from "../../../domain/address/index.js"; import { obtainSignature } from "../../services/signing/obtain-signature.js"; +import type { RecipientResolver } from "../../services/recipient-resolver.js"; const MAX_DEADLINE_SECONDS = 86_400n; const POLL_MS = 1_000; -const ADDRESS = new TronAddress(); export interface GasFreeTransferInput { to: string; @@ -46,6 +45,7 @@ export class GasFreeService { private readonly provider: GasFreeProvider, private readonly gateways: ChainGatewayProvider, private readonly signers: SignerResolver, + private readonly recipients: RecipientResolver, private readonly clock: () => number = () => Date.now(), private readonly sleep: (milliseconds: number) => Promise = ( milliseconds, @@ -117,6 +117,9 @@ export class GasFreeService { "--wait cannot be used with --dry-run", ); } + if (!input.dryRun) { + this.signers.assertCanSign(scope.activeAccount, "tron"); + } const metadata = network.gasfree; if (!metadata) { throw new UsageError( @@ -125,12 +128,7 @@ export class GasFreeService { ); } const owner = scope.resolveAddress("tron"); - if (!ADDRESS.validate(input.to)) { - throw new UsageError( - "invalid_value", - "--to must be a valid TRON address or contact name", - ); - } + const recipient = this.recipients.resolve("tron", input.to); const [addressInfo, tokens, providers] = await Promise.all([ this.provider.getAddress(network, owner), this.#tokens(network), @@ -179,21 +177,26 @@ export class GasFreeService { token: token.tokenAddress, serviceProvider: provider.address, user: owner, - receiver: input.to, + receiver: recipient.address, value, maxFee: authorizedMaxFee, deadline: String(BigInt(Math.floor(this.clock() / 1000)) + duration), version: "1", nonce: addressInfo.nonce, }; - const base = transferView( - "dry-run", - authorization, - token, - addressInfo, - activateFee, - totalDeducted, - ); + const base: GasFreeTransferView = { + ...transferView( + "dry-run", + authorization, + token, + addressInfo, + activateFee, + totalDeducted, + ), + ...(recipient.contactName + ? { toContact: recipient.contactName } + : {}), + }; if (input.dryRun) return base; if (addressInfo.allowSubmit === false) { throw new ChainError( @@ -202,7 +205,6 @@ export class GasFreeService { ); } - this.signers.assertCanSign(scope.activeAccount, "tron"); const signer = this.signers.resolve(scope.activeAccount, "tron"); if (signer.address !== owner) { throw new UsageError( diff --git a/ts/src/application/use-cases/tron/transaction-service.send.test.ts b/ts/src/application/use-cases/tron/transaction-service.send.test.ts new file mode 100644 index 000000000..81e3c92da --- /dev/null +++ b/ts/src/application/use-cases/tron/transaction-service.send.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +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, TxPipelineParams } from "../../services/pipeline/index.js"; +import { TronTransactionService } from "./transaction-service.js"; + +const OWNER = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const RECEIVER = "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT"; +const NETWORK = { + id: "tron:nile", + family: "tron", + chainId: "nile", + aliases: ["nile"], + capabilities: [], +} satisfies NetworkDescriptor; + +function scope(): TransactionScope { + return { + activeAccount: "wlt_test", + resolveAddress: () => OWNER, + timeoutMs: 100, + wait: false, + waitTimeoutMs: 100, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +describe("TronTransactionService recipient resolution", () => { + it("builds and estimates against the resolved contact address", async () => { + const gateway = { + buildNativeTransfer: vi.fn(async () => ({ txID: "tx" })), + prepareTransaction: vi.fn((transaction) => transaction), + encodeTransactionHex: vi.fn(() => "abcd"), + } as unknown as TronGateway; + const pipeline = { + assertCanSign: vi.fn(), + run: vi.fn(async (params: TxPipelineParams) => { + const transaction = await params.build(OWNER); + return { + stage: "plan" as const, + tx: transaction, + fee: await params.estimate(transaction), + }; + }), + } as unknown as TxPipeline; + const recipients = { + resolve: vi.fn(() => ({ + address: RECEIVER, + contactName: "Alice", + })), + }; + const service = new TronTransactionService( + { get: () => gateway } as unknown as ChainGatewayProvider, + {} as never, + pipeline, + recipients as never, + ); + + const result = await service.send(scope(), NETWORK, { + to: "alice", + rawAmount: "1000000", + feeLimit: "100000000", + dryRun: true, + }); + + expect(recipients.resolve).toHaveBeenCalledWith("tron", "alice"); + expect(gateway.buildNativeTransfer).toHaveBeenCalledWith( + OWNER, + RECEIVER, + "1000000", + ); + expect(result).toMatchObject({ + kind: "send", + mode: "dry-run", + to: RECEIVER, + toContact: "Alice", + }); + expect(pipeline.assertCanSign).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/tron/transaction-service.status.test.ts b/ts/src/application/use-cases/tron/transaction-service.status.test.ts index 5c6968160..a755959fd 100644 --- a/ts/src/application/use-cases/tron/transaction-service.status.test.ts +++ b/ts/src/application/use-cases/tron/transaction-service.status.test.ts @@ -19,7 +19,12 @@ function service(opts: { tx?: TronTx | Error; info?: TronTxInfo }) { }, } as unknown as TronGateway; const gateways = { get: () => gateway } as unknown as ChainGatewayProvider; - return new TronTransactionService(gateways, {} as never, {} as never); + return new TronTransactionService( + gateways, + {} as never, + {} as never, + {} as never, + ); } describe("TronTransactionService.status — four-state", () => { diff --git a/ts/src/application/use-cases/tron/transaction-service.ts b/ts/src/application/use-cases/tron/transaction-service.ts index 0cf62c444..4b14f0f71 100644 --- a/ts/src/application/use-cases/tron/transaction-service.ts +++ b/ts/src/application/use-cases/tron/transaction-service.ts @@ -14,6 +14,7 @@ import { } from "../../services/transaction-mode.js"; import { stageTronBroadcast, tronConfirmation } from "../../services/tron-confirmation.js"; import { tronTransactionHooks } from "./multisig-authorization.js"; +import type { RecipientResolver } from "../../services/recipient-resolver.js"; export interface TronSendInput extends TransactionModeInput { to: string; @@ -30,11 +31,13 @@ export class TronTransactionService { private readonly gateways: ChainGatewayProvider, private readonly tokens: TokenRepository, private readonly pipeline: TxPipeline, + private readonly recipients: RecipientResolver, ) {} async send(scope: TransactionScope, network: NetworkDescriptor, input: TronSendInput) { if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); + const recipient = this.recipients.resolve("tron", input.to); const resolved = await this.resolveTransfer( gateway, network.id, @@ -50,13 +53,13 @@ export class TronTransactionService { ...tronTransactionHooks(gateway), confirm: tronConfirmation(gateway, scope), build: (from) => resolved.contract - ? gateway.buildTrc20Transfer(from, input.to, resolved.contract, resolved.rawAmount, input.feeLimit) + ? gateway.buildTrc20Transfer(from, recipient.address, resolved.contract, resolved.rawAmount, input.feeLimit) : resolved.assetId - ? gateway.buildTrc10Transfer(from, input.to, resolved.assetId, resolved.rawAmount) - : gateway.buildNativeTransfer(from, input.to, resolved.rawAmount), + ? gateway.buildTrc10Transfer(from, recipient.address, resolved.assetId, resolved.rawAmount) + : gateway.buildNativeTransfer(from, recipient.address, resolved.rawAmount), estimate: () => resolved.contract ? gateway.estimateResources(scope.resolveAddress("tron"), resolved.contract, "transfer(address,uint256)", [ - { type: "address", value: input.to }, + { type: "address", value: recipient.address }, { type: "uint256", value: resolved.rawAmount }, ]) : Promise.resolve(resolved.assetId @@ -71,7 +74,10 @@ export class TronTransactionService { decimals: resolved.decimals, contract: resolved.contract, assetId: resolved.assetId, - to: input.to, + to: recipient.address, + ...(recipient.contactName + ? { toContact: recipient.contactName } + : {}), }; } diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index 3348ad9e9..b2e521520 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -31,6 +31,10 @@ import { FAMILY_REGISTRY, familyMap } from "./family-registry.js"; import { registerTronChainCommands } from "./families/tron.js"; import { TronLinkClient } from "../adapters/outbound/tronlink/client.js"; import { GasFreeClient } from "../adapters/outbound/gasfree/client.js"; +import { ContactBook } from "../adapters/outbound/contactbook/index.js"; +import { ContactService } from "../application/use-cases/contact-service.js"; +import { RecipientResolver } from "../application/services/recipient-resolver.js"; +import { registerContactCommands } from "../adapters/inbound/cli/commands/contact.js"; export interface BootstrapOptions { readonly globals: Globals; @@ -63,6 +67,8 @@ export function composeCliRuntime(options: BootstrapOptions) { new SecureBackupWriter(root), ); const tokenBook = new TokenBook(root, store); + const contactBook = new ContactBook(root, store); + const recipientResolver = new RecipientResolver(contactBook); const priceProvider = createPriceProvider(config.price, timeoutMs); const gatewayProvider = new ChainGatewayRegistry( familyMap((plugin) => plugin.createGateway), @@ -80,6 +86,7 @@ export function composeCliRuntime(options: BootstrapOptions) { registerWalletCommands(registry, { walletService, ledger }); registerConfigCommands(registry, configService); registerNetworkCommands(registry); + registerContactCommands(registry, new ContactService(contactBook)); registerTronChainCommands(registry, { gateways: gatewayProvider, tokens: tokenBook, @@ -90,6 +97,7 @@ export function composeCliRuntime(options: BootstrapOptions) { timeoutMs, tronlink: new TronLinkClient(config, timeoutMs), gasfree: new GasFreeClient(config, timeoutMs), + recipients: recipientResolver, }); const capabilitiesByFamily = registry.capabilityKeysByFamily(); diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 5966847db..10864e742 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -104,6 +104,7 @@ import { TransactionArtifactWriter } from "../../adapters/outbound/persistence/t import type { FamilyPlugin } from "./types.js"; import type { TronLinkCollaborationPort } from "../../application/ports/tronlink-collaboration.js"; import type { GasFreeProvider } from "../../application/ports/gasfree-provider.js"; +import type { RecipientResolver } from "../../application/services/recipient-resolver.js"; import { GasFreeService } from "../../application/use-cases/tron/gasfree-service.js"; import { gasFreeInfoSpec, @@ -130,6 +131,7 @@ export interface TronChainCommandDependencies { timeoutMs: number; tronlink: TronLinkCollaborationPort; gasfree: GasFreeProvider; + recipients: RecipientResolver; } export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainCommandDependencies): void { @@ -143,10 +145,20 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC const token = new TronTokenService(deps.gateways, deps.tokens); const message = new MessageService(deps.signers); const typedData = new TypedDataService(deps.signers); - const transaction = new TronTransactionService(deps.gateways, deps.tokens, deps.transactions); + const transaction = new TronTransactionService( + deps.gateways, + deps.tokens, + deps.transactions, + deps.recipients, + ); const multisig = new TronMultisigService(deps.gateways, deps.signers); const tronlink = new TronLinkMultisigService(deps.tronlink, deps.gateways, multisig); - const gasfree = new GasFreeService(deps.gasfree, deps.gateways, deps.signers); + const gasfree = new GasFreeService( + deps.gasfree, + deps.gateways, + deps.signers, + deps.recipients, + ); const permission = new TronPermissionService(deps.gateways, deps.accounts, deps.transactions); const stake = new TronStakeService(deps.gateways, deps.transactions); const vote = new TronVoteService(deps.gateways, deps.transactions, stake); diff --git a/ts/src/domain/contact/contact.test.ts b/ts/src/domain/contact/contact.test.ts new file mode 100644 index 000000000..dcc11fb08 --- /dev/null +++ b/ts/src/domain/contact/contact.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + contactNameKey, + contactNote, + createContact, +} from "./index.js"; + +const ADDRESS = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; + +describe("contact validation", () => { + it("normalizes compatibility-equivalent names into one lookup key", () => { + expect(contactNameKey(" ALICE ")).toBe("alice"); + }); + + it("rejects address-shaped aliases and control characters", () => { + expect(() => contactNameKey(ADDRESS)).toThrow(/must not resemble/); + expect(() => contactNameKey("alice\u202e")).toThrow(/safe characters/); + }); + + it("enforces character limits while preserving valid Unicode", () => { + expect(contactNote("账".repeat(128))).toBe("账".repeat(128)); + expect(() => contactNote("账".repeat(129))).toThrow(/128/); + expect(createContact("tron", "财务", ADDRESS)).toMatchObject({ + name: "财务", + address: ADDRESS, + family: "tron", + }); + }); + + it("rejects an invalid Base58Check address", () => { + expect(() => + createContact( + "tron", + "alice", + "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HX", + ) + ).toThrow(/Base58Check/); + }); +}); diff --git a/ts/src/domain/contact/index.ts b/ts/src/domain/contact/index.ts new file mode 100644 index 000000000..535f5f29b --- /dev/null +++ b/ts/src/domain/contact/index.ts @@ -0,0 +1,67 @@ +import type { ChainFamily, ContactEntry } from "../types/index.js"; +import { UsageError } from "../errors/index.js"; +import { TronAddress } from "../address/index.js"; + +const TRON_SHAPED = /^T[1-9A-HJ-NP-Za-km-z]{25,40}$/; +const UNSAFE_TEXT = /[\p{Cc}\p{Cf}]/u; +const ADDRESS = new TronAddress(); + +/** Case-insensitive, compatibility-normalized lookup key. */ +export function contactNameKey(input: string): string { + return contactName(input).normalize("NFKC").toLowerCase(); +} + +export function contactName(input: string): string { + const value = input.trim(); + const length = Array.from(value).length; + if ( + length < 1 + || length > 64 + || UNSAFE_TEXT.test(value) + || TRON_SHAPED.test(value) + ) { + throw new UsageError( + "invalid_value", + "contact name must be 1-64 safe characters and must not resemble a TRON address", + ); + } + return value; +} + +export function contactNote(input?: string): string | null { + if (input === undefined) return null; + const value = input.trim(); + if (Array.from(value).length > 128 || UNSAFE_TEXT.test(value)) { + throw new UsageError( + "invalid_value", + "contact note must contain at most 128 safe characters", + ); + } + return value || null; +} + +export function createContact( + family: ChainFamily, + nameInput: string, + address: string, + noteInput?: string, +): ContactEntry { + if (family !== "tron" || !ADDRESS.validate(address)) { + throw new UsageError( + "invalid_value", + "contact address must be a valid TRON Base58Check address", + ); + } + const name = contactName(nameInput); + return { + family, + name, + nameKey: contactNameKey(name), + address, + note: contactNote(noteInput), + }; +} + +export function resemblesTronAddress(input: string): boolean { + return TRON_SHAPED.test(input.trim()); +} diff --git a/ts/src/domain/types/contact.ts b/ts/src/domain/types/contact.ts new file mode 100644 index 000000000..ae6c81604 --- /dev/null +++ b/ts/src/domain/types/contact.ts @@ -0,0 +1,27 @@ +import type { ChainFamily } from "../family/index.js"; + +/** Canonical persisted recipient. nameKey is an integrity-checked lookup key. */ +export interface ContactEntry { + name: string; + nameKey: string; + address: string; + note: string | null; + family: ChainFamily; +} + +/** Public contact projection; storage-only normalization fields never leak. */ +export interface ContactView { + name: string; + address: string; + note: string | null; + family: ChainFamily; +} + +export interface ContactListView { + contacts: ContactView[]; +} + +export interface ResolvedRecipient { + address: string; + contactName?: string; +} diff --git a/ts/src/domain/types/index.ts b/ts/src/domain/types/index.ts index be5229ad9..4d5fed4e8 100644 --- a/ts/src/domain/types/index.ts +++ b/ts/src/domain/types/index.ts @@ -30,4 +30,5 @@ export * from "./tx.js"; export * from "./permission.js"; export * from "./multisig.js"; export * from "./gasfree.js"; +export * from "./contact.js"; export * from "./primitives.js"; diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index ba8946b16..6950900fe 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -123,6 +123,7 @@ export interface TxReceiptView { assetId?: string; decimals?: number; to?: string; + toContact?: string; receiver?: string; resource?: string; votes?: Array<{ witness: string; count: number }>; From c35c07f8c80662878282cb3f4ff91535caf11db4 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 13:06:18 +0800 Subject: [PATCH 07/24] feat(ts): add local address utilities --- .../adapters/inbound/cli/commands/address.ts | 37 ++++ .../adapters/inbound/cli/commands/encoding.ts | 34 ++++ .../cli/commands/text-formatters.test.ts | 4 + .../adapters/inbound/cli/render/encoding.ts | 41 +++++ ts/src/adapters/inbound/cli/render/index.ts | 2 + .../persistence/keypair-writer.test.ts | 65 +++++++ .../outbound/persistence/keypair-writer.ts | 62 +++++++ ts/src/application/ports/keypair-writer.ts | 3 + .../use-cases/address-service.test.ts | 49 ++++++ .../application/use-cases/address-service.ts | 68 ++++++++ .../application/use-cases/encoding-service.ts | 7 + ts/src/bootstrap/composition.ts | 10 ++ ts/src/domain/address/index.ts | 82 ++++++--- ts/src/domain/encoding/encoding.test.ts | 75 ++++++++ ts/src/domain/encoding/index.ts | 161 ++++++++++++++++++ 15 files changed, 677 insertions(+), 23 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/address.ts create mode 100644 ts/src/adapters/inbound/cli/commands/encoding.ts create mode 100644 ts/src/adapters/inbound/cli/render/encoding.ts create mode 100644 ts/src/adapters/outbound/persistence/keypair-writer.test.ts create mode 100644 ts/src/adapters/outbound/persistence/keypair-writer.ts create mode 100644 ts/src/application/ports/keypair-writer.ts create mode 100644 ts/src/application/use-cases/address-service.test.ts create mode 100644 ts/src/application/use-cases/address-service.ts create mode 100644 ts/src/application/use-cases/encoding-service.ts create mode 100644 ts/src/domain/encoding/encoding.test.ts create mode 100644 ts/src/domain/encoding/index.ts diff --git a/ts/src/adapters/inbound/cli/commands/address.ts b/ts/src/adapters/inbound/cli/commands/address.ts new file mode 100644 index 000000000..f7e6fa435 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/address.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../contracts/index.js"; +import type { CommandRegistry } from "../registry/index.js"; +import type { AddressService } from "../../../../application/use-cases/address-service.js"; +import { TextFormatters } from "../render/index.js"; + +export function registerAddressCommands( + registry: CommandRegistry, + service: AddressService, +): void { + const fields = z.object({ + out: z.string().min(1).max(4096).optional() + .describe("exclusive 0600 output path; existing files are never overwritten"), + printSecret: z.boolean().default(false) + .describe("print the private key instead of writing it; use only offline"), + }); + registry.add({ + path: ["address", "generate"], + network: "none", + wallet: "none", + auth: "none", + summary: + "Generate a random TRON/EVM keypair locally without adding it to the wallet", + description: + "Generate a secp256k1 keypair offline. By default the private key is written exclusively to a 0600 file and never printed or added to the keystore.", + fields, + input: fields, + examples: [ + { cmd: "wallet-cli address generate" }, + { + cmd: "wallet-cli address generate --out /secure/usb/key.json", + }, + ], + formatText: TextFormatters.addressGenerate, + run: async (_context, _network, input) => service.generate(input), + } satisfies CommandDefinition); +} diff --git a/ts/src/adapters/inbound/cli/commands/encoding.ts b/ts/src/adapters/inbound/cli/commands/encoding.ts new file mode 100644 index 000000000..749462795 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/encoding.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../contracts/index.js"; +import type { CommandRegistry } from "../registry/index.js"; +import type { EncodingService } from "../../../../application/use-cases/encoding-service.js"; +import { TextFormatters } from "../render/index.js"; + +export function registerEncodingCommands( + registry: CommandRegistry, + service: EncodingService, +): void { + const fields = z.object({ + input: z.string().min(1).max(2 * 1024 * 1024), + }); + registry.add({ + path: ["encoding", "convert"], + network: "none", + wallet: "none", + auth: "none", + positionals: [{ field: "input" }], + summary: + "Convert and validate address, hex, Base64, and Base58Check encodings", + description: + "Auto-detect an address/public-key or generic encoding and print all equivalent forms. Runs locally; 32-byte private-key-shaped values are rejected from argv.", + fields, + input: fields, + examples: [ + { cmd: "wallet-cli encoding convert TBhC..." }, + { cmd: "wallet-cli encoding convert deadbeef0102" }, + ], + formatText: TextFormatters.encodingConvert, + run: async (_context, _network, input) => + service.convert(input.input), + } satisfies CommandDefinition); +} 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 f658ef80b..cbbbc3a3c 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -10,6 +10,8 @@ import { isChainCommand } from "../contracts/index.js"; import type { TextRenderContext } from "../contracts/index.js"; import type { ConfigService } from "../../../../application/use-cases/config-service.js"; import { registerContactCommands } from "./contact.js"; +import { registerAddressCommands } from "./address.js"; +import { registerEncodingCommands } from "./encoding.js"; const ctx = (over: Partial = {}): TextRenderContext => ({ command: "x", ...over }); @@ -20,6 +22,8 @@ describe("text formatters", () => { registerConfigCommands(registry, {} as ConfigService); registerNetworkCommands(registry); registerContactCommands(registry, {} as never); + registerAddressCommands(registry, {} as never); + registerEncodingCommands(registry, {} as never); registerTronChainCommands(registry, {} as TronChainCommandDependencies); const missing = registry.all() diff --git a/ts/src/adapters/inbound/cli/render/encoding.ts b/ts/src/adapters/inbound/cli/render/encoding.ts new file mode 100644 index 000000000..d3fe5268f --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/encoding.ts @@ -0,0 +1,41 @@ +import type { EncodingConversion } from "../../../../domain/encoding/index.js"; +import { ok, query, receipt } from "./layout.js"; + +export const EncodingFormatters = { + encodingConvert: (value: EncodingConversion): string => + "tron" in value + ? query([ + ["TRON", value.tron], + ["TRON hex", value.tronHex], + ["EVM", value.evm], + ]) + : query([ + ["Hex", value.hex], + ["Base64", value.base64], + ["Base58Check", value.base58check], + ]), + addressGenerate: (value: { + tron: string; + evm: string; + secretFile?: string; + privateKey?: string; + }): string => { + const rendered = receipt( + ok(), + "Keypair generated (NOT stored in the wallet)", + [ + ["TRON address", value.tron], + ["EVM address", value.evm], + [ + "Private key", + value.privateKey ?? `written to ${value.secretFile}`, + ], + ], + ); + return `${ + value.privateKey + ? "! Private key printed to stdout — keep it offline\n" + : "" + }${rendered}\n\n! To sign with this key, import it: wallet-cli import private-key`; + }, +}; diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index f059d78ce..cc5dc0028 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -26,6 +26,7 @@ import { PermissionFormatters } from "./permission.js" import { MultisigFormatters } from "./multisig.js" import { GasFreeFormatters } from "./gasfree.js" import { ContactFormatters } from "./contact.js" +import { EncodingFormatters } from "./encoding.js" export { FAMILY_RENDER, renderFamily } from "./family.js" @@ -42,6 +43,7 @@ export const TextFormatters = { ...MultisigFormatters, ...GasFreeFormatters, ...ContactFormatters, + ...EncodingFormatters, } export function renderGenericText(command: string, net: NetworkDescriptor | undefined, data: unknown): string { diff --git a/ts/src/adapters/outbound/persistence/keypair-writer.test.ts b/ts/src/adapters/outbound/persistence/keypair-writer.test.ts new file mode 100644 index 000000000..5045cea46 --- /dev/null +++ b/ts/src/adapters/outbound/persistence/keypair-writer.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SecureKeypairWriter } from "./keypair-writer.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function root(): string { + const value = mkdtempSync(join(tmpdir(), "wallet-cli-keypair-")); + roots.push(value); + return value; +} + +describe("SecureKeypairWriter", () => { + it("creates a new durable 0600 JSON artifact", () => { + const directory = root(); + const path = join(directory, "nested", "key.json"); + expect(new SecureKeypairWriter().write(path, { privateKey: "secret" })) + .toBe(path); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ + privateKey: "secret", + }); + if (process.platform !== "win32") { + expect(lstatSync(path).mode & 0o777).toBe(0o600); + } + }); + + it("never overwrites an existing file", () => { + const directory = root(); + const path = join(directory, "key.json"); + writeFileSync(path, "original", { mode: 0o600 }); + + expect(() => + new SecureKeypairWriter().write(path, { privateKey: "replacement" }) + ).toThrow(/refusing to overwrite/); + expect(readFileSync(path, "utf8")).toBe("original"); + }); + + it("never follows an existing final-path symlink", () => { + const directory = root(); + const external = join(directory, "external.json"); + const path = join(directory, "key.json"); + writeFileSync(external, "original", { mode: 0o600 }); + symlinkSync(external, path); + + expect(() => + new SecureKeypairWriter().write(path, { privateKey: "replacement" }) + ).toThrow(/refusing to overwrite/); + expect(readFileSync(external, "utf8")).toBe("original"); + }); +}); diff --git a/ts/src/adapters/outbound/persistence/keypair-writer.ts b/ts/src/adapters/outbound/persistence/keypair-writer.ts new file mode 100644 index 000000000..2bfa38c69 --- /dev/null +++ b/ts/src/adapters/outbound/persistence/keypair-writer.ts @@ -0,0 +1,62 @@ +import { + closeSync, + constants, + fchmodSync, + fsyncSync, + mkdirSync, + openSync, + writeFileSync, +} from "node:fs"; +import { dirname, resolve } from "node:path"; +import { ExecutionError } from "../../../domain/errors/index.js"; +import type { KeypairWriter } from "../../../application/ports/keypair-writer.js"; + +/** Exclusive, no-follow writer for plaintext private-key artifacts. */ +export class SecureKeypairWriter implements KeypairWriter { + write(path: string, value: unknown): string { + const target = resolve(path); + mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); + let descriptor: number | undefined; + try { + descriptor = openSync( + target, + constants.O_WRONLY + | constants.O_CREAT + | constants.O_EXCL + | (constants.O_NOFOLLOW ?? 0), + 0o600, + ); + fchmodSync(descriptor, 0o600); + writeFileSync( + descriptor, + `${JSON.stringify(value, null, 2)}\n`, + "utf8", + ); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + if (process.platform !== "win32") { + const parent = openSync(dirname(target), constants.O_RDONLY); + try { + fsyncSync(parent); + } finally { + closeSync(parent); + } + } + return target; + } catch (error) { + if (descriptor !== undefined) closeSync(descriptor); + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" || code === "ELOOP") { + throw new ExecutionError( + "file_exists", + `refusing to overwrite keypair file: ${target}`, + ); + } + throw new ExecutionError( + "io_error", + `could not write keypair file: ${target}`, + ); + } + } +} diff --git a/ts/src/application/ports/keypair-writer.ts b/ts/src/application/ports/keypair-writer.ts new file mode 100644 index 000000000..1fcb9c884 --- /dev/null +++ b/ts/src/application/ports/keypair-writer.ts @@ -0,0 +1,3 @@ +export interface KeypairWriter { + write(path: string, value: unknown): string; +} diff --git a/ts/src/application/use-cases/address-service.test.ts b/ts/src/application/use-cases/address-service.test.ts new file mode 100644 index 000000000..67e66b59a --- /dev/null +++ b/ts/src/application/use-cases/address-service.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { AddressService } from "./address-service.js"; + +const SECRET = "0".repeat(63) + "1"; + +describe("AddressService", () => { + it("uses a valid scalar, zeroizes its buffer, and omits it from the default result", () => { + const writer = { write: vi.fn(() => "/safe/key.json") }; + const scalar = Uint8Array.from([...new Uint8Array(31), 1]); + const result = new AddressService( + "/safe", + writer, + () => scalar, + ).generate({ printSecret: false }); + + expect(result).toEqual({ + tron: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + evm: "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + secretFile: "/safe/key.json", + }); + expect(JSON.stringify(result)).not.toContain(SECRET); + expect(writer.write).toHaveBeenCalledWith( + "/safe/generated/keypair-TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + expect.objectContaining({ privateKey: SECRET }), + ); + expect(scalar.every((byte) => byte === 0)).toBe(true); + }); + + it("returns the private key only when printSecret is explicit", () => { + const writer = { write: vi.fn() }; + const result = new AddressService( + "/safe", + writer, + () => Uint8Array.from([...new Uint8Array(31), 1]), + ).generate({ printSecret: true }); + + expect(result.privateKey).toBe(SECRET); + expect(writer.write).not.toHaveBeenCalled(); + }); + + it("bounds rejection sampling when an entropy source is broken", () => { + const random = vi.fn(() => new Uint8Array(32)); + expect(() => + new AddressService("/safe", { write: vi.fn() }, random) + .generate({ printSecret: false }) + ).toThrow(/valid secp256k1/); + expect(random).toHaveBeenCalledTimes(128); + }); +}); diff --git a/ts/src/application/use-cases/address-service.ts b/ts/src/application/use-cases/address-service.ts new file mode 100644 index 000000000..d4da528b7 --- /dev/null +++ b/ts/src/application/use-cases/address-service.ts @@ -0,0 +1,68 @@ +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import type { KeypairWriter } from "../ports/keypair-writer.js"; +import { + evmAddressFromPublicKey, + TronAddress, +} from "../../domain/address/index.js"; +import { ExecutionError } from "../../domain/errors/index.js"; + +const MAX_SCALAR_ATTEMPTS = 128; + +export class AddressService { + constructor( + private readonly root: string, + private readonly writer: KeypairWriter, + private readonly random: (size: number) => Uint8Array = randomBytes, + ) {} + + generate(input: { out?: string; printSecret: boolean }) { + let privateKey: Uint8Array | undefined; + for ( + let attempt = 0; + attempt < MAX_SCALAR_ATTEMPTS; + attempt += 1 + ) { + const candidate = this.random(32); + if ( + candidate.length === 32 + && secp256k1.utils.isValidSecretKey(candidate) + ) { + privateKey = candidate; + break; + } + candidate.fill(0); + } + if (!privateKey) { + throw new ExecutionError( + "entropy_failure", + "could not obtain a valid secp256k1 private key", + ); + } + + const publicKey = secp256k1.getPublicKey(privateKey, false); + const tron = new TronAddress().fromPublicKey(publicKey); + const evm = evmAddressFromPublicKey(publicKey); + const privateKeyHex = bytesToHex(privateKey); + try { + if (input.printSecret) { + return { tron, evm, privateKey: privateKeyHex }; + } + const path = + input.out + ?? join(this.root, "generated", `keypair-${tron}`); + const secretFile = this.writer.write(path, { + version: 1, + privateKey: privateKeyHex, + publicKey: bytesToHex(publicKey), + tron, + evm, + }); + return { tron, evm, secretFile }; + } finally { + privateKey.fill(0); + } + } +} diff --git a/ts/src/application/use-cases/encoding-service.ts b/ts/src/application/use-cases/encoding-service.ts new file mode 100644 index 000000000..4ea4128a6 --- /dev/null +++ b/ts/src/application/use-cases/encoding-service.ts @@ -0,0 +1,7 @@ +import { convertEncoding } from "../../domain/encoding/index.js"; + +export class EncodingService { + convert(input: string) { + return convertEncoding(input); + } +} diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index b2e521520..c906279f0 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -35,6 +35,11 @@ import { ContactBook } from "../adapters/outbound/contactbook/index.js"; import { ContactService } from "../application/use-cases/contact-service.js"; import { RecipientResolver } from "../application/services/recipient-resolver.js"; import { registerContactCommands } from "../adapters/inbound/cli/commands/contact.js"; +import { EncodingService } from "../application/use-cases/encoding-service.js"; +import { AddressService } from "../application/use-cases/address-service.js"; +import { SecureKeypairWriter } from "../adapters/outbound/persistence/keypair-writer.js"; +import { registerEncodingCommands } from "../adapters/inbound/cli/commands/encoding.js"; +import { registerAddressCommands } from "../adapters/inbound/cli/commands/address.js"; export interface BootstrapOptions { readonly globals: Globals; @@ -87,6 +92,11 @@ export function composeCliRuntime(options: BootstrapOptions) { registerConfigCommands(registry, configService); registerNetworkCommands(registry); registerContactCommands(registry, new ContactService(contactBook)); + registerEncodingCommands(registry, new EncodingService()); + registerAddressCommands( + registry, + new AddressService(root, new SecureKeypairWriter()), + ); registerTronChainCommands(registry, { gateways: gatewayProvider, tokens: tokenBook, diff --git a/ts/src/domain/address/index.ts b/ts/src/domain/address/index.ts index 29e1ab753..644c08df5 100644 --- a/ts/src/domain/address/index.ts +++ b/ts/src/domain/address/index.ts @@ -5,7 +5,7 @@ import { keccak_256 } from "@noble/hashes/sha3.js" import { sha256 } from "@noble/hashes/sha2.js" import { createBase58check } from "@scure/base" -import { hexToBytes } from "@noble/hashes/utils.js" +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js" import { secp256k1 } from "@noble/curves/secp256k1.js" import type { Bytes, ChainFamily } from "../types/index.js" @@ -17,48 +17,84 @@ export interface AddressCodec { const b58c = createBase58check(sha256) -/** uncompressed (65B, 0x04…) or compressed pubkey → last-20-bytes keccak hash. */ -function pubKeyHash20(pub: Bytes): Bytes { - let uncompressed = pub - if (pub.length === 33) { - try { - uncompressed = secp256k1.Point.fromBytes(pub).toBytes(false) - } catch { - throw new Error("invalid compressed secp256k1 public key") - } +/** Valid SEC1 key → canonical uncompressed 65-byte representation. */ +export function uncompressedPublicKey(pub: Bytes): Bytes { + if (pub.length !== 33 && pub.length !== 65) { + throw new Error("public key must be 33 or 65 bytes") } - if (uncompressed.length !== 65 || uncompressed[0] !== 0x04) { - throw new Error("public key must be compressed or uncompressed secp256k1") - } - const body = uncompressed.slice(1) + // Point parsing validates prefix, coordinates, and secp256k1 curve membership. + return secp256k1.Point.fromBytes(pub).toBytes(false) +} + +/** compressed/uncompressed public key → last-20-bytes keccak(x || y). */ +export function publicKeyHash20(pub: Bytes): Bytes { + const body = uncompressedPublicKey(pub).slice(1) return keccak_256(body).slice(-20) } +/** Decode and validate a Base58Check TRON address payload. */ +export function tronAddressBytes(address: string): Bytes { + const decoded = b58c.decode(address) + if (decoded.length !== 21 || decoded[0] !== 0x41) { + throw new Error("invalid TRON address") + } + return decoded +} + +export function tronAddressFromBytes(payload: Bytes): string { + if (payload.length !== 21 || payload[0] !== 0x41) { + throw new Error("TRON payload must be 0x41 + 20 bytes") + } + return b58c.encode(payload) +} + +export function tronHexAddress(address: string): string { + return bytesToHex(tronAddressBytes(address)) +} + +export function evmChecksumAddress(hash20: Bytes): string { + if (hash20.length !== 20) { + throw new Error("EVM address payload must be 20 bytes") + } + const lower = bytesToHex(hash20) + const checksum = bytesToHex( + keccak_256(new TextEncoder().encode(lower)), + ) + let result = "0x" + for (let index = 0; index < lower.length; index += 1) { + const char = lower[index]! + result += + /[a-f]/.test(char) + && Number.parseInt(checksum[index]!, 16) >= 8 + ? char.toUpperCase() + : char + } + return result +} + +export function evmAddressFromPublicKey(pub: Bytes): string { + return evmChecksumAddress(publicKeyHash20(pub)) +} + export class TronAddress implements AddressCodec { readonly family: ChainFamily = "tron" fromPublicKey(pub: Bytes): string { const payload = new Uint8Array(21) payload[0] = 0x41 - payload.set(pubKeyHash20(pub), 1) + payload.set(publicKeyHash20(pub), 1) return b58c.encode(payload) } validate(addr: string): boolean { if (!/^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(addr)) return false try { - const decoded = b58c.decode(addr) - return decoded.length === 21 && decoded[0] === 0x41 + tronAddressBytes(addr) + return true } catch { return false } } } -/** Decode and validate a Base58Check TRON address as its 21-byte 0x41-prefixed payload. */ -export function tronAddressBytes(address: string): Bytes { - if (!new TronAddress().validate(address)) throw new Error("invalid TRON address"); - return b58c.decode(address); -} - /** Convert a 41-prefixed TRON hex address to base58; preserve non-hex values unchanged. */ export function tronHexToBase58(address: unknown): string { const value = String(address ?? ""); diff --git a/ts/src/domain/encoding/encoding.test.ts b/ts/src/domain/encoding/encoding.test.ts new file mode 100644 index 000000000..c5b0ef394 --- /dev/null +++ b/ts/src/domain/encoding/encoding.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { convertEncoding } from "./index.js"; + +const TRON = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const EVM = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"; + +describe("encoding convert", () => { + it("converts TRON/EVM forms without changing the 20-byte payload", () => { + expect(convertEncoding(TRON)).toMatchObject({ + inputType: "tron-base58", + tronHex: "417e5f4552091a69125d5dfcb7b8c2659029395bdf", + evm: EVM, + }); + expect(convertEncoding(EVM)).toMatchObject({ + tron: TRON, + tronHex: "417e5f4552091a69125d5dfcb7b8c2659029395bdf", + }); + }); + + it("derives the same address from compressed and uncompressed public keys", () => { + const secret = Uint8Array.from([...new Uint8Array(31), 1]); + const compressed = bytesToHex(secp256k1.getPublicKey(secret, true)); + const uncompressed = bytesToHex( + secp256k1.getPublicKey(secret, false), + ); + + expect(convertEncoding(compressed)).toMatchObject({ + inputType: "public-key", + tron: TRON, + evm: EVM, + }); + expect(convertEncoding(uncompressed)).toMatchObject({ + inputType: "public-key", + tron: TRON, + evm: EVM, + }); + }); + + it("round-trips generic hex through Base64 and Base58Check", () => { + const converted = convertEncoding("deadbeef0102"); + expect(converted).toMatchObject({ + inputType: "hex", + hex: "deadbeef0102", + base64: "3q2+7wEC", + }); + if (!("base58check" in converted)) throw new Error("wrong family"); + expect(convertEncoding(converted.base64)).toMatchObject({ + hex: "deadbeef0102", + }); + expect(convertEncoding(converted.base58check)).toMatchObject({ + hex: "deadbeef0102", + }); + }); + + it("fails closed for bad checksums, bad curve points, and EIP-55 errors", () => { + expect(() => + convertEncoding("TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HX") + ).toThrow(/checksum/); + expect(() => + convertEncoding("0x7E5F4552091A69125d5dfCb7b8C2659029395Bdf") + ).toThrow(/EIP-55/); + expect(() => convertEncoding(`04${"00".repeat(64)}`)).toThrow( + /secp256k1/, + ); + }); + + it("rejects private-key-shaped data in hex and Base64", () => { + expect(() => convertEncoding("11".repeat(32))).toThrow(/private key/); + expect(() => + convertEncoding(Buffer.alloc(32, 0x11).toString("base64")) + ).toThrow(/private key/); + }); +}); diff --git a/ts/src/domain/encoding/index.ts b/ts/src/domain/encoding/index.ts new file mode 100644 index 000000000..b360d3bd3 --- /dev/null +++ b/ts/src/domain/encoding/index.ts @@ -0,0 +1,161 @@ +import { createBase58check } from "@scure/base"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { + evmAddressFromPublicKey, + evmChecksumAddress, + publicKeyHash20, + TronAddress, + tronAddressBytes, + tronAddressFromBytes, +} from "../address/index.js"; +import { UsageError } from "../errors/index.js"; + +const base58check = createBase58check(sha256); +const tron = new TronAddress(); + +export type EncodingConversion = + | { + input: string; + inputType: "tron-base58" | "tron-hex" | "evm" | "public-key"; + valid: true; + tron: string; + tronHex: string; + evm: string; + } + | { + input: string; + inputType: "hex" | "base64" | "base58check"; + valid: true; + hex: string; + base64: string; + base58check: string; + }; + +export function convertEncoding(input: string): EncodingConversion { + const value = input.trim(); + if (!value || /[\p{Cc}\p{Cf}\s]/u.test(value)) { + throw invalid( + "input is empty or contains unsafe whitespace/control characters", + ); + } + + if (/^T[1-9A-HJ-NP-Za-km-z]{25,40}$/.test(value)) { + if (!tron.validate(value)) { + throw invalid("base58 checksum mismatch (typo in the address?)"); + } + return addressView( + value, + "tron-base58", + tronAddressBytes(value).slice(1), + ); + } + if (/^(?:0x)?41[0-9a-fA-F]{40}$/.test(value)) { + const raw = hexToBytes(value.replace(/^0x/, "")); + return addressView(value, "tron-hex", raw.slice(1)); + } + if (/^0x[0-9a-fA-F]{40}$/.test(value)) { + const payload = hexToBytes(value.slice(2)); + const checksum = evmChecksumAddress(payload); + if ( + /[a-f]/.test(value) + && /[A-F]/.test(value) + && value !== checksum + ) { + throw invalid("EVM EIP-55 checksum mismatch"); + } + return addressView(value, "evm", payload); + } + if ( + /^(?:02|03)[0-9a-fA-F]{64}$/.test(value) + || /^04[0-9a-fA-F]{128}$/.test(value) + ) { + const publicKey = hexToBytes(value); + try { + const payload = publicKeyHash20(publicKey); + return { + input: value, + inputType: "public-key", + valid: true, + tron: tron.fromPublicKey(publicKey), + tronHex: `41${bytesToHex(payload)}`, + evm: evmAddressFromPublicKey(publicKey), + }; + } catch { + throw invalid("public key is not a valid secp256k1 point"); + } + } + + let bytes: Uint8Array; + let inputType: "hex" | "base64" | "base58check"; + if (/^(?:0x)?[0-9a-fA-F]+$/.test(value)) { + const hex = value.replace(/^0x/, ""); + if (hex.length % 2 !== 0) { + throw invalid("hex input must contain complete bytes"); + } + bytes = hexToBytes(hex); + inputType = "hex"; + } else { + try { + bytes = base58check.decode(value); + inputType = "base58check"; + } catch { + if (!isCanonicalBase64(value)) { + throw invalid( + "input is not valid hex, Base64, or Base58Check", + ); + } + bytes = Uint8Array.from(Buffer.from(value, "base64")); + inputType = "base64"; + } + } + if (bytes.length === 32) { + throw invalid( + "32-byte input may be a private key and is not accepted on argv", + ); + } + if (bytes.length === 0 || bytes.length > 1024 * 1024) { + throw invalid("decoded input must contain 1 byte to 1 MiB"); + } + return { + input: value, + inputType, + valid: true, + hex: bytesToHex(bytes), + base64: Buffer.from(bytes).toString("base64"), + base58check: base58check.encode(bytes), + }; +} + +function addressView( + input: string, + inputType: "tron-base58" | "tron-hex" | "evm", + payload: Uint8Array, +): EncodingConversion { + const prefixed = new Uint8Array(21); + prefixed[0] = 0x41; + prefixed.set(payload, 1); + return { + input, + inputType, + valid: true, + tron: tronAddressFromBytes(prefixed), + tronHex: bytesToHex(prefixed), + evm: evmChecksumAddress(payload), + }; +} + +function isCanonicalBase64(value: string): boolean { + if ( + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + value, + ) + ) { + return false; + } + return Buffer.from(value, "base64").toString("base64") === value; +} + +function invalid(reason: string): UsageError { + return new UsageError("invalid_value", reason); +} From 89438207d742493b677b1402c94d25885918002d Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 13:10:15 +0800 Subject: [PATCH 08/24] feat(ts): add terminal receive QR --- ts/package-lock.json | 278 +++++++++++++++++- ts/package.json | 2 + .../cli/commands/text-formatters.test.ts | 21 ++ .../cli/commands/wallet.current.test.ts | 92 ++++++ .../adapters/inbound/cli/commands/wallet.ts | 55 +++- ts/src/adapters/inbound/cli/render/wallet.ts | 8 +- ts/src/adapters/outbound/qr/index.test.ts | 50 ++++ ts/src/adapters/outbound/qr/index.ts | 69 +++++ ts/src/application/ports/qr-encoder.ts | 4 + .../application/use-cases/wallet-service.ts | 4 +- ts/src/bootstrap/composition.ts | 7 +- 11 files changed, 574 insertions(+), 16 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/wallet.current.test.ts create mode 100644 ts/src/adapters/outbound/qr/index.test.ts create mode 100644 ts/src/adapters/outbound/qr/index.ts create mode 100644 ts/src/application/ports/qr-encoder.ts diff --git a/ts/package-lock.json b/ts/package-lock.json index 1a4433c3d..e4c652524 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -20,6 +20,7 @@ "@scure/bip39": "^2.2.0", "axios": "^1.18.1", "lossless-json": "^4.3.0", + "qrcode": "^1.5.4", "tronweb": "6.4.0", "ws": "8.21.1", "yaml": "^2.9.0", @@ -31,6 +32,7 @@ }, "devDependencies": { "@types/node": "^25.9.3", + "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", "dependency-cruiser": "^17.4.3", @@ -1491,6 +1493,16 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/w3c-web-usb": { "version": "1.0.14", "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", @@ -1727,7 +1739,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1886,6 +1897,15 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1953,7 +1973,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1966,7 +1985,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2032,6 +2050,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -2125,6 +2152,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2484,6 +2517,19 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -2801,6 +2847,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-installed-globally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", @@ -3167,6 +3222,18 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -3382,6 +3449,51 @@ "wrappy": "1" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3438,6 +3550,15 @@ "pathe": "^2.0.1" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", @@ -3570,6 +3691,130 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -3648,6 +3893,21 @@ "regexp-tree": "bin/regexp-tree" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3810,6 +4070,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4550,6 +4816,12 @@ "node": "^20.12||^22.13||>=24.0" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/ts/package.json b/ts/package.json index a521961fc..85e2c2b62 100644 --- a/ts/package.json +++ b/ts/package.json @@ -61,6 +61,7 @@ "@scure/bip39": "^2.2.0", "axios": "^1.18.1", "lossless-json": "^4.3.0", + "qrcode": "^1.5.4", "tronweb": "6.4.0", "ws": "8.21.1", "yaml": "^2.9.0", @@ -74,6 +75,7 @@ }, "devDependencies": { "@types/node": "^25.9.3", + "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", "dependency-cruiser": "^17.4.3", 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 cbbbc3a3c..f546e7787 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -52,6 +52,27 @@ describe("accountBalance formatter", () => { }); }); +describe("walletCurrent formatter", () => { + it("renders a receive QR followed by the full address for manual verification", () => { + const out = TextFormatters.walletCurrent({ + accountId: "wlt_selected", + label: "treasury", + active: false, + addresses: { + tron: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + }, + receiveQr: "█▀█\n▀▄▀", + receiveAddress: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + }); + + expect(out).toContain("Selected account: treasury"); + expect(out).toContain("█▀█\n▀▄▀"); + expect(out).toMatch( + /█▀█\n▀▄▀\nReceive address\s+TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC/, + ); + }); +}); + describe("stake/chain TRX amount formatting", () => { it("groups the integer part without truncating fractional TRX", () => { const stake = TextFormatters.stakeDelegated({ diff --git a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts new file mode 100644 index 000000000..29089216b --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AccountDescriptor } from "../../../../domain/types/index.js"; +import { CommandRegistry } from "../registry/index.js"; +import { isChainCommand } from "../contracts/index.js"; +import { registerWalletCommands } from "./wallet.js"; + +const ADDRESS = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; +const descriptor = { + accountId: "wlt_selected", + label: "treasury", + type: "watch", + index: null, + active: false, + addresses: { tron: ADDRESS }, +} satisfies AccountDescriptor; + +function command(options: { + output?: "text" | "json"; + encoded?: string | null; + account?: string; +} = {}) { + const walletService = { + current: vi.fn(() => descriptor), + }; + const qr = { + encode: vi.fn(() => + options.encoded === undefined ? "QR-MATRIX" : options.encoded + ), + }; + const registry = new CommandRegistry(); + registerWalletCommands(registry, { + walletService: walletService as never, + ledger: {} as never, + qr, + }); + const current = registry.resolveNeutral(["current"]); + if (!current || isChainCommand(current)) { + throw new Error("current command missing"); + } + const context = { + activeAccount: options.account ?? "wlt_selected", + output: options.output ?? "text", + warn: vi.fn(), + }; + return { current, context, walletService, qr }; +} + +describe("current --qr", () => { + it("encodes exactly the selected account's TRON address in text mode", async () => { + const fixture = command({ account: "wlt_selected", encoded: "QR" }); + const result = await fixture.current.run( + fixture.context as never, + undefined, + { qr: true }, + ); + + expect(fixture.walletService.current).toHaveBeenCalledWith( + "wlt_selected", + ); + expect(fixture.qr.encode).toHaveBeenCalledWith(ADDRESS); + expect(result).toMatchObject({ + receiveQr: "QR", + receiveAddress: ADDRESS, + }); + }); + + it("keeps JSON data unchanged and never builds terminal art", async () => { + const fixture = command({ output: "json" }); + const result = await fixture.current.run( + fixture.context as never, + undefined, + { qr: true }, + ); + + expect(result).toEqual(descriptor); + expect(fixture.qr.encode).not.toHaveBeenCalled(); + }); + + it("warns and returns the full normal descriptor on a narrow terminal", async () => { + const fixture = command({ encoded: null }); + const result = await fixture.current.run( + fixture.context as never, + undefined, + { qr: true }, + ); + + expect(result).toEqual(descriptor); + expect(fixture.context.warn).toHaveBeenCalledWith( + expect.stringContaining("too narrow"), + ); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index 050c3c6b6..c55cd50f7 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -8,6 +8,7 @@ import { Schemas } from "../schemas/index.js" import { CommandRegistry } from "../registry/index.js" import { accountRef, ciEnum } from "../arity/index.js" import type { LedgerDevice } from "../../../../application/ports/ledger-device.js" +import type { QrEncoder } from "../../../../application/ports/qr-encoder.js" import type { WalletService } from "../../../../application/use-cases/wallet-service.js" import { resolveLedgerPath, selectLedgerPath } from "../../../../application/services/ledger-account.js" import { ChainFamily, CHAIN_FAMILIES, FAMILIES } from "../../../../domain/family/index.js" @@ -40,7 +41,14 @@ export const walletImportLedgerInput = walletImportLedgerFields.superRefine((v, if (locators > 1) c.addIssue({ code: "custom", path: ["index"], message: "--index, --path and --address are mutually exclusive" }) }) -export function registerWalletCommands(reg: CommandRegistry, services: { walletService: WalletService; ledger: LedgerDevice }): void { +export function registerWalletCommands( + reg: CommandRegistry, + services: { + walletService: WalletService; + ledger: LedgerDevice; + qr?: QrEncoder; + }, +): void { const wallets = services.walletService const empty = z.object({}) @@ -203,20 +211,49 @@ export function registerWalletCommands(reg: CommandRegistry, services: { walletS } satisfies CommandDefinition) // ── current ─────────────────────────────────────────────────────────────── - // Read-only: always reports the persisted active account; ignores --account - // (use `use` to change it). wallet:"none" keeps help from - // advertising an --account override here. + const currentFields = z.object({ + qr: z.boolean().default(false) + .describe("render a terminal receive QR containing exactly the selected TRON address; text TTY only"), + }) reg.add({ path: ["current"], network: "none", - wallet: "none", + wallet: "optional", auth: "none", summary: "Show the current active account", - fields: empty, - input: empty, - examples: [{ cmd: "wallet-cli current" }], + description: + "Show the selected account locally. --qr appends a scannable TRON receive-address QR in text mode without unlocking or accessing the network.", + fields: currentFields, + input: currentFields, + examples: [ + { cmd: "wallet-cli current" }, + { cmd: "wallet-cli current --qr" }, + { cmd: "wallet-cli current --qr --account main" }, + ], formatText: TextFormatters.walletCurrent, - run: async () => wallets.current(), + run: async (context, _network, input) => { + const descriptor = wallets.current(context.activeAccount) + if (!input.qr || context.output !== "text") return descriptor + const address = descriptor.addresses.tron + if (!address) { + throw new UsageError( + "invalid_value", + "selected account has no TRON receive address", + ) + } + const qr = services.qr?.encode(address) ?? null + if (!qr) { + context.warn( + "terminal is non-interactive or too narrow for a complete QR code; showing the full address only", + ) + return descriptor + } + return { + ...descriptor, + receiveQr: qr, + receiveAddress: address, + } + }, } satisfies CommandDefinition) // ── rename ──────────────────────────────────────────────────────────────── diff --git a/ts/src/adapters/inbound/cli/render/wallet.ts b/ts/src/adapters/inbound/cli/render/wallet.ts index 6677c5fe3..b8a67c16f 100644 --- a/ts/src/adapters/inbound/cli/render/wallet.ts +++ b/ts/src/adapters/inbound/cli/render/wallet.ts @@ -24,7 +24,13 @@ export const WalletFormatters = { }) satisfies TextFormatter, walletCurrent: ((data) => { const d = asObj(data) - return titled(`Active account: ${displayName(d)}`, addressPairs(d)) + const identity = titled( + `${d.active === true ? "Active" : "Selected"} account: ${displayName(d)}`, + addressPairs(d), + ) + return d.receiveQr + ? `${identity}\n\n${String(d.receiveQr)}\nReceive address ${String(d.receiveAddress ?? "")}` + : identity }) satisfies TextFormatter, walletRename: ((data) => { const d = asObj(data) diff --git a/ts/src/adapters/outbound/qr/index.test.ts b/ts/src/adapters/outbound/qr/index.test.ts new file mode 100644 index 000000000..72bd93259 --- /dev/null +++ b/ts/src/adapters/outbound/qr/index.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { TerminalQrEncoder } from "./index.js"; + +const ADDRESS = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; + +describe("TerminalQrEncoder", () => { + it("renders a complete fixed-width ANSI-free matrix on a wide TTY", () => { + const output = new TerminalQrEncoder({ + isTTY: true, + columns: 120, + }).encode(ADDRESS); + const lines = output!.split("\n"); + + expect(output).toContain("█"); + expect(output).not.toMatch(/\u001b/); + expect(lines.length).toBeGreaterThan(10); + expect(new Set(lines.map((line) => line.length))).toHaveLength(1); + expect(lines[0]!.trim()).toBe(""); + expect(lines.at(-1)!.trim()).toBe(""); + }); + + it("encodes the exact payload rather than returning a static symbol", () => { + const terminal = { isTTY: true, columns: 120 }; + const first = new TerminalQrEncoder(terminal).encode(ADDRESS); + const second = new TerminalQrEncoder(terminal).encode( + "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT", + ); + expect(first).not.toBe(second); + }); + + it("degrades for non-TTY and narrow terminals", () => { + expect(new TerminalQrEncoder({ + isTTY: false, + columns: 120, + }).encode(ADDRESS)).toBeNull(); + expect(new TerminalQrEncoder({ + isTTY: true, + columns: 10, + }).encode(ADDRESS)).toBeNull(); + }); + + it("rejects unsafe payload text before encoding", () => { + expect(() => + new TerminalQrEncoder({ + isTTY: true, + columns: 120, + }).encode("address\u202e") + ).toThrow(/safe characters/); + }); +}); diff --git a/ts/src/adapters/outbound/qr/index.ts b/ts/src/adapters/outbound/qr/index.ts new file mode 100644 index 000000000..0d52cb31f --- /dev/null +++ b/ts/src/adapters/outbound/qr/index.ts @@ -0,0 +1,69 @@ +import QRCode from "qrcode"; +import type { QrEncoder } from "../../../application/ports/qr-encoder.js"; +import { UsageError } from "../../../domain/errors/index.js"; + +interface TerminalShape { + isTTY?: boolean; + columns?: number; +} + +/** Compact ANSI-free half-block QR renderer with a four-module quiet zone. */ +export class TerminalQrEncoder implements QrEncoder { + constructor( + private readonly terminal: TerminalShape = process.stdout, + ) {} + + encode(payload: string): string | null { + if ( + payload.length === 0 + || payload.length > 512 + || /[\p{Cc}\p{Cf}]/u.test(payload) + ) { + throw new UsageError( + "invalid_value", + "QR payload must contain 1-512 safe characters", + ); + } + if (!this.terminal.isTTY) return null; + + let matrix: ReturnType["modules"]; + try { + matrix = QRCode.create(payload, { + errorCorrectionLevel: "M", + }).modules; + } catch { + throw new UsageError( + "invalid_value", + "could not encode the receive address as a QR code", + ); + } + const quietZone = 4; + const width = matrix.size + quietZone * 2; + if ((this.terminal.columns ?? 0) < width) return null; + + const bit = (row: number, column: number): boolean => + row >= quietZone + && row < matrix.size + quietZone + && column >= quietZone + && column < matrix.size + quietZone + && matrix.get( + row - quietZone, + column - quietZone, + ) === 1; + const lines: string[] = []; + for (let row = 0; row < width; row += 2) { + let line = ""; + for (let column = 0; column < width; column += 1) { + const top = bit(row, column); + const bottom = bit(row + 1, column); + line += + top + ? bottom ? "█" : "▀" + : bottom ? "▄" : " "; + } + // Retain the right quiet zone; it is part of the scannable symbol. + lines.push(line); + } + return lines.join("\n"); + } +} diff --git a/ts/src/application/ports/qr-encoder.ts b/ts/src/application/ports/qr-encoder.ts new file mode 100644 index 000000000..0dfad8a08 --- /dev/null +++ b/ts/src/application/ports/qr-encoder.ts @@ -0,0 +1,4 @@ +export interface QrEncoder { + /** Returns null when the output device cannot display the full matrix safely. */ + encode(payload: string): string | null; +} diff --git a/ts/src/application/use-cases/wallet-service.ts b/ts/src/application/use-cases/wallet-service.ts index a33d783c8..ddf9208ee 100644 --- a/ts/src/application/use-cases/wallet-service.ts +++ b/ts/src/application/use-cases/wallet-service.ts @@ -52,8 +52,8 @@ export class WalletService { return { previous: result.previous, ...this.wallets.describe(result.accountId) }; } - current() { - const account = this.wallets.activeAccount(); + current(requestedAccount?: string) { + const account = requestedAccount ?? this.wallets.activeAccount(); if (!account) throw new WalletError("missing_wallet_address", "no active account; import one first"); return this.wallets.describe(account); } diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index c906279f0..ae6b829b4 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -40,6 +40,7 @@ import { AddressService } from "../application/use-cases/address-service.js"; import { SecureKeypairWriter } from "../adapters/outbound/persistence/keypair-writer.js"; import { registerEncodingCommands } from "../adapters/inbound/cli/commands/encoding.js"; import { registerAddressCommands } from "../adapters/inbound/cli/commands/address.js"; +import { TerminalQrEncoder } from "../adapters/outbound/qr/index.js"; export interface BootstrapOptions { readonly globals: Globals; @@ -88,7 +89,11 @@ export function composeCliRuntime(options: BootstrapOptions) { const txPipeline = new TxPipeline(signerResolver); const registry = new CommandRegistry(); - registerWalletCommands(registry, { walletService, ledger }); + registerWalletCommands(registry, { + walletService, + ledger, + qr: new TerminalQrEncoder(), + }); registerConfigCommands(registry, configService); registerNetworkCommands(registry); registerContactCommands(registry, new ContactService(contactBook)); From 080c351a5090ff6ac6d49dcecbd19b559e0e703e Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 13:13:07 +0800 Subject: [PATCH 09/24] fix(ts): expose v0.1.2 commands in help --- ts/src/adapters/inbound/cli/help/index.ts | 16 ++++++++++++---- ts/test/golden.test.ts | 6 ++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index 6bff3ff9e..aa6ef6747 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -98,14 +98,15 @@ export class HelpService { ["list", "List wallets / accounts", ""], ] as const const management = [ - ["account", "Query on-chain account state", ""], + ["account", "Query on-chain account state, activate & name accounts", ""], + ["permission", "View and update account multi-sign permissions", "tron"], ["token", "Manage the token address book and query tokens", ""], ["tx", "Build, send, broadcast, co-sign, and inspect transactions", ""], + ["gasfree", "Gas-free token transfers via the GasFree service", "tron"], ["contract", "Call, send, deploy, and inspect smart contracts", ""], ["stake", "Stake / delegate resources & query state", "tron"], ["vote", "Vote for super representatives", "tron"], ["reward", "Query / withdraw voting rewards", "tron"], - ["permission", "View and update account multi-sign permissions", "tron"], ["chain", "Query chain params, prices & node info", ""], ["message", "Sign arbitrary messages", ""], ["typed-data", "Sign EIP-712 / TIP-712 structured data", ""], @@ -113,7 +114,7 @@ export class HelpService { ] as const const commands = [ ["use", "Set the active account", ""], - ["current", "Show the current (active) account", ""], + ["current", "Show the current account (--qr for a receive QR code)", ""], ["rename", "Rename an account label", ""], ["derive", "Derive the next HD account from a seed wallet", ""], ["backup", "Export an account's secret + metadata (0600)", ""], @@ -121,6 +122,9 @@ export class HelpService { ["config", "Show / get / set configuration values", ""], ["networks", "List known networks", ""], ["change-password", "Change the master password (re-encrypt keystores)", ""], + ["encoding", "Convert / validate addresses & encodings across formats", ""], + ["address", "Generate a random keypair (local, not stored)", ""], + ["contact", "Manage the recipient address book", ""], ] as const const sections = [common, management, commands] as const const nameWidth = Math.max(...sections.flat().map(([name]) => name.length)) + 2 @@ -430,9 +434,10 @@ function globalFlagLine(g: GlobalFlag): string { // ` --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.", - account: "Query on-chain account state.", + account: "Query on-chain account state, activate accounts, and set on-chain identity fields.", token: "Manage the token address book and query tokens.", tx: "Build, send, broadcast, co-sign, and inspect transactions.", + gasfree: "Gas-free token transfers via the GasFree service (open.gasfree.io).\nRequires API credentials (config gasfreeApiKey / gasfreeApiSecret).", contract: "Call, send, deploy, and inspect smart contracts.", stake: "Stake / delegate resources & query state (TRON Stake 2.0).", vote: "Vote for super representatives (SR).\nVoting accrues rewards — query and claim them with 'wallet-cli reward'.", @@ -442,6 +447,9 @@ const GROUP_DESCRIPTIONS: Record = { message: "Sign arbitrary messages.", "typed-data": "Sign EIP-712 / TIP-712 structured data.", block: "Get a block (latest if omitted).", + encoding: "Convert and validate addresses and encodings across formats.", + address: "Generate a random secp256k1 keypair locally without storing it in the wallet.", + contact: "Manage the local recipient address book.\nNames can be used directly in 'tx send --to' and 'gasfree transfer --to'.", } /** "--output, -o " style header for text help. */ diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 05843d628..f2eacd449 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -76,6 +76,12 @@ describe("golden CLI — meta & introspection", () => { expect(r.stdout).toContain(" import") expect(r.stdout).toContain(" account") expect(r.stdout).toContain(" tx") + expect(r.stdout).toMatch(/^ permission\s/m) + expect(r.stdout).toMatch(/^ gasfree\s/m) + expect(r.stdout).toMatch(/^ encoding\s/m) + expect(r.stdout).toMatch(/^ address\s/m) + expect(r.stdout).toMatch(/^ contact\s/m) + expect(r.stdout).toContain("current account (--qr for a receive QR code)") expect(r.stdout).not.toContain("Learn more:") expect(r.stdout).not.toMatch(/^ import watch\s/m) expect(r.stdout).not.toMatch(/^ account balance\s/m) From 9d29343e6d1a6d782961462335f34ce2807f3662 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 16:06:16 +0800 Subject: [PATCH 10/24] fix(ts): parse numeric transaction options --- .../inbound/cli/commands/permission.ts | 4 +-- .../adapters/inbound/cli/commands/shared.ts | 4 +-- .../cli/commands/transaction-options.test.ts | 29 +++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/transaction-options.test.ts diff --git a/ts/src/adapters/inbound/cli/commands/permission.ts b/ts/src/adapters/inbound/cli/commands/permission.ts index 01a2f2a7b..b45fe3335 100644 --- a/ts/src/adapters/inbound/cli/commands/permission.ts +++ b/ts/src/adapters/inbound/cli/commands/permission.ts @@ -30,8 +30,8 @@ const updateFields = z.object({ dryRun: z.boolean().default(false).describe("validate, build, and estimate without signing or broadcasting"), signOnly: z.boolean().default(false).describe("build and sign, then output complete transaction hex"), buildOnly: z.boolean().default(false).describe("build and output unsigned complete transaction hex without unlocking"), - permissionId: z.number().int().min(0).max(9).default(0).describe("TRON permission group id used to authorize this transaction"), - expiration: z.number().int().min(1).max(86_400_000).optional().describe("expiration duration in milliseconds; only with --sign-only or --build-only"), + permissionId: z.coerce.number().int().min(0).max(9).default(0).describe("TRON permission group id used to authorize this transaction"), + expiration: z.coerce.number().int().min(1).max(86_400_000).optional().describe("expiration duration in milliseconds; only with --sign-only or --build-only"), }); export const permissionUpdateSpec: ChainSpec = { diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts index e856fb49d..2cbacaeda 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -15,8 +15,8 @@ export const txModeFields = { dryRun: z.boolean().default(false).describe("build and estimate only, with no signature and no broadcast"), signOnly: z.boolean().default(false).describe("sign and output complete transaction hex without broadcasting"), buildOnly: z.boolean().default(false).describe("build and output unsigned complete transaction hex without unlocking"), - permissionId: z.number().int().min(0).max(9).default(0).describe("TRON permission group id used to authorize this transaction"), - expiration: z.number().int().min(1).max(86_400_000).optional().describe("expiration duration in milliseconds; only with --sign-only or --build-only"), + permissionId: z.coerce.number().int().min(0).max(9).default(0).describe("TRON permission group id used to authorize this transaction"), + expiration: z.coerce.number().int().min(1).max(86_400_000).optional().describe("expiration duration in milliseconds; only with --sign-only or --build-only"), }; // ── unified --amount / --raw-amount selector (shared by every chain's `tx send`) ──── // A transfer of 0 is meaningless on any chain — reject it here (exit 2) rather than let the node diff --git a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts new file mode 100644 index 000000000..83e12e749 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { permissionUpdateSpec } from "./permission.js"; +import { txModeFields } from "./shared.js"; + +describe("transaction option argv coercion", () => { + it("accepts numeric --permission-id and --expiration values from argv", () => { + const parsed = z.object(txModeFields).parse({ + buildOnly: true, + permissionId: "2", + expiration: "86400000", + }); + + expect(parsed.permissionId).toBe(2); + expect(parsed.expiration).toBe(86_400_000); + }); + + it("applies the same argv coercion to permission update", () => { + const parsed = permissionUpdateSpec.baseFields.parse({ + file: "permissions.json", + buildOnly: true, + permissionId: "2", + expiration: "60000", + }); + + expect(parsed.permissionId).toBe(2); + expect(parsed.expiration).toBe(60_000); + }); +}); From 55b18e5ff6f9ccfac37fbe88463421a3778e41fc Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 16:09:15 +0800 Subject: [PATCH 11/24] fix(ts): accept unsigned multisig weight defaults --- .../chain/tron/tron.permissions.test.ts | 45 +++++++++++++++++++ ts/src/adapters/outbound/chain/tron/tron.ts | 4 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ts/src/adapters/outbound/chain/tron/tron.permissions.test.ts b/ts/src/adapters/outbound/chain/tron/tron.permissions.test.ts index 922ab0e33..29ed20c21 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.permissions.test.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.permissions.test.ts @@ -74,6 +74,51 @@ describe("TronRpcClient account permission boundary", () => { }); }); + it("materializes the omitted protobuf current_weight default as zero", async () => { + const client = new TronRpcClient("https://node.invalid", 100); + vi.spyOn(client.tronweb.trx, "getSignWeight").mockResolvedValue({ + result: { code: "NOT_ENOUGH_PERMISSION" }, + permission: { + permission_name: "owner", + threshold: 1, + keys: [{ address: A_HEX, weight: 1 }], + }, + } as never); + const transaction = decodeTransactionHex(encodeTransactionHex({ + visible: false, + raw_data: { + contract: [{ + type: "TransferContract", + parameter: { + type_url: "type.googleapis.com/protocol.TransferContract", + value: { + owner_address: A_HEX, + to_address: B_HEX, + amount: 1, + }, + }, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: 1_900_000_000_000, + expiration: 1_900_000_060_000, + }, + })); + + await expect( + client.getSignWeight(transaction), + ).resolves.toMatchObject({ + permission: { + id: 0, + threshold: 1, + keys: [{ address: A, weight: 1 }], + }, + approvedList: [], + currentWeight: 0, + resultCode: "NOT_ENOUGH_PERMISSION", + }); + }); + it("maps an empty account response to not_found", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 }))); await expect(new TronRpcClient("https://node.invalid", 100).getAccountPermissions(A)) diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 2ee4c726b..30ee537b7 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -235,7 +235,9 @@ export class TronRpcClient implements TronGateway, Broadcaster { keys: permission.keys.map(({ address: keyAddress, weight }) => ({ address: keyAddress, weight })), } : null, approvedList: (response.approved_list ?? []).map(hexToBase58), - currentWeight: safeNodeInteger(response.current_weight, "current_weight"), + // Protobuf JSON omits scalar zero values. An unsigned transaction therefore has no + // current_weight field even though the protocol value is exactly 0. + currentWeight: safeNodeInteger(response.current_weight ?? 0, "current_weight"), resultCode: String(response.result?.code ?? ""), message: decodeTronMessage(response.result?.message), }; From 73cb95186099f53e06971bad9a889313a71e46d9 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 29 Jul 2026 00:33:03 +0800 Subject: [PATCH 12/24] refactor(ts): separate TRON signing and multisig workflows --- ts/docs/commands/tx/sign.md | 55 ++++++- .../adapters/inbound/cli/commands/account.ts | 4 +- .../adapters/inbound/cli/commands/contract.ts | 4 +- .../adapters/inbound/cli/commands/gasfree.ts | 2 +- .../inbound/cli/commands/permission.ts | 2 +- .../adapters/inbound/cli/commands/reward.ts | 2 +- ts/src/adapters/inbound/cli/commands/stake.ts | 2 +- .../cli/commands/text-formatters.test.ts | 40 ++++- .../inbound/cli/commands/tx.sign.test.ts | 35 ++++- ts/src/adapters/inbound/cli/commands/tx.ts | 32 ++-- ts/src/adapters/inbound/cli/commands/vote.ts | 2 +- .../adapters/inbound/cli/contracts/command.ts | 7 +- ts/src/adapters/inbound/cli/help/help.test.ts | 9 ++ ts/src/adapters/inbound/cli/help/index.ts | 24 +-- .../adapters/inbound/cli/render/multisig.ts | 35 ++++- ts/src/adapters/outbound/config/builtins.ts | 1 + .../tron/multisig-authorization.test.ts | 14 +- .../use-cases/tron/multisig-authorization.ts | 31 +--- ...=> multisig-collaboration-service.test.ts} | 23 ++- ...e.ts => multisig-collaboration-service.ts} | 17 ++- .../use-cases/tron/multisig-service.test.ts | 24 ++- .../use-cases/tron/multisig-service.ts | 58 +++----- .../use-cases/tron/sig-service.test.ts | 137 ++++++++++++++++++ .../application/use-cases/tron/sig-service.ts | 106 ++++++++++++++ .../use-cases/tron/transaction-artifact.ts | 28 ++++ ts/src/bootstrap/families/tron.ts | 19 ++- ts/src/domain/permission/index.ts | 16 +- ts/src/domain/permission/permission.test.ts | 11 ++ ts/src/domain/types/multisig.ts | 31 +++- ts/test/golden.test.ts | 2 +- 30 files changed, 628 insertions(+), 145 deletions(-) rename ts/src/application/use-cases/tron/{tronlink-multisig-service.test.ts => multisig-collaboration-service.test.ts} (91%) rename ts/src/application/use-cases/tron/{tronlink-multisig-service.ts => multisig-collaboration-service.ts} (96%) create mode 100644 ts/src/application/use-cases/tron/sig-service.test.ts create mode 100644 ts/src/application/use-cases/tron/sig-service.ts create mode 100644 ts/src/application/use-cases/tron/transaction-artifact.ts diff --git a/ts/docs/commands/tx/sign.md b/ts/docs/commands/tx/sign.md index c4b81469b..82bc0cc23 100644 --- a/ts/docs/commands/tx/sign.md +++ b/ts/docs/commands/tx/sign.md @@ -5,13 +5,14 @@ Sign a transaction built elsewhere, without broadcasting. ## Synopsis ``` -wallet-cli tx sign --transaction [options] +wallet-cli tx sign (--transaction | --hex | --file ) [options] ``` ## Description Signs a transaction that was constructed outside this CLI with the active account's key (or -`--account`) and prints the signed result. Nothing is broadcast — pass the signed payload to +`--account`) and prints the signed result. JSON input retains the original direct-signing response. +Hex/file input produces a portable protobuf transaction artifact for multi-party signing. Nothing is broadcast — pass the signed payload to [`tx broadcast`](broadcast.md) when you are ready. `--network` is optional: signing itself is offline for software accounts. @@ -42,14 +43,20 @@ Watch-only accounts cannot sign (`watch_only_no_signer`). Ledger accounts sign o ### Multi-sig co-signing -If the transaction you pass already carries a `signature` array, this command **appends** its +With `--hex` or `--file`, the command decodes the complete protobuf transaction and **appends** its signature rather than replacing what is there. That is what TRON multi-sig requires — each permitted key signs the same transaction in turn — so a partially signed transaction can be passed from signer to signer and broadcast once the permission threshold is met. -The consequence worth knowing: `data.signed.signature` may contain signatures this wallet did not -produce. `data.address` always tells you which key *this* invocation used, and the text receipt -numbers them so you can tell them apart: +This path is offline by default: it verifies transaction integrity, expiration, and append-only +signature behavior locally, but does not claim that the signer belongs to the selected permission +or that the threshold has been reached. Use `--check` when connected to a node to verify those +facts through `getsignweight` and `getapprovedlist`, or inspect the result later with +`wallet-cli tx approvals`. + +The JSON compatibility path also preserves existing signatures. `data.signed.signature` may +therefore contain signatures this wallet did not produce. `data.address` always tells you which key +this invocation used, and the text receipt numbers them so you can tell them apart: ```console ✅ Signed transaction @@ -64,6 +71,10 @@ numbers them so you can tell them apart: | Option | Description | |---|---| | `--transaction ` | Unsigned transaction JSON (`raw_data`, `raw_data_hex` and `txID` must agree) | +| `--hex ` | Complete protobuf `protocol.Transaction` hex | +| `--file ` | Read complete transaction hex from a file | +| `--out ` | Atomically write the resulting signed hex to a file | +| `--check` | Verify signer permission and resulting approval weight online | | `--password-stdin` | Master password from stdin (software accounts) | Plus the [global options](../index.md#global-options-every-command). @@ -98,6 +109,21 @@ echo "$PW" | wallet-cli tx sign --transaction "$TX" --password-stdin -o json \ wallet-cli tx broadcast --network tron:nile --tx-stdin < signed.json ``` +Append a signature without an approval RPC dependency, then check it from an online machine: + +```bash +echo "$PW" | wallet-cli tx sign --file partially-signed.hex \ + --out signed.hex --password-stdin +wallet-cli tx approvals --file signed.hex +``` + +To check permission membership and approval progress during signing: + +```bash +echo "$PW" | wallet-cli tx sign --file partially-signed.hex \ + --check --out signed.hex --password-stdin +``` + A transaction whose fields disagree is refused rather than signed: ```console @@ -106,6 +132,8 @@ A transaction whose fields disagree is refused rather than signed: ## Output +`--transaction` retains the original direct-signing shape: + | Field | Type | Meaning | |---|---|---| | `kind` | string | `"sign"` | @@ -116,9 +144,22 @@ A transaction whose fields disagree is refused rather than signed: No `fee` is reported: nothing was estimated, because the transaction was not built here. +`--hex` and `--file` return the artifact-signing shape: + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `"tx-sign"` | +| `signer` | string | Address that appended this signature | +| `hex` | string | Complete transaction hex including all signatures | +| `checked` | boolean | Whether online permission/approval verification ran | +| `transaction` | object | Locally decoded transaction summary | +| `signerWeight` | number | Present only when `checked` is true | +| `approval` | object | Present only when `checked` is true; authoritative online approval state | + ## Exit status -`0` signed · `1` execution failure (`tx_integrity`, `watch_only_no_signer`, `auth_failed`, +`0` signed · `1` execution failure (`tx_integrity`, `tx_expired`, `not_authorized`, +`already_signed`, `watch_only_no_signer`, `auth_failed`, `signing_rejected`) · `2` usage error (missing or malformed `--transaction` → `missing_option` / `invalid_value`). diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts index cb0964654..9e53e2bc7 100644 --- a/ts/src/adapters/inbound/cli/commands/account.ts +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -34,7 +34,7 @@ export const accountActivateSpec: ChainSpec = { path: ["account", "activate"], network: "optional", wallet: "optional", - auth: "required", + auth: "conditional", broadcasts: true, capability: "account.activate", summary: "Activate a new TRON account", @@ -62,7 +62,7 @@ export const accountSetSpec: ChainSpec = { path: ["account", "set"], network: "optional", wallet: "optional", - auth: "required", + auth: "conditional", broadcasts: true, capability: "account.set", summary: "Set the one-time on-chain account name or ID", diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index b20bbcf77..153e7d63a 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -75,7 +75,7 @@ const sendFields = z.object({ export const contractSendSpec: ChainSpec = { path: ["contract", "send"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: "contract.call", summary: "State-changing call (triggerSmartContract)", @@ -104,7 +104,7 @@ const deployFields = z.object({ export const contractDeploySpec: ChainSpec = { path: ["contract", "deploy"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: "contract.deploy", summary: "Deploy a smart contract", diff --git a/ts/src/adapters/inbound/cli/commands/gasfree.ts b/ts/src/adapters/inbound/cli/commands/gasfree.ts index 0aba4f53d..c425f2b2b 100644 --- a/ts/src/adapters/inbound/cli/commands/gasfree.ts +++ b/ts/src/adapters/inbound/cli/commands/gasfree.ts @@ -42,7 +42,7 @@ export const gasFreeTransferSpec: ChainSpec = { path: ["gasfree", "transfer"], network: "optional", wallet: "optional", - auth: "required", + auth: "conditional", broadcasts: true, capability: "gasfree.transfer", requires: [ diff --git a/ts/src/adapters/inbound/cli/commands/permission.ts b/ts/src/adapters/inbound/cli/commands/permission.ts index b45fe3335..b71901829 100644 --- a/ts/src/adapters/inbound/cli/commands/permission.ts +++ b/ts/src/adapters/inbound/cli/commands/permission.ts @@ -38,7 +38,7 @@ export const permissionUpdateSpec: ChainSpec = { path: ["permission", "update"], network: "optional", wallet: "optional", - auth: "required", + auth: "conditional", broadcasts: true, capability: "permission.update", summary: "Replace the complete account permission structure", diff --git a/ts/src/adapters/inbound/cli/commands/reward.ts b/ts/src/adapters/inbound/cli/commands/reward.ts index 75bd1e8d4..7cf79373d 100644 --- a/ts/src/adapters/inbound/cli/commands/reward.ts +++ b/ts/src/adapters/inbound/cli/commands/reward.ts @@ -24,7 +24,7 @@ export const rewardBalanceTronBinding = (svc: TronRewardService): FamilyBinding export const rewardWithdrawSpec: ChainSpec = { path: ["reward", "withdraw"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: "reward.withdraw", summary: "Withdraw accrued voting/block rewards", diff --git a/ts/src/adapters/inbound/cli/commands/stake.ts b/ts/src/adapters/inbound/cli/commands/stake.ts index 44b1873e3..faa82c78f 100644 --- a/ts/src/adapters/inbound/cli/commands/stake.ts +++ b/ts/src/adapters/inbound/cli/commands/stake.ts @@ -38,7 +38,7 @@ function stakeCommand( return { spec: { path: ["stake", action], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: options.capability ?? "staking.freeze", summary, 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 f546e7787..bbc8f7afd 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -252,22 +252,38 @@ describe("local multisig formatters", () => { }); it("shows the next broadcast command only after threshold is reached", () => { + const transaction = { + txId: approval.txId, + contractType: approval.contractType, + operation: approval.operation, + from: approval.from, + to: approval.to, + rawAmount: approval.rawAmount, + permissionId: approval.permission.id, + expiration: approval.expiration, + expired: approval.expired, + signatures: approval.signatures, + }; const pending = TextFormatters.txSign({ kind: "tx-sign", signer: "Tsigner", + checked: true, signerWeight: 1, hex: "aabb", - transaction: approval, + transaction, + approval, }) as string; expect(pending).not.toContain("wallet-cli tx broadcast"); const ready = TextFormatters.txSign({ kind: "tx-sign", signer: "Tsigner2", + checked: true, signerWeight: 1, hex: "ccdd", out: "signed.hex", - transaction: { + transaction: { ...transaction, signatures: 2 }, + approval: { ...approval, currentWeight: 2, missingWeight: 0, @@ -276,6 +292,26 @@ describe("local multisig formatters", () => { }) as string; expect(ready).toContain("wallet-cli tx broadcast --file signed.hex"); }); + + it("labels offline signing and points to the explicit approval check", () => { + const out = TextFormatters.txSign({ + kind: "tx-sign", + signer: "Tsigner", + checked: false, + hex: "aabb", + transaction: { + txId: approval.txId, + contractType: approval.contractType, + permissionId: approval.permission.id, + expiration: approval.expiration, + expired: false, + signatures: 1, + }, + }) as string; + expect(out).toContain("Approval state was not checked online"); + expect(out).toContain("wallet-cli tx approvals --hex "); + expect(out).not.toContain("weight"); + }); }); describe("txStatus formatter (family-agnostic; command supplies `state`)", () => { diff --git a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts index 6ec74c2b9..ecfcbb2e7 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts @@ -21,6 +21,7 @@ describe("tx sign spec", () => { expect(txSignSpec.baseFields.safeParse({ transaction: "{}" }).success).toBe(true); expect(txSignSpec.baseFields.safeParse({ hex: "abcd" }).success).toBe(true); expect(txSignSpec.baseFields.safeParse({ file: "tx.hex" }).success).toBe(true); + expect(txSignSpec.baseFields.safeParse({ hex: "abcd", check: true }).success).toBe(true); }); // the payload is not a secret, so argv is the only channel — no --tx-stdin on this command. @@ -38,29 +39,47 @@ describe("tx sign binding", () => { return { kind: "sign" }; }, }; - await txSignTronBinding(svc as never, {} as never, {} as never) + await txSignTronBinding(svc as never, {} as never, {} as never, {} as never) .run(ctx, net, { transaction: '{"txID":"abc"}' }); expect(received).toEqual({ txID: "abc" }); }); it("rejects malformed JSON with invalid_value", async () => { const svc = { sign: async () => ({}) }; - await expect(txSignTronBinding(svc as never, {} as never, {} as never) + await expect(txSignTronBinding(svc as never, {} as never, {} as never, {} as never) .run(ctx, net, { transaction: "not json" })) .rejects.toMatchObject({ code: "invalid_value" }); }); - it("routes hex co-signing through the multisig service and writes --out", async () => { - const multisig = { - sign: async () => ({ kind: "tx-sign", hex: "beef", signer: "T1", signerWeight: 1, transaction: {} }), + it("routes hex signing through the offline signing service and writes --out", async () => { + const signing = { + sign: async () => ({ kind: "tx-sign", hex: "beef", signer: "T1", checked: false, transaction: {} }), }; let written: unknown; const writer = { write: (path: string, hex: string) => { written = { path, hex }; } }; - const result = await txSignTronBinding({} as never, multisig as never, writer as never) + const result = await txSignTronBinding({} as never, signing as never, {} as never, writer as never) .run(ctx, net, { hex: "abcd", out: "signed.hex" }); expect(written).toEqual({ path: "signed.hex", hex: "beef" }); expect(result).toMatchObject({ out: "signed.hex", hex: "beef" }); }); + + it("routes --check through the multisig authorization service", async () => { + const signing = { sign: async () => { throw new Error("unexpected offline route"); } }; + const multisig = { + signChecked: async () => ({ + kind: "tx-sign", + hex: "beef", + signer: "T1", + checked: true, + signerWeight: 1, + transaction: {}, + approval: {}, + }), + }; + await expect(txSignTronBinding({} as never, signing as never, multisig as never, {} as never) + .run(ctx, net, { hex: "abcd", check: true })) + .resolves.toMatchObject({ checked: true, signerWeight: 1 }); + }); }); describe("tx broadcast binding", () => { @@ -128,9 +147,9 @@ describe("tx multisig spec", () => { expect(txTronLinkMultisigSpec.baseFields.safeParse({ watch: true }).success).toBe(true); }); - it("declares no broadcast and uses lazy signing authentication", () => { + it("declares no broadcast and only requires auth for --sign", () => { expect(txTronLinkMultisigSpec.broadcasts).toBeFalsy(); - expect(txTronLinkMultisigSpec.auth).toBe("required"); + expect(txTronLinkMultisigSpec.auth).toBe("conditional"); expect(txTronLinkMultisigSpec.passwordMode).toBeUndefined(); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index 46cb00820..cbb4308c1 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -2,8 +2,9 @@ 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 type { TronSigService } from "../../../../application/use-cases/tron/sig-service.js"; import type { TronMultisigService } from "../../../../application/use-cases/tron/multisig-service.js"; -import type { TronLinkMultisigService } from "../../../../application/use-cases/tron/tronlink-multisig-service.js"; +import type { TronMultisigCollaborationService } from "../../../../application/use-cases/tron/multisig-collaboration-service.js"; import type { TransactionArtifactWriter } from "../../../outbound/persistence/transaction-artifact-writer.js"; import { Schemas } from "../schemas/index.js"; import { @@ -36,7 +37,7 @@ const sendFields = z.object({ export const txSendSpec: ChainSpec = { path: ["tx", "send"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: "tx.send", summary: "Send native TRX or TRC20/TRC10 tokens with human --amount", @@ -142,6 +143,8 @@ const signFields = z.object({ transaction: z.string().min(1).optional() .describe("unsigned TRON transaction JSON; retained for direct single-signature compatibility"), ...artifactFields, + check: z.boolean().default(false) + .describe("verify signer permission and resulting approval weight online"), out: z.string().min(1).optional().describe("atomically write co-signed transaction hex to this file"), }); @@ -149,12 +152,12 @@ export const txSignSpec: ChainSpec = { path: ["tx", "sign"], network: "optional", wallet: "optional", auth: "required", broadcasts: false, - capability: "tx.multisig.local", - summary: "Sign transaction JSON or append a signature to transaction hex", + capability: "tx.sign", + summary: "Sign transaction JSON or append a signature to transaction hex offline", description: - "With --transaction, preserve the direct JSON signing flow. With --hex/--file, validate the\n" + - "selected permission, append exactly one signature, preserve prior signatures, and report\n" + - "the new approval weight. This command never broadcasts.", + "With --transaction, preserve the direct JSON signing flow. With --hex/--file, append exactly\n" + + "one signature while preserving prior signatures without requiring a node. Add --check to\n" + + "verify permission membership and approval weight online. This command never broadcasts.", baseFields: signFields, baseRefine: (input, context) => { if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length !== 1) { @@ -167,28 +170,37 @@ export const txSignSpec: ChainSpec = { if (input.out && input.transaction) { context.addIssue({ code: "custom", path: ["out"], message: "--out is only valid with --hex or --file" }); } + if (input.check && input.transaction) { + context.addIssue({ code: "custom", path: ["check"], message: "--check is only valid with --hex or --file" }); + } }, examples: [ { cmd: `wallet-cli tx sign --transaction '{"txID":"...","raw_data":{...},"raw_data_hex":"..."}'` }, { cmd: "wallet-cli tx sign --file partially-signed.hex --out signed.hex --password-stdin" }, + { cmd: "wallet-cli tx sign --file partially-signed.hex --check --password-stdin" }, ], formatText: TextFormatters.txSign, }; export const txSignTronBinding = ( transactionService: TronTransactionService, + signingService: TronSigService, multisigService: TronMultisigService, writer: TransactionArtifactWriter, ): FamilyBinding => ({ run: async (ctx, net, input) => { exactlyOne([input.transaction, input.hex, input.file], "provide exactly one of --transaction, --hex, or --file"); if (!input.transaction) { - const result = await multisigService.sign(ctx, net, hexInput(input)); + const hex = hexInput(input); + const result = input.check + ? await multisigService.signChecked(ctx, net, hex) + : await signingService.sign(ctx, net, hex); if (!input.out) return result; writer.write(input.out, result.hex); return { ...result, out: input.out }; } if (input.out) throw new UsageError("invalid_option", "--out is only valid with --hex or --file"); + if (input.check) throw new UsageError("invalid_option", "--check is only valid with --hex or --file"); let tx: unknown; try { tx = JSON.parse(input.transaction); @@ -212,7 +224,7 @@ const tronLinkMultisigFields = z.object({ export const txTronLinkMultisigSpec: ChainSpec = { path: ["tx", "multisig"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", capability: "tx.multisig.tronlink", summary: "Coordinate multi-signature collection through the TronLink service", description: @@ -233,7 +245,7 @@ export const txTronLinkMultisigSpec: ChainSpec = { formatText: TextFormatters.txTronLinkMultisig, }; -export const txTronLinkMultisigBinding = (service: TronLinkMultisigService): FamilyBinding => ({ +export const txTronLinkMultisigBinding = (service: TronMultisigCollaborationService): FamilyBinding => ({ run: async (ctx, network, input) => { const address = ctx.resolveAddress("tron"); if (input.create) return service.create(network, address, hexInput(input)); diff --git a/ts/src/adapters/inbound/cli/commands/vote.ts b/ts/src/adapters/inbound/cli/commands/vote.ts index 806d8be26..deb172938 100644 --- a/ts/src/adapters/inbound/cli/commands/vote.ts +++ b/ts/src/adapters/inbound/cli/commands/vote.ts @@ -11,7 +11,7 @@ const voteForField = z.array(z.string().min(1)).min(1).max(30) export const voteCastSpec: ChainSpec = { path: ["vote", "cast"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", wallet: "optional", auth: "conditional", broadcasts: true, capability: "vote.cast", summary: "Cast or replace your full SR vote allocation", diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index 482d5f8fc..604a0ebfc 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -13,9 +13,10 @@ export interface Example { // "optional" = the command operates on an account; --account is optional and falls back to the // active account (errors only if no account exists at all). "none" = never touches an account. // (No "required": no command forces --account — active is always a valid default. cf. network.) -// "required" = unlocks the master password (sign / read secrets / encrypt); -// "none" = never unlocks. (No middle state — a command either needs the password or it doesn't.) -export type AuthRequirement = "none" | "required"; +// "required" = every execution needs the master password (sign / read secrets / encrypt); +// "conditional" = only selected execution modes need it; other modes run without a password; +// "none" = never needs it. +export type AuthRequirement = "none" | "conditional" | "required"; /** 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 diff --git a/ts/src/adapters/inbound/cli/help/help.test.ts b/ts/src/adapters/inbound/cli/help/help.test.ts index 6d6d80b0d..1d1e41a20 100644 --- a/ts/src/adapters/inbound/cli/help/help.test.ts +++ b/ts/src/adapters/inbound/cli/help/help.test.ts @@ -109,4 +109,13 @@ describe("Requires: master password line", () => { expect(renderAuthLine({ interactive: true })).toContain("enter it interactively in a TTY") expect(renderAuthLine({ interactive: true })).not.toContain("never prompts") }) + + // "locked" is taken: stake help uses it for the TRX freeze period, and Ledger help uses + // "unlocked" for the device. Say what the reader must supply, not what state the keystore is in. + it("describes mode-dependent authentication in terms of the password, not a lock state", () => { + const line = renderAuthLine({ auth: "conditional" }) + expect(line).toContain("only when the selected mode signs") + expect(line).toContain("other modes need no password") + expect(line).not.toContain("locked") + }) }) diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index aa6ef6747..b9e8e9128 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -276,13 +276,19 @@ export class HelpService { // A command only prompts when it opts in (`interactive`); everything else fails fast so // scripts and agents get a deterministic error instead of a hung prompt. Say which one this // is — promising a TTY prompt that never comes sends the reader hunting for a broken terminal. - if (c.auth === "required") requires.push( - c.secretsTtyOnly - ? "the master password — entered interactively in a TTY" - : c.interactive - ? "master password — pass --password-stdin for non-interactive use, or enter it interactively in a TTY" - : "master password — pass --password-stdin; this command never prompts", - ) + if (c.auth === "required") { + requires.push( + c.secretsTtyOnly + ? "the master password — entered interactively in a TTY" + : c.interactive + ? "master password — pass --password-stdin for non-interactive use, or enter it interactively in a TTY" + : "master password — pass --password-stdin; this command never prompts", + ) + } else if (c.auth === "conditional") { + requires.push( + "the master password only when the selected mode signs — pass --password-stdin then; other modes need no password", + ) + } 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:") @@ -404,7 +410,7 @@ function formatDefault(v: unknown): string { } // Per-command "Global options" projection: output/timeout/verbose always; --network only when the -// command selects a network; --password-stdin only when it requires unlock; --wait/--wait-timeout +// command selects a network; --password-stdin when it may unlock; --wait/--wait-timeout // 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( @@ -417,7 +423,7 @@ function globalFlagsForText( 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" && !secretsTtyOnly + if (g.flag === "--password-stdin") return auth !== "none" && !secretsTtyOnly if (g.flag === "--wait" || g.flag === "--wait-timeout") return broadcasts return true }) diff --git a/ts/src/adapters/inbound/cli/render/multisig.ts b/ts/src/adapters/inbound/cli/render/multisig.ts index 6e9eb8fcd..680c55749 100644 --- a/ts/src/adapters/inbound/cli/render/multisig.ts +++ b/ts/src/adapters/inbound/cli/render/multisig.ts @@ -1,6 +1,7 @@ import type { TronLinkMultisigView, TxApprovalView, + TxSignTransactionView, TxSignView, } from "../../../../domain/types/index.js"; import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; @@ -91,14 +92,42 @@ function renderTronLink(value: TronLinkMultisigView): string { function renderSign(value: TxSignView): string { const artifact = value.out ? `written to ${value.out}` : value.hex; + const signer = value.checked + ? `${value.signer} (weight ${formatInt(value.signerWeight)})` + : value.signer; const action = receipt(ok(), "Signature added", [ - ["Signer", `${value.signer} (weight ${formatInt(value.signerWeight)})`], + ["Signer", signer], ["Hex", artifact], ]); - const next = value.transaction.thresholdReached + if (!value.checked || !value.approval) { + return `${action}\n\n${renderSignedTransaction(value.transaction)}\n` + + "! Approval state was not checked online. Inspect it with: " + + `wallet-cli tx approvals ${value.out ? `--file ${value.out}` : "--hex "}`; + } + const next = value.approval.thresholdReached ? `\n! Broadcast it: wallet-cli tx broadcast ${value.out ? `--file ${value.out}` : "--hex "}` : ""; - return `${action}\n\n${renderApproval(value.transaction)}${next}`; + return `${action}\n\n${renderApproval(value.approval)}${next}`; +} + +function renderSignedTransaction(value: TxSignTransactionView): string { + const permissionKind = value.permissionId === 0 ? "owner" : value.permissionId === 1 ? "witness" : "active"; + const label = value.operation ? `${value.operation} (${value.contractType})` : value.contractType; + const type = !value.rawAmount + ? label + : value.contractType === "TransferContract" + ? `${label} — ${formatSun(value.rawAmount)} TRX` + : `${label} — ${value.rawAmount} base units`; + const transaction = query([ + ["TxID", value.txId], + ["Type", type], + ["From", value.from ?? ""], + ["To", value.to ?? ""], + ["Permission", `${permissionKind} (id ${value.permissionId})`], + ["Signatures", formatInt(value.signatures)], + ["Expires", `${formatAtWithRelative(value.expiration)}${value.expired ? " [EXPIRED]" : ""}`], + ]); + return `Transaction (local inspection)\n${transaction.split("\n").map((line) => ` ${line}`).join("\n")}`; } export const MultisigFormatters = { diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index 47f955c05..95e26b6a6 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -22,6 +22,7 @@ export const CAP_SUMMARIES: Record = { "account.set": "set one-time on-chain account name or ID", "token.tokenbook": "token address-book (add/list/remove)", "tx.send": "transfer native / token", + "tx.sign": "sign transaction artifacts without broadcasting", "tx.broadcast": "broadcast a presigned transaction", "tx.multisig.local": "inspect and append local multi-sign approvals", "tx.multisig.tronlink": "coordinate multi-sign approvals through TronLink", diff --git a/ts/src/application/use-cases/tron/multisig-authorization.test.ts b/ts/src/application/use-cases/tron/multisig-authorization.test.ts index 90f15d2f3..cb85c07d9 100644 --- a/ts/src/application/use-cases/tron/multisig-authorization.test.ts +++ b/ts/src/application/use-cases/tron/multisig-authorization.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import type { TronTransactionArtifact } from "../../../domain/types/index.js"; import type { TronGateway } from "../../ports/chain/tron-gateway.js"; -import { assertTronSignerAuthorized, authorizationState } from "./multisig-authorization.js"; +import { + assertTronSignerAuthorized, + authorizationState, + tronTransactionHooks, +} from "./multisig-authorization.js"; const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; @@ -37,6 +41,14 @@ function gateway(over: Partial = {}): TronGateway { } describe("TRON multisig authorization preflight", () => { + it("does not attach approval RPC checks to ordinary transaction commands", () => { + const hooks = tronTransactionHooks({ + prepareTransaction: vi.fn((value) => value), + encodeTransactionHex: vi.fn(() => "abcd"), + } as unknown as TronGateway); + expect("preflight" in hooks).toBe(false); + }); + it("accepts an unused key whose active bitmap allows the transaction contract", async () => { await expect(assertTronSignerAuthorized(gateway(), transaction(), A, 1_900_000_000_000)) .resolves.toBeUndefined(); diff --git a/ts/src/application/use-cases/tron/multisig-authorization.ts b/ts/src/application/use-cases/tron/multisig-authorization.ts index daadf34f8..1cc0e7896 100644 --- a/ts/src/application/use-cases/tron/multisig-authorization.ts +++ b/ts/src/application/use-cases/tron/multisig-authorization.ts @@ -3,29 +3,7 @@ import { ChainError } from "../../../domain/errors/index.js"; import { decodeOperations } from "../../../domain/permission/index.js"; import { addressCodec } from "../../../domain/family/index.js"; import type { TronGateway, TronSignWeight } from "../../ports/chain/tron-gateway.js"; - -export function transactionContract(transaction: TronTransactionArtifact) { - const contracts = transaction.raw_data?.contract; - if (!Array.isArray(contracts) || contracts.length !== 1) { - throw new ChainError("invalid_transaction", "transaction must contain exactly one contract"); - } - return contracts[0]!; -} - -export function expirationOf(transaction: TronTransactionArtifact): number { - const expiration = transaction.raw_data.expiration; - if (!Number.isSafeInteger(expiration) || expiration! <= 0) { - throw new ChainError("invalid_transaction", "transaction expiration is missing or imprecise"); - } - return expiration!; -} - -export function assertNotExpired(transaction: TronTransactionArtifact, now = Date.now()): void { - const expiration = expirationOf(transaction); - if (expiration <= now) { - throw new ChainError("tx_expired", `transaction expired at ${new Date(expiration).toISOString()}`); - } -} +import { assertNotExpired, transactionContract } from "./transaction-artifact.js"; function uniqueAddresses(addresses: readonly string[], field: string): Set { const set = new Set(addresses); @@ -125,13 +103,14 @@ export async function assertTronSignerAuthorized( } } -/** Shared TRON transaction hooks for permission binding, protobuf artifacts, and signer preflight. */ +/** + * Shared transaction-construction hooks. Permission approval checks belong to explicit + * multi-signature workflows so ordinary signing does not depend on online approval endpoints. + */ export function tronTransactionHooks(gateway: TronGateway) { return { prepare: (transaction: UnsignedTx, options: { permissionId: number; expiration?: number }) => gateway.prepareTransaction(transaction, options), artifact: (transaction: UnsignedTx) => gateway.encodeTransactionHex(transaction), - preflight: (transaction: UnsignedTx, signerAddress: string) => - assertTronSignerAuthorized(gateway, transaction, signerAddress), }; } diff --git a/ts/src/application/use-cases/tron/tronlink-multisig-service.test.ts b/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts similarity index 91% rename from ts/src/application/use-cases/tron/tronlink-multisig-service.test.ts rename to ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts index 7800e1a50..cd4f4bead 100644 --- a/ts/src/application/use-cases/tron/tronlink-multisig-service.test.ts +++ b/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts @@ -11,7 +11,7 @@ import { encodeTransactionHex, } from "../../../adapters/outbound/chain/tron/transaction-codec.js"; import type { TronMultisigService } from "./multisig-service.js"; -import { TronLinkMultisigService } from "./tronlink-multisig-service.js"; +import { TronMultisigCollaborationService } from "./multisig-collaboration-service.js"; const A = "TLZz5XKerAAebbRdScB3jmSPr5DHSpGJJP"; const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; @@ -124,16 +124,25 @@ function setup(record = remote()) { const signedHex = encodeTransactionHex({ ...decodeTransactionHex(unsignedHex()), signature: [SIG] }); const local = { approvals: vi.fn(async (_network, hex: string) => approval(hex)), - sign: vi.fn(async () => ({ + signChecked: vi.fn(async () => ({ kind: "tx-sign", signer: A, signerWeight: 1, hex: signedHex, - transaction: approval(signedHex), + checked: true, + transaction: { + txId: decodeTransactionHex(signedHex).txID, + contractType: "TransferContract", + permissionId: 2, + expiration: NOW + 86_400_000, + expired: false, + signatures: 1, + }, + approval: approval(signedHex), })), } as unknown as TronMultisigService; return { - service: new TronLinkMultisigService(collaboration, provider, local, () => NOW), + service: new TronMultisigCollaborationService(collaboration, provider, local, () => NOW), collaboration, local, signedHex, @@ -168,7 +177,7 @@ describe("TronLink multi-sign collaboration workflow", () => { const { service, collaboration, local } = setup(); const result = await service.create(NETWORK, A, unsignedHex()); expect(result).toMatchObject({ action: "create", accepted: true, transaction: { currentWeight: 0 } }); - expect(local.sign).not.toHaveBeenCalled(); + expect(local.signChecked).not.toHaveBeenCalled(); const request = vi.mocked(collaboration.create).mock.calls[0]![2]; expect(request).toMatchObject({ permissionName: "finance", @@ -184,7 +193,7 @@ describe("TronLink multi-sign collaboration workflow", () => { const { service, collaboration, local, signedHex } = setup(); await expect(service.create(NETWORK, A, signedHex)) .rejects.toMatchObject({ code: "invalid_value" }); - expect(local.sign).not.toHaveBeenCalled(); + expect(local.signChecked).not.toHaveBeenCalled(); expect(collaboration.create).not.toHaveBeenCalled(); }); @@ -194,7 +203,7 @@ describe("TronLink multi-sign collaboration workflow", () => { const context = scope(); const result = await service.sign(context, NETWORK, txId); expect(result).toMatchObject({ action: "sign", accepted: true, hex: signedHex }); - expect(local.sign).toHaveBeenCalledWith(context, NETWORK, unsignedHex()); + expect(local.signChecked).toHaveBeenCalledWith(context, NETWORK, unsignedHex()); expect(collaboration.submit).toHaveBeenCalledTimes(1); }); diff --git a/ts/src/application/use-cases/tron/tronlink-multisig-service.ts b/ts/src/application/use-cases/tron/multisig-collaboration-service.ts similarity index 96% rename from ts/src/application/use-cases/tron/tronlink-multisig-service.ts rename to ts/src/application/use-cases/tron/multisig-collaboration-service.ts index 9db263fb8..3cc22f7ba 100644 --- a/ts/src/application/use-cases/tron/tronlink-multisig-service.ts +++ b/ts/src/application/use-cases/tron/multisig-collaboration-service.ts @@ -27,14 +27,17 @@ interface ValidatedRemoteRecord { hex: string; } -/** Secure collaboration workflow: the remote coordinator is never treated as a trust root. */ -export class TronLinkMultisigService { +/** + * Multi-signature collaboration use case. TronLink is an external coordination port, + * never the source of truth for transaction bytes or on-chain approval state. + */ +export class TronMultisigCollaborationService { readonly #address = new TronAddress(); constructor( private readonly collaboration: TronLinkCollaborationPort, private readonly gateways: ChainGatewayProvider, - private readonly local: TronMultisigService, + private readonly multisig: TronMultisigService, private readonly now: () => number = () => Date.now(), ) {} @@ -64,7 +67,7 @@ export class TronLinkMultisigService { if ((transaction.signature?.length ?? 0) !== 0) { throw new UsageError("invalid_value", "TronLink --create requires an unsigned transaction"); } - const approval = await this.local.approvals(network, unsignedHex); + const approval = await this.multisig.approvals(network, unsignedHex); if (approval.expired) throw new ChainError("tx_expired", "transaction has expired"); const weight = await gateway.getSignWeight(transaction); @@ -105,7 +108,7 @@ export class TronLinkMultisigService { } if (remote.view.expired) throw new ChainError("tx_expired", "transaction has expired"); - const signed = await this.local.sign(scope, network, remote.hex); + const signed = await this.multisig.signChecked(scope, network, remote.hex); const gateway = this.gateways.get(network, "tron"); await this.collaboration.submit(network, signed.signer, visibleTransaction(gateway, signed.hex)); return { @@ -114,7 +117,7 @@ export class TronLinkMultisigService { signer: signed.signer, signerWeight: signed.signerWeight, hex: signed.hex, - transaction: signed.transaction, + transaction: signed.approval, }; } @@ -272,7 +275,7 @@ export class TronLinkMultisigService { } async #verifyOnChain(network: NetworkDescriptor, remote: ValidatedRemoteRecord): Promise { - const approval = await this.local.approvals(network, remote.hex); + const approval = await this.multisig.approvals(network, remote.hex); const signedProgress = remote.view.signatureProgress .filter((entry) => entry.signed) .map((entry) => entry.address); diff --git a/ts/src/application/use-cases/tron/multisig-service.test.ts b/ts/src/application/use-cases/tron/multisig-service.test.ts index 4c648893f..092ed13ba 100644 --- a/ts/src/application/use-cases/tron/multisig-service.test.ts +++ b/ts/src/application/use-cases/tron/multisig-service.test.ts @@ -10,6 +10,7 @@ import { encodeTransactionHex, } from "../../../adapters/outbound/chain/tron/transaction-codec.js"; import { TronMultisigService } from "./multisig-service.js"; +import { TronSigService } from "./sig-service.js"; const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; @@ -97,7 +98,8 @@ function service(gateway: TronGateway, signer?: Signer) { resolve: vi.fn(() => actualSigner), } as unknown as SignerResolver; const provider = { get: () => gateway } as unknown as ChainGatewayProvider; - return new TronMultisigService(provider, signers, () => NOW); + const signing = new TronSigService(provider, signers, () => NOW); + return new TronMultisigService(provider, signing, () => NOW); } const NETWORK = { id: "tron:nile", family: "tron" } as never; @@ -118,7 +120,7 @@ describe("local TRON multi-signature workflow", () => { it("appends exactly one signature while preserving txID/raw_data and verifies its weight", async () => { const before = decodeTransactionHex(unsignedHex()); - const signed = await service(fakeGateway()).sign(scope(), NETWORK, unsignedHex()); + const signed = await service(fakeGateway()).signChecked(scope(), NETWORK, unsignedHex()); const after = decodeTransactionHex(signed.hex); expect(after.txID).toBe(before.txID); expect(after.raw_data_hex).toBe(before.raw_data_hex); @@ -126,7 +128,8 @@ describe("local TRON multi-signature workflow", () => { expect(signed).toMatchObject({ signer: A, signerWeight: 1, - transaction: { currentWeight: 1, missingWeight: 1 }, + checked: true, + approval: { currentWeight: 1, missingWeight: 1 }, }); }); @@ -138,11 +141,24 @@ describe("local TRON multi-signature workflow", () => { signMessage: async () => "", signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), }; - await expect(service(fakeGateway(), signer).sign(scope(), NETWORK, unsignedHex(NOW))) + await expect(service(fakeGateway(), signer).signChecked(scope(), NETWORK, unsignedHex(NOW))) .rejects.toMatchObject({ code: "tx_expired" }); expect(signer.sign).not.toHaveBeenCalled(); }); + it("does not sign when the authorized account and resolved signer diverge", async () => { + const signer: Signer = { + kind: "software", + address: B, + sign: vi.fn(async (transaction) => transaction), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }; + await expect(service(fakeGateway(), signer).signChecked(scope(), NETWORK, unsignedHex())) + .rejects.toMatchObject({ code: "signing_rejected" }); + expect(signer.sign).not.toHaveBeenCalled(); + }); + it("requires threshold before broadcast and reports the dynamic multi-sign fee", async () => { const gateway = fakeGateway(); await expect(service(gateway).broadcastHex(scope(), NETWORK, unsignedHex(), false)) diff --git a/ts/src/application/use-cases/tron/multisig-service.ts b/ts/src/application/use-cases/tron/multisig-service.ts index b22a11b1d..727f2841a 100644 --- a/ts/src/application/use-cases/tron/multisig-service.ts +++ b/ts/src/application/use-cases/tron/multisig-service.ts @@ -1,6 +1,6 @@ import type { NetworkDescriptor, - Signer, + CheckedTxSignView, TronTransactionArtifact, TxApprovalView, } from "../../../domain/types/index.js"; @@ -9,21 +9,22 @@ import { operationForContractType } from "../../../domain/permission/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 { SignerResolver } from "../../services/signer/index.js"; -import { obtainSignature } from "../../services/signing/obtain-signature.js"; import { stageTronBroadcast } from "../../services/tron-confirmation.js"; +import type { TronSigService } from "./sig-service.js"; import { - assertNotExpired, assertTronSignerAuthorized, authorizationState, +} from "./multisig-authorization.js"; +import { + assertNotExpired, expirationOf, transactionContract, -} from "./multisig-authorization.js"; +} from "./transaction-artifact.js"; export class TronMultisigService { constructor( private readonly gateways: ChainGatewayProvider, - private readonly signers: SignerResolver, + private readonly signing: TronSigService, private readonly now: () => number = () => Date.now(), ) {} @@ -32,42 +33,27 @@ export class TronMultisigService { return this.#approvalFor(gateway, gateway.decodeTransactionHex(hex)); } - async sign(scope: TransactionScope, network: NetworkDescriptor, hex: string) { + async signChecked( + scope: TransactionScope, + network: NetworkDescriptor, + hex: string, + ): Promise { const gateway = this.gateways.get(network, "tron"); const transaction = gateway.decodeTransactionHex(hex); - assertNotExpired(transaction, this.now()); - const originalTxId = transaction.txID; - const originalRawDataHex = transaction.raw_data_hex; - const previousSignatures = [...(transaction.signature ?? [])]; - - this.signers.assertCanSign(scope.activeAccount, "tron"); - const signer = this.signers.resolve(scope.activeAccount, "tron"); - await assertTronSignerAuthorized(gateway, transaction, signer.address, this.now()); + const expectedSigner = scope.resolveAddress("tron"); + await assertTronSignerAuthorized(gateway, transaction, expectedSigner, this.now()); - const signed = await this.#sign(signer, transaction, scope); - const signedHex = gateway.encodeTransactionHex(signed); - const decoded = gateway.decodeTransactionHex(signedHex); - if (decoded.txID !== originalTxId || decoded.raw_data_hex !== originalRawDataHex) { - throw new ChainError("invalid_transaction", "signer changed the transaction raw_data or txID"); - } - const current = decoded.signature ?? []; - if (current.length !== previousSignatures.length + 1 - || previousSignatures.some((signature, index) => current[index] !== signature)) { - throw new ChainError("signing_rejected", "signer did not append exactly one signature while preserving prior approvals"); - } - - const transactionView = await this.#approvalFor(gateway, decoded); - const approvedSigner = transactionView.approved.find((approved) => approved.address === signer.address); + const signed = await this.signing.sign(scope, network, hex, { expectedSigner }); + const approval = await this.#approvalFor(gateway, gateway.decodeTransactionHex(signed.hex)); + const approvedSigner = approval.approved.find((approved) => approved.address === signed.signer); if (!approvedSigner) { throw new ChainError("signing_rejected", "node did not recognize the newly appended signature"); } - assertNotExpired(decoded, this.now()); return { - kind: "tx-sign" as const, - signer: signer.address, + ...signed, + checked: true, signerWeight: approvedSigner.weight, - hex: signedHex, - transaction: transactionView, + approval, }; } @@ -138,8 +124,4 @@ export class TronMultisigService { signatures: transaction.signature?.length ?? 0, }; } - - #sign(signer: Signer, transaction: TronTransactionArtifact, scope: TransactionScope) { - return obtainSignature(signer, scope, (options) => signer.sign(transaction, options)); - } } diff --git a/ts/src/application/use-cases/tron/sig-service.test.ts b/ts/src/application/use-cases/tron/sig-service.test.ts new file mode 100644 index 000000000..379ea6212 --- /dev/null +++ b/ts/src/application/use-cases/tron/sig-service.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OfflineTxSignView, Signer } from "../../../domain/types/index.js"; +import { + decodeTransactionHex, + encodeTransactionHex, +} from "../../../adapters/outbound/chain/tron/transaction-codec.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 { SignerResolver } from "../../services/signer/index.js"; +import { TronSigService } from "./sig-service.js"; + +const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const OWNER_HEX = "417445076632894b7b844887d2bcd2e8c30bb6c6f2"; +const TO_HEX = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"; +const SIGNATURE = "ab".repeat(65); +const NOW = 1_900_000_000_000; +const NETWORK = { id: "tron:nile", family: "tron" } as never; + +function unsignedHex(expiration = NOW + 60_000): string { + return encodeTransactionHex({ + visible: false, + raw_data: { + contract: [{ + parameter: { + value: { owner_address: OWNER_HEX, to_address: TO_HEX, amount: 1 }, + type_url: "type.googleapis.com/protocol.TransferContract", + }, + type: "TransferContract", + Permission_id: 2, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: NOW, + expiration, + }, + }); +} + +function scope(): TransactionScope { + return { + activeAccount: "local" as never, + timeoutMs: 100, + wait: false, + waitTimeoutMs: 100, + resolveAddress: () => A, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function setup(signerOverride?: Signer) { + const gateway = { + decodeTransactionHex, + encodeTransactionHex, + decodeTransaction: () => ({ kind: "trx", from: A, to: B, rawAmount: "1" }), + } as unknown as TronGateway; + const signer = signerOverride ?? { + kind: "software", + address: A, + sign: vi.fn(async (transaction) => ({ + ...transaction, + signature: [...(transaction.signature ?? []), SIGNATURE], + })), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + } as Signer; + const signers = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => signer), + } as unknown as SignerResolver; + const gateways = { get: vi.fn(() => gateway) } as unknown as ChainGatewayProvider; + return { service: new TronSigService(gateways, signers, () => NOW), signer }; +} + +describe("TRON artifact signing", () => { + it("appends a signature without requiring online approval endpoints", async () => { + const before = decodeTransactionHex(unsignedHex()); + const result: OfflineTxSignView = await setup().service.sign(scope(), NETWORK, unsignedHex()); + const after = decodeTransactionHex(result.hex); + + expect(after.txID).toBe(before.txID); + expect(after.raw_data_hex).toBe(before.raw_data_hex); + expect(after.signature).toEqual([SIGNATURE]); + expect(result).toMatchObject({ + kind: "tx-sign", + checked: false, + signer: A, + transaction: { + permissionId: 2, + signatures: 1, + contractType: "TransferContract", + }, + }); + }); + + it("rejects an expired artifact before invoking the signer", async () => { + const { service, signer } = setup(); + await expect(service.sign(scope(), NETWORK, unsignedHex(NOW))) + .rejects.toMatchObject({ code: "tx_expired" }); + expect(signer.sign).not.toHaveBeenCalled(); + }); + + it("rejects a signer that changes raw_data instead of only appending a signature", async () => { + const signer = { + kind: "software", + address: A, + sign: vi.fn(async (transaction) => ({ + ...transaction, + txID: "ff".repeat(32), + signature: [SIGNATURE], + })), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + } as Signer; + await expect(setup(signer).service.sign(scope(), NETWORK, unsignedHex())) + .rejects.toMatchObject({ code: "invalid_transaction" }); + }); + + it("rejects an unexpected signer before asking it to sign", async () => { + const signer = { + kind: "software", + address: B, + sign: vi.fn(async (transaction) => transaction), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + } as Signer; + await expect(setup(signer).service.sign( + scope(), + NETWORK, + unsignedHex(), + { expectedSigner: A }, + )).rejects.toMatchObject({ code: "signing_rejected" }); + expect(signer.sign).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/tron/sig-service.ts b/ts/src/application/use-cases/tron/sig-service.ts new file mode 100644 index 000000000..e26899f94 --- /dev/null +++ b/ts/src/application/use-cases/tron/sig-service.ts @@ -0,0 +1,106 @@ +import type { + NetworkDescriptor, + Signer, + TronTransactionArtifact, + OfflineTxSignView, + TxSignTransactionView, +} from "../../../domain/types/index.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { operationForContractType } from "../../../domain/permission/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 { SignerResolver } from "../../services/signer/index.js"; +import { obtainSignature } from "../../services/signing/obtain-signature.js"; +import { + assertNotExpired, + expirationOf, + transactionContract, +} from "./transaction-artifact.js"; + +export interface TronSignOptions { + /** Bind an authorization decision to the exact signer before any signature interaction. */ + expectedSigner?: string; +} + +/** Offline-capable TRON artifact signing. It never queries permission or approval endpoints. */ +export class TronSigService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly signers: SignerResolver, + private readonly now: () => number = () => Date.now(), + ) {} + + async sign( + scope: TransactionScope, + network: NetworkDescriptor, + hex: string, + options: TronSignOptions = {}, + ): Promise { + const gateway = this.gateways.get(network, "tron"); + const transaction = gateway.decodeTransactionHex(hex); + assertNotExpired(transaction, this.now()); + const originalTxId = transaction.txID; + const originalRawDataHex = transaction.raw_data_hex; + const previousSignatures = [...(transaction.signature ?? [])]; + + this.signers.assertCanSign(scope.activeAccount, "tron"); + const signer = this.signers.resolve(scope.activeAccount, "tron"); + if (options.expectedSigner !== undefined && signer.address !== options.expectedSigner) { + throw new ChainError( + "signing_rejected", + "resolved signer does not match the signer that passed authorization", + ); + } + const signed = await this.#sign(signer, transaction, scope); + const signedHex = gateway.encodeTransactionHex(signed); + const decoded = gateway.decodeTransactionHex(signedHex); + if (decoded.txID !== originalTxId || decoded.raw_data_hex !== originalRawDataHex) { + throw new ChainError("invalid_transaction", "signer changed the transaction raw_data or txID"); + } + const current = decoded.signature ?? []; + if ( + current.length !== previousSignatures.length + 1 + || previousSignatures.some((signature, index) => current[index] !== signature) + ) { + throw new ChainError( + "signing_rejected", + "signer did not append exactly one signature while preserving prior approvals", + ); + } + assertNotExpired(decoded, this.now()); + return { + kind: "tx-sign", + signer: signer.address, + hex: signedHex, + checked: false, + transaction: this.#transactionView(gateway, decoded), + }; + } + + #transactionView( + gateway: TronGateway, + transaction: TronTransactionArtifact, + ): TxSignTransactionView { + const contract = transactionContract(transaction); + const decoded = gateway.decodeTransaction(transaction); + const expiration = expirationOf(transaction); + return { + txId: transaction.txID, + contractType: contract.type, + operation: operationForContractType(contract.type)?.label, + from: decoded.from, + to: decoded.to, + rawAmount: decoded.rawAmount, + tokenContract: decoded.tokenContract, + permissionId: contract.Permission_id ?? 0, + expiration, + expired: expiration <= this.now(), + signatures: transaction.signature?.length ?? 0, + }; + } + + #sign(signer: Signer, transaction: TronTransactionArtifact, scope: TransactionScope) { + return obtainSignature(signer, scope, (options) => signer.sign(transaction, options)); + } +} diff --git a/ts/src/application/use-cases/tron/transaction-artifact.ts b/ts/src/application/use-cases/tron/transaction-artifact.ts new file mode 100644 index 000000000..86ee91f69 --- /dev/null +++ b/ts/src/application/use-cases/tron/transaction-artifact.ts @@ -0,0 +1,28 @@ +import { ChainError } from "../../../domain/errors/index.js"; +import type { TronTransactionArtifact } from "../../../domain/types/index.js"; + +export function transactionContract(transaction: TronTransactionArtifact) { + const contracts = transaction.raw_data?.contract; + if (!Array.isArray(contracts) || contracts.length !== 1) { + throw new ChainError("invalid_transaction", "transaction must contain exactly one contract"); + } + return contracts[0]!; +} + +export function expirationOf(transaction: TronTransactionArtifact): number { + const expiration = transaction.raw_data.expiration; + if (!Number.isSafeInteger(expiration) || expiration! <= 0) { + throw new ChainError("invalid_transaction", "transaction expiration is missing or imprecise"); + } + return expiration!; +} + +export function assertNotExpired( + transaction: TronTransactionArtifact, + now = Date.now(), +): void { + const expiration = expirationOf(transaction); + if (expiration <= now) { + throw new ChainError("tx_expired", `transaction expired at ${new Date(expiration).toISOString()}`); + } +} diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 10864e742..ebdc16a0d 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -92,8 +92,9 @@ import { TronBlockService } from "../../application/use-cases/tron/block-service import { MessageService } from "../../application/use-cases/message-service.js"; import { TypedDataService } from "../../application/use-cases/typed-data-service.js"; import { TronPermissionService } from "../../application/use-cases/tron/permission-service.js"; +import { TronSigService } from "../../application/use-cases/tron/sig-service.js"; import { TronMultisigService } from "../../application/use-cases/tron/multisig-service.js"; -import { TronLinkMultisigService } from "../../application/use-cases/tron/tronlink-multisig-service.js"; +import { TronMultisigCollaborationService } from "../../application/use-cases/tron/multisig-collaboration-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"; @@ -151,8 +152,13 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC deps.transactions, deps.recipients, ); - const multisig = new TronMultisigService(deps.gateways, deps.signers); - const tronlink = new TronLinkMultisigService(deps.tronlink, deps.gateways, multisig); + const signing = new TronSigService(deps.gateways, deps.signers); + const multisig = new TronMultisigService(deps.gateways, signing); + const multisigCollaboration = new TronMultisigCollaborationService( + deps.tronlink, + deps.gateways, + multisig, + ); const gasfree = new GasFreeService( deps.gasfree, deps.gateways, @@ -183,11 +189,16 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC reg.addChain(txSendSpec, "tron", txSendTronBinding(transaction)); reg.addChain(txSignSpec, "tron", txSignTronBinding( transaction, + signing, multisig, new TransactionArtifactWriter(), )); reg.addChain(txApprovalsSpec, "tron", txApprovalsTronBinding(multisig)); - reg.addChain(txTronLinkMultisigSpec, "tron", txTronLinkMultisigBinding(tronlink)); + reg.addChain( + txTronLinkMultisigSpec, + "tron", + txTronLinkMultisigBinding(multisigCollaboration), + ); reg.addChain(gasFreeInfoSpec, "tron", gasFreeInfoTronBinding(gasfree)); reg.addChain(gasFreeTransferSpec, "tron", gasFreeTransferTronBinding(gasfree)); reg.addChain(gasFreeTraceSpec, "tron", gasFreeTraceTronBinding(gasfree)); diff --git a/ts/src/domain/permission/index.ts b/ts/src/domain/permission/index.ts index 4d2c908f2..ce32d425d 100644 --- a/ts/src/domain/permission/index.ts +++ b/ts/src/domain/permission/index.ts @@ -213,11 +213,21 @@ function activeGroup(value: unknown, index: number): ActivePermissionView { if (!Array.isArray(input.operations)) { return invalidPermission(`actives[${index}].operations must be an array`); } - const operationsHex = encodeOperations(input.operations as string[]); + const encodedKnownOperations = encodeOperations(input.operations as string[]); + let operationsHex = encodedKnownOperations; if (input.operationsHex !== undefined) { - if (typeof input.operationsHex !== "string" || decodeOperations(input.operationsHex).operationsHex !== operationsHex) { + if (typeof input.operationsHex !== "string") { return invalidPermission(`actives[${index}].operationsHex does not match operations`); } + const supplied = decodeOperations(input.operationsHex); + const known = decodeOperations(encodedKnownOperations); + if ( + supplied.operations.length !== known.operations.length + || supplied.operations.some((operation, operationIndex) => operation !== known.operations[operationIndex]) + ) { + return invalidPermission(`actives[${index}].operationsHex does not match operations`); + } + operationsHex = supplied.operationsHex; } const decoded = decodeOperations(operationsHex); const parsedKeys = keys(input.keys, `actives[${index}].keys`); @@ -234,7 +244,7 @@ function activeGroup(value: unknown, index: number): ActivePermissionView { operations: decoded.operations, operationLabels: decoded.labels, operationsHex, - unknownOperationIds: [], + unknownOperationIds: decoded.unknownOperationIds, }; } diff --git a/ts/src/domain/permission/permission.test.ts b/ts/src/domain/permission/permission.test.ts index f71e7f711..126eadb57 100644 --- a/ts/src/domain/permission/permission.test.ts +++ b/ts/src/domain/permission/permission.test.ts @@ -64,6 +64,17 @@ describe("permission replacement validation", () => { expect(parsed.actives[0]?.operationLabels).toContain("Transfer TRX"); }); + it("round-trips unknown operation bits when the known operation list matches", () => { + const input = structure(); + input.actives[0]!.operationsHex = "0e000080" + "00".repeat(28); + const parsed = validatePermissionStructure(input); + expect(parsed.actives[0]).toMatchObject({ + operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], + operationsHex: input.actives[0]!.operationsHex, + unknownOperationIds: [3], + }); + }); + it("rejects unsafe thresholds, duplicate ids/addresses, unknown operations, and control names", () => { const high = structure(); high.owner.threshold = 3; diff --git a/ts/src/domain/types/multisig.ts b/ts/src/domain/types/multisig.ts index 4192f4261..3af7efc7e 100644 --- a/ts/src/domain/types/multisig.ts +++ b/ts/src/domain/types/multisig.ts @@ -25,15 +25,40 @@ export interface TxApprovalView { signatures: number; } -export interface TxSignView { +export interface TxSignTransactionView { + txId: string; + contractType: string; + operation?: string; + from?: string; + to?: string; + rawAmount?: string; + tokenContract?: string; + permissionId: number; + expiration: number; + expired: boolean; + signatures: number; +} + +interface TxSignViewBase { kind: "tx-sign"; signer: string; - signerWeight: number; hex: string; out?: string; - transaction: TxApprovalView; + transaction: TxSignTransactionView; +} + +export interface OfflineTxSignView extends TxSignViewBase { + checked: false; } +export interface CheckedTxSignView extends TxSignViewBase { + checked: true; + signerWeight: number; + approval: TxApprovalView; +} + +export type TxSignView = OfflineTxSignView | CheckedTxSignView; + export type TronLinkMultisigState = "pending" | "signed" | "success" | "failed"; export interface TronLinkSignatureProgressView { diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index f2eacd449..82bf64154 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -126,7 +126,7 @@ describe("golden CLI — meta & introspection", () => { expect(r.json.aliases).toBeUndefined() 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.requires).toMatchObject({ network: "optional", auth: "conditional", wallet: "optional" }) expect(cmd.inputSchema.properties.to).toBeDefined() const importMnemonic = r.json.commands.find((c: { id: string }) => c.id === "import.mnemonic") // TTY-only setup op: the mnemonic is entered interactively, so there is no --*-stdin input flag. From 9064d5c201a48e0d19cf8cd16b10ae829e0a7445 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 29 Jul 2026 02:26:28 +0800 Subject: [PATCH 13/24] docs(ts): document v0.3.0 commands and prepare 4.11.0 Backfill reference pages for every command added since release_v4.11.0, verified against the command registry rather than the drafted command doc: all 66 registered commands now have a page. New pages: permission/ (index, show, update) multi-sig permission structure gasfree/ (index, info, transfer, trace) contact/ (index, add, list, remove) address/ (index, generate) encoding/ (index, convert) tx/approvals, tx/multisig account/activate, account/set Updated: commands/index, account/index, tx/index (multi-sig lifecycle), tx/broadcast (--hex/--file/--dry-run, threshold validation), tx/send (contact names), current (--qr), config (TronLink/GasFree credentials and secret masking), README, and the agent SKILL command map. Examples and error codes were taken from actual command output and the domain rules, so contact limits, the permission operation bitmaps, and the config masking behaviour match the implementation. Also included in this commit: - bump version to 4.11.0 (ts package, runner, java Utils) - name TRON contract types 3/20/32/51 and require unnamed operation bits to be declared via unknownOperationIds - add USDD to mainnet and seed the Nile builtin token book --- .../java/org/tron/common/utils/Utils.java | 2 +- ts/README.md | 32 ++- ts/docs/commands/account/activate.md | 103 +++++++++ ts/docs/commands/account/index.md | 12 ++ ts/docs/commands/account/set.md | 94 +++++++++ ts/docs/commands/address/generate.md | 96 +++++++++ ts/docs/commands/address/index.md | 30 +++ ts/docs/commands/config.md | 52 ++++- ts/docs/commands/contact/add.md | 83 ++++++++ ts/docs/commands/contact/index.md | 55 +++++ ts/docs/commands/contact/list.md | 71 +++++++ ts/docs/commands/contact/remove.md | 66 ++++++ ts/docs/commands/current.md | 55 ++++- ts/docs/commands/encoding/convert.md | 155 ++++++++++++++ ts/docs/commands/encoding/index.md | 30 +++ ts/docs/commands/gasfree/index.md | 70 +++++++ ts/docs/commands/gasfree/info.md | 77 +++++++ ts/docs/commands/gasfree/trace.md | 94 +++++++++ ts/docs/commands/gasfree/transfer.md | 154 ++++++++++++++ ts/docs/commands/index.md | 42 +++- ts/docs/commands/permission/index.md | 65 ++++++ ts/docs/commands/permission/show.md | 103 +++++++++ ts/docs/commands/permission/update.md | 195 ++++++++++++++++++ ts/docs/commands/tx/approvals.md | 115 +++++++++++ ts/docs/commands/tx/broadcast.md | 60 +++++- ts/docs/commands/tx/index.md | 28 ++- ts/docs/commands/tx/multisig.md | 178 ++++++++++++++++ ts/docs/commands/tx/send.md | 10 +- ts/package-lock.json | 4 +- ts/package.json | 2 +- ts/skills/wallet-cli/SKILL.md | 28 ++- .../chain/tron/tron.token-info.test.ts | 74 +++++++ ts/src/adapters/outbound/chain/tron/tron.ts | 38 +++- .../adapters/outbound/tokenbook/builtins.ts | 6 +- .../outbound/tokenbook/tokenbook.test.ts | 21 +- .../use-cases/tron/permission-service.test.ts | 21 ++ ts/src/bootstrap/runner.ts | 2 +- ts/src/domain/permission/index.ts | 52 ++++- ts/src/domain/permission/permission.test.ts | 106 +++++++++- ts/test/golden.test.ts | 9 +- 40 files changed, 2435 insertions(+), 55 deletions(-) create mode 100644 ts/docs/commands/account/activate.md create mode 100644 ts/docs/commands/account/set.md create mode 100644 ts/docs/commands/address/generate.md create mode 100644 ts/docs/commands/address/index.md create mode 100644 ts/docs/commands/contact/add.md create mode 100644 ts/docs/commands/contact/index.md create mode 100644 ts/docs/commands/contact/list.md create mode 100644 ts/docs/commands/contact/remove.md create mode 100644 ts/docs/commands/encoding/convert.md create mode 100644 ts/docs/commands/encoding/index.md create mode 100644 ts/docs/commands/gasfree/index.md create mode 100644 ts/docs/commands/gasfree/info.md create mode 100644 ts/docs/commands/gasfree/trace.md create mode 100644 ts/docs/commands/gasfree/transfer.md create mode 100644 ts/docs/commands/permission/index.md create mode 100644 ts/docs/commands/permission/show.md create mode 100644 ts/docs/commands/permission/update.md create mode 100644 ts/docs/commands/tx/approvals.md create mode 100644 ts/docs/commands/tx/multisig.md create mode 100644 ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts diff --git a/java/src/main/java/org/tron/common/utils/Utils.java b/java/src/main/java/org/tron/common/utils/Utils.java index a8f4374f5..812959868 100644 --- a/java/src/main/java/org/tron/common/utils/Utils.java +++ b/java/src/main/java/org/tron/common/utils/Utils.java @@ -138,7 +138,7 @@ public class Utils { public static final int MIN_LENGTH = 2; public static final int MAX_LENGTH = 14; - public static final String VERSION = " v4.10.1"; + public static final String VERSION = " v4.11.0"; public static final String TRANSFER_METHOD_ID = "a9059cbb"; private static SecureRandom random = new SecureRandom(); diff --git a/ts/README.md b/ts/README.md index b3300a480..067b72182 100644 --- a/ts/README.md +++ b/ts/README.md @@ -8,6 +8,8 @@ The agent-first implementation of wallet-cli, built for automation: every comman - **Encrypted local storage** — software keystores are encrypted on disk; secrets are never passed via argv or environment variables. - **Software and Ledger signing** — sign in software, or on a Ledger device (the private key never leaves the device). - **Covers the main TRON capabilities** — HD wallets, TRX and TRC20/TRC10 transfers, staking / resource delegation, voting / rewards, smart-contract calls and deployment, message and EIP-712/TIP-712 signing, and on-chain queries. +- **Multi-signature end to end** — inspect and replace [account permissions](docs/commands/permission/index.md), then collect signatures either offline by passing a transaction artifact between signers or through the TronLink service, with approval weight and threshold visible at every step. +- **Gas-free transfers** — move USDT and other supported tokens with [no TRX at all](docs/commands/gasfree/index.md), paying the fee in the token itself via the GasFree Open Platform. ## Supported chains @@ -106,17 +108,37 @@ Every command — including every subcommand — has a reference page; run `wall | [`derive`](docs/commands/derive.md) | Derive the next HD account from a seed wallet | | [`rename`](docs/commands/rename.md) / [`backup`](docs/commands/backup.md) / [`delete`](docs/commands/delete.md) | Manage accounts (backup writes secret + metadata, mode 0600) | | [`change-password`](docs/commands/change-password.md) | Change the master password (re-encrypt all software keystores) | +| [`address generate`](docs/commands/address/generate.md) | Generate a keypair locally, without adding it to the wallet | ### Transactions | Command | Description | |---|---| | [`tx send`](docs/commands/tx/send.md) | Send native TRX or TRC20/TRC10 tokens | -| [`tx sign`](docs/commands/tx/sign.md) | Sign a transaction built elsewhere, without broadcasting | -| [`tx broadcast`](docs/commands/tx/broadcast.md) | Broadcast a presigned transaction | +| [`tx sign`](docs/commands/tx/sign.md) | Sign transaction JSON, or append a signature to transaction hex, offline | +| [`tx broadcast`](docs/commands/tx/broadcast.md) | Validate and broadcast a presigned JSON or protobuf-hex transaction | +| [`tx approvals`](docs/commands/tx/approvals.md) | Show permission, approvals, accumulated weight, and expiration | +| [`tx multisig`](docs/commands/tx/multisig.md) | Coordinate signature collection through the TronLink service | | [`tx status`](docs/commands/tx/status.md) | Show confirmation status (confirmed / failed / pending / not_found) | | [`tx info`](docs/commands/tx/info.md) | Show full transaction detail + receipt | +### Multi-signature permissions + +| Command | Description | +|---|---| +| [`permission show`](docs/commands/permission/show.md) | Show owner, witness, and active permission groups with decoded operations | +| [`permission update`](docs/commands/permission/update.md) | Replace the complete permission structure — **can permanently lock the account** | + +### Gas-free transfers + +Send tokens with no TRX: sign a TIP-712 authorization and let the GasFree provider broadcast, taking its fee in the transferred token. Needs credentials via [`config`](docs/commands/config.md); unavailable on `tron:shasta`. + +| Command | Description | +|---|---| +| [`gasfree info`](docs/commands/gasfree/info.md) | GasFree address, activation status, nonce, balances, and fees | +| [`gasfree transfer`](docs/commands/gasfree/transfer.md) | Sign and submit a TIP-712 GasFree token transfer | +| [`gasfree trace`](docs/commands/gasfree/trace.md) | Track a submitted transfer to its terminal state | + ### On-chain queries | Command | Description | @@ -125,6 +147,8 @@ Every command — including every subcommand — has a reference page; run `wall | [`account info`](docs/commands/account/info.md) | Show raw account data incl. resources | | [`account history`](docs/commands/account/history.md) | Show transaction history (requires TronGrid) | | [`account portfolio`](docs/commands/account/portfolio.md) | Native + token balances with best-effort USD value | +| [`account activate`](docs/commands/account/activate.md) | Activate a new TRON account, paid for by the active account | +| [`account set`](docs/commands/account/set.md) | Set the one-time on-chain account name or ID | | [`block`](docs/commands/block.md) | Get a block (latest if omitted) | | [`chain params`](docs/commands/chain/params.md) | On-chain governance parameters | | [`chain prices`](docs/commands/chain/prices.md) | Energy/bandwidth unit price and memo fee | @@ -146,8 +170,10 @@ Every command — including every subcommand — has a reference page; run `wall | Command | Description | |---|---| -| [`config`](docs/commands/config.md) | Show / get / set configuration values | +| [`config`](docs/commands/config.md) | Show / get / set configuration values, including TronLink and GasFree credentials (secrets masked) | | [`networks`](docs/commands/networks.md) | List known networks (`tron:mainnet`, `tron:nile`, `tron:shasta`) | +| [`contact`](docs/commands/contact/index.md) | Local recipient address book, usable as `--to ` ([add](docs/commands/contact/add.md) · [list](docs/commands/contact/list.md) · [remove](docs/commands/contact/remove.md)) | +| [`encoding convert`](docs/commands/encoding/convert.md) | Convert and validate address, hex, Base64, and Base58Check encodings | ## Documentation map diff --git a/ts/docs/commands/account/activate.md b/ts/docs/commands/account/activate.md new file mode 100644 index 000000000..75cc0e945 --- /dev/null +++ b/ts/docs/commands/account/activate.md @@ -0,0 +1,103 @@ +# wallet-cli account activate + +Activate a new TRON account. ✍️ + +## Synopsis + +``` +wallet-cli account activate --address
+ [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +A TRON address exists as soon as you generate a key, but it is not *activated* until it appears on +chain. An unactivated address cannot receive TRC10, hold resources, or be the owner of a +transaction. Activation is a funded operation: this command builds an `AccountCreateContract` paid +for by the **active account** (or `--account`) and broadcasts it. + +Preconditions checked before anything is signed: + +- `--address` must be a valid TRON base58 address (`invalid_value`, exit 2). +- The target must not already be active — otherwise `account_already_active` (exit 1). +- The payer's balance must cover the creation fee — otherwise `insufficient_balance` (exit 1), + with `balance` and `required` in the error details. + +Because the fee comes from the chain's current parameters rather than a fixed constant, run +`--dry-run` first to see what it costs right now. + +With `--wait`, the command additionally re-reads the target account after confirmation and fails +with `provider_error` if the node does not report it as active — a confirmed activation that is +invisible to the node is treated as a failure, not a success. + +## Options + +| Option | Description | +|---|---| +| `--address ` | **Required.** Unactivated TRON base58 address to activate | +| `--dry-run` | Build and estimate only — no signature, no broadcast | +| `--sign-only` | Sign and output the complete transaction hex without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex without unlocking | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | +| `--password-stdin` | Master password from stdin (software accounts) | + +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Check the current cost before spending anything: + +```bash +wallet-cli account activate --address TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 \ + --network tron:nile --dry-run +``` + +Activate and wait for confirmation: + +```bash +echo "$PW" | wallet-cli account activate --address TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 \ + --network tron:nile --wait --password-stdin +``` + +```console +✅ Account activated + TxID 4c0a1f4b1e0e0d8a5f0a2c6f9e1d3b7a8c5e2f4d6b8a0c2e4f6a8c0e2f4a6b8c + Block #66,012,345 + Fee 1.1 TRX + Address TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 + Payer TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ + Status success +``` + +An address that is already on chain is refused rather than paid for twice: + +```json +{"schema":"wallet-cli.result.v1","success":false,"command":"account.activate","error":{"code":"account_already_active","message":"TRON account is already active: TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2"}} +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `"account-activate"` | +| `stage` | string | `"submitted"` / `"confirmed"` / `"failed"` (absent for `--dry-run`) | +| `mode` | string | `"dry-run"` / `"sign-only"` / `"build-only"` when a mode flag was used | +| `txId` | string | Transaction id | +| `address` | string | The address that was activated | +| `payer` | string | Address that paid the creation fee | +| `blockNumber`, `feeSun` | number \| string | Present after `--wait` | + +## Exit status + +`0` · `1` execution failure (`account_already_active`, `insufficient_balance`, `provider_error`, +`auth_failed`) · `2` usage error (`invalid_value`, conflicting mode flags). + +## See also + +[`address generate`](../address/generate.md) — make a keypair to activate · +[`account info`](info.md) · [`tx send`](../tx/send.md) — a transfer to a fresh address also +activates it diff --git a/ts/docs/commands/account/index.md b/ts/docs/commands/account/index.md index fc12c1e69..f306b408b 100644 --- a/ts/docs/commands/account/index.md +++ b/ts/docs/commands/account/index.md @@ -12,6 +12,8 @@ All subcommands read the chain for the **active account** by default; override w ## Subcommands +Read-only: + | Command | Description | Data source | |---|---|---| | [`account balance`](balance.md) | Native balance (TRX/SUN) | node RPC | @@ -19,6 +21,16 @@ All subcommands read the chain for the **active account** by default; override w | [`account history`](history.md) | Transaction history | **TronGrid required** | | [`account portfolio`](portfolio.md) | Native + token balances, best-effort USD | node RPC + price source | +Broadcasting (✍️) — these change on-chain state and cost fees: + +| Command | Description | +|---|---| +| [`account activate`](activate.md) | Activate a new TRON account, paid for by the active account | +| [`account set`](set.md) | Set the one-time on-chain account name or ID | + +Both write-side commands are effectively one-shot: an account can only be activated once, and the +on-chain name and ID can each be set once. Run them with `--dry-run` first. + ## See also [`list`](../list.md) — local accounts (no chain access) · [Networks & resources](../../concepts/networks.md) diff --git a/ts/docs/commands/account/set.md b/ts/docs/commands/account/set.md new file mode 100644 index 000000000..38110238f --- /dev/null +++ b/ts/docs/commands/account/set.md @@ -0,0 +1,94 @@ +# wallet-cli account set + +Set the one-time on-chain account name or ID. ✍️ + +## Synopsis + +``` +wallet-cli account set (--name | --id ) + [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +TRON accounts carry two optional on-chain text fields: + +| Field | Contract | Constraint | +|---|---|---| +| `--name` | `AccountUpdateContract` | 1–32 UTF-8 bytes | +| `--id` | `SetAccountIdContract` | 8–32 UTF-8 bytes, **unique across the chain** | + +Both are effectively **immutable** — the chain accepts them once, and a second attempt is rejected. +Treat this as a one-way decision and confirm the value with `--dry-run` before broadcasting. + +Exactly one of `--name` / `--id` per invocation (`invalid_option`, exit 2). The target account must +already be activated, otherwise the command fails before signing. + +These fields are on-chain metadata and are unrelated to the local wallet label set by +[`rename`](../rename.md), which never touches the chain. + +After a confirmed broadcast the command re-reads the account and fails with `provider_error` if the +stored value does not match what was submitted. + +## Options + +| Option | Description | +|---|---| +| `--name ` | One-time on-chain account name (1–32 UTF-8 bytes) | +| `--id ` | One-time unique account ID (8–32 UTF-8 bytes) | +| `--dry-run` | Build and estimate only — no signature, no broadcast | +| `--sign-only` | Sign and output the complete transaction hex without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex without unlocking | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | +| `--password-stdin` | Master password from stdin (software accounts) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli account set --name alice --network tron:nile --dry-run +``` + +```bash +echo "$PW" | wallet-cli account set --id alice-001 --network tron:nile \ + --wait --password-stdin +``` + +```console +✅ On-chain id set + TxID 9b2f...c1d4 + Block #66,012,401 + Address TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ + ID alice-001 + Status success +``` + +Passing both selectors is a usage error: + +```console +error [invalid_option]: provide exactly one of --name or --id +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `"account-set"` | +| `field` | string | `"name"` or `"id"` | +| `value` | string | The value written on chain | +| `address` | string | Account that was updated | +| `stage`, `txId`, `blockNumber`, `feeSun` | — | Standard broadcast receipt fields | + +## Exit status + +`0` · `1` execution failure (account not activated, chain rejected an already-set field, +`provider_error`, `auth_failed`) · `2` usage error (neither/both of `--name` / `--id`, conflicting +mode flags). + +## See also + +[`rename`](../rename.md) — local label only · [`account info`](info.md) · +[`account activate`](activate.md) diff --git a/ts/docs/commands/address/generate.md b/ts/docs/commands/address/generate.md new file mode 100644 index 000000000..f98665818 --- /dev/null +++ b/ts/docs/commands/address/generate.md @@ -0,0 +1,96 @@ +# wallet-cli address generate + +Generate a random TRON/EVM keypair locally, without adding it to the wallet. + +## Synopsis + +``` +wallet-cli address generate [--out ] [--print-secret] [options] +``` + +## Description + +Generates a secp256k1 keypair offline and derives both address encodings from the same public key: +the TRON base58 address and the EVM `0x` address. No network access, no wallet unlock, no keystore +write. + +**The private key is not printed by default.** It is written to an exclusively-created `0600` file: + +- `--out ` — write there. An existing file is **never** overwritten; the create is exclusive, + so a collision fails rather than clobbering a key you already have. +- omitted — write to `/generated/keypair-`. + +`--print-secret` prints the key to stdout instead of writing a file. That puts the secret in your +terminal scrollback and, in `-o json`, in whatever consumes the envelope — use it only on an +offline machine. + +The generated key is **not** in the wallet: `list`, `use`, and every signing command remain unaware +of it. Import it with [`import private-key`](../import/private-key.md) if you want to sign with it. + +## Options + +| Option | Description | +|---|---| +| `--out ` | Exclusive `0600` output path; existing files are never overwritten | +| `--print-secret` | Print the private key instead of writing it — use only offline | + +Plus the [global options](../index.md#global-options-every-command). `--network` and `--account` +do not apply: the command is entirely local. + +## Examples + +```bash +wallet-cli address generate +``` + +```console +✅ Keypair generated (NOT stored in the wallet) + TRON address TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 + EVM address 0x0C5f589E3C99Cc365ffb8af588241921f764dF66 + Private key written to /Users/you/.wallet-cli/generated/keypair-TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 + +! To sign with this key, import it: wallet-cli import private-key +``` + +Write to a specific location: + +```bash +wallet-cli address generate --out /secure/usb/key.json +``` + +The secret file is JSON: + +```json +{"version":1,"privateKey":"…","publicKey":"…","tron":"TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2","evm":"0x0C5f589E3C99Cc365ffb8af588241921f764dF66"} +``` + +JSON output — note that `privateKey` is absent unless `--print-secret` was given: + +```bash +wallet-cli address generate -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"address.generate","data":{"tron":"TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2","evm":"0x0C5f589E3C99Cc365ffb8af588241921f764dF66","secretFile":"/Users/you/.wallet-cli/generated/keypair-TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2"},"meta":{"durationMs":21,"warnings":[]}} +``` + +## Output + +`data` is a local result — no `chain` block. + +| Field | Type | Meaning | +|---|---|---| +| `tron` | string | TRON base58 address | +| `evm` | string | EIP-55 checksummed EVM address from the same key | +| `secretFile` | string | Path the private key was written to (omitted with `--print-secret`) | +| `privateKey` | string | Raw private key hex — **only** with `--print-secret` | + +## Exit status + +`0` · `1` execution failure — `entropy_failure`, `file_exists` (refusing to overwrite an existing +keypair file), `io_error` · `2` usage error. + +## See also + +[`import private-key`](../import/private-key.md) · [`create`](../create.md) · +[`encoding convert`](../encoding/convert.md) · [Security model](../../concepts/security.md) diff --git a/ts/docs/commands/address/index.md b/ts/docs/commands/address/index.md new file mode 100644 index 000000000..906d589c9 --- /dev/null +++ b/ts/docs/commands/address/index.md @@ -0,0 +1,30 @@ +# wallet-cli address + +Local keypair utilities that never touch the wallet or the network. + +## Synopsis + +``` +wallet-cli address COMMAND +``` + +## Subcommands + +| Command | Description | Network | +|---|---|---| +| [`address generate`](generate.md) | Generate a random TRON/EVM keypair locally | none | + +## Why it is separate from `create` + +[`create`](../create.md) and [`import`](../import/index.md) produce accounts the wallet **owns** — +encrypted on disk, unlockable, signable. `address generate` produces a bare keypair that the wallet +does not know about: nothing is added to the keystore, and the CLI cannot sign with it afterwards. + +Use it when you need a key for something else (a test fixture, a cold address, a key you will hand +to another system). To sign with it here, import it afterwards with +[`import private-key`](../import/private-key.md). + +## See also + +[`create`](../create.md) · [`import private-key`](../import/private-key.md) · +[`encoding convert`](../encoding/convert.md) · [Security model](../../concepts/security.md) diff --git a/ts/docs/commands/config.md b/ts/docs/commands/config.md index 48c3469d3..0a576927e 100644 --- a/ts/docs/commands/config.md +++ b/ts/docs/commands/config.md @@ -29,9 +29,31 @@ Known keys: | `waitTimeoutMs` | integer ms ≥ 0 | `60000` | Default `--wait` polling cap for broadcast commands | | `networks` | — | — | Known networks (read-only list) | +Service credentials — all optional, all writable, and only needed by the commands that use them: + +| Key | Used by | Meaning | +|---|---|---| +| `tronlinkSecretId` | [`tx multisig`](tx/multisig.md) | TronLink multi-sign service secret id | +| `tronlinkSecretKey` | [`tx multisig`](tx/multisig.md) | TronLink service secret key — **masked on read** | +| `tronlinkChannel` | [`tx multisig`](tx/multisig.md) | TronLink service channel | +| `gasfreeApiKey` | [`gasfree`](gasfree/index.md) | GasFree Open Platform API key | +| `gasfreeApiSecret` | [`gasfree`](gasfree/index.md) | GasFree API secret — **masked on read** | + +Each must be 1–256 characters with no control characters. + +**Secrets are masked, never echoed.** `tronlinkSecretKey` and `gasfreeApiSecret` read back as +`********`, and setting one returns `"value":"********","input":"********"` rather than the value +you typed — so a config write is safe to log. An unset secret reads as absent, not as `********`, +which is how you tell "not configured" from "configured". + +These are the only wallet-cli settings that hold credentials, and unlike wallet secrets they are +stored in the config document rather than the encrypted keystore — they authenticate you to a +third-party service, they do not control funds. + Precedence for a value that has both a flag and a config key (highest first): command-line flag > config value > built-in default — e.g. `--timeout` > config `timeoutMs` > built-in 60000. -An invalid value returns `invalid_value` (exit 2). +An invalid value returns `invalid_value` (exit 2). `networks` is read-only — writing it fails with +`networks is read-only` (`invalid_value`, exit 2). ## Examples @@ -77,13 +99,39 @@ wallet-cli config timeoutMs 120000 -o json {"schema":"wallet-cli.result.v1","success":true,"command":"config","data":{"key":"timeoutMs","value":120000,"input":"120000"},"meta":{"durationMs":3,"warnings":[]}} ``` +Configure a service credential — note that the value never appears in the output: + +```bash +wallet-cli config gasfreeApiSecret "$SECRET" -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"config","data":{"key":"gasfreeApiSecret","value":"********","input":"********"},"meta":{"durationMs":37,"warnings":[]}} +``` + +```bash +wallet-cli config +``` + +```console +defaultNetwork tron:mainnet +defaultOutput text +timeoutMs 60000 +waitTimeoutMs 60000 +networks tron:mainnet, tron:nile, tron:shasta +gasfreeApiSecret ******** +``` + +Keys that have never been set are omitted from the listing entirely — that absence is how you tell +an unconfigured credential from a configured one. + ## Output `data` varies by mode. Local command — no `chain` block. | Mode | `data` fields | |---|---| -| show all (no args) | one field per key: `defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`, `networks` (array of network ids) | +| show all (no args) | one field per set key: `defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`, `networks` (array of network ids), plus any configured `tronlink*` / `gasfree*` credentials, with secrets as `********` | | read (``) | `key`, `value` | | set (` `) | `key`, `value`, `input` (the raw string as typed) | diff --git a/ts/docs/commands/contact/add.md b/ts/docs/commands/contact/add.md new file mode 100644 index 000000000..1d5294be7 --- /dev/null +++ b/ts/docs/commands/contact/add.md @@ -0,0 +1,83 @@ +# wallet-cli contact add + +Add a recipient to the local address book. + +## Synopsis + +``` +wallet-cli contact add
[--note ] [options] +``` + +## Description + +Stores a name → address mapping locally. The address is validated as TRON Base58Check **at this +point**, so a typo fails here rather than on a later transfer: + +```console +error [invalid_value]: address is not a valid TRON address +``` + +After adding, the name is accepted by [`tx send --to`](../tx/send.md) and +[`gasfree transfer --to`](../gasfree/transfer.md). + +Names must not themselves look like a TRON address — a name that would be ambiguous with an +address is refused, so `--to ` can never be silently reinterpreted. + +Adding a name that already exists is an error (`already_exists`), not an update. Lookups are +case-insensitive and Unicode-normalized (NFKC), so `Alice` and `alice` are the same contact — to +repoint a name, [`contact remove`](remove.md) it first. + +No network access and no wallet unlock. + +## Arguments + +- `name` — contact name, 1–64 safe characters; must not resemble a TRON address (positional) +- `address` — TRON base58 address (positional) + +## Options + +| Option | Description | +|---|---| +| `--note ` | Free-form note, at most 128 safe characters | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli contact add alice TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t --note "Alice mainnet" +``` + +```console +✅ Contact added + Name alice + Address TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t + Note Alice mainnet +``` + +Then send by name: + +```bash +echo "$PW" | wallet-cli tx send --to alice --amount 1 --network tron:nile --password-stdin +``` + +## Output + +`data` is the stored contact. Local command — no `chain` block. + +| Field | Type | Meaning | +|---|---|---| +| `name` | string | Contact name as stored | +| `address` | string | Validated TRON base58 address | +| `note` | string \| null | The note, or `null` | +| `family` | string | Chain family — `tron` | + +## Exit status + +`0` · `1` execution failure (`insecure_permissions`, `encoding_error` — see +[`contact list`](list.md#storage)) · `2` usage error — `invalid_value` (malformed address, bad +name/note), `already_exists`, `limit_exceeded`. + +## See also + +[`contact list`](list.md) · [`contact remove`](remove.md) · [`tx send`](../tx/send.md) diff --git a/ts/docs/commands/contact/index.md b/ts/docs/commands/contact/index.md new file mode 100644 index 000000000..527d12a00 --- /dev/null +++ b/ts/docs/commands/contact/index.md @@ -0,0 +1,55 @@ +# wallet-cli contact + +A local address book of recipients. + +## Synopsis + +``` +wallet-cli contact COMMAND +``` + +## Subcommands + +| Command | Description | Network | +|---|---|---| +| [`contact add`](add.md) | Add a recipient | none | +| [`contact list`](list.md) | List recipients | none | +| [`contact remove`](remove.md) | Remove a recipient | none | + +## What a contact is for + +A contact maps a **name** to a validated TRON base58 address. Once stored, the name can be used +anywhere a recipient is expected: + +```bash +wallet-cli tx send --to alice --amount 1 --network tron:nile +wallet-cli gasfree transfer --to alice --amount 25 +``` + +The point is not convenience but transcription safety: the address is checksum-validated **once**, +when you add it, instead of being re-typed (and re-risked) on every transfer. Receipts show both, +so the resolution stays auditable — `To alice (TR7NHq…)`. + +## Storage and trust + +The address book lives in `contacts.json` under the wallet home. It is **not encrypted** — it holds +no secrets, only public addresses — but it is treated as security-relevant input: the file must be a +regular file (not a symlink), owned by you, with mode `0600`, and every entry is re-validated on +read. A file that fails those checks is rejected (`insecure_permissions` / `encoding_error`) rather +than trusted. + +That protects the file, not the decision. A contact is local data, not authority: anything that can +write your wallet directory can change where a name points. Verify the address printed on the +receipt before confirming a large transfer, exactly as you would a pasted address. + +Names are matched by a case-insensitive, NFKC-normalized key, so `Alice` and `alice` are the same +contact, and a name that resembles a TRON address is refused outright. The book holds at most +10,000 entries per family and is kept sorted by name. + +Contacts are chain-family scoped (`tron` today) and are **not** per-network: the same address is +valid on mainnet, Nile, and Shasta. + +## See also + +[`token add`](../token/add.md) — the equivalent address book for tokens · +[`tx send`](../tx/send.md) · [`gasfree transfer`](../gasfree/transfer.md) diff --git a/ts/docs/commands/contact/list.md b/ts/docs/commands/contact/list.md new file mode 100644 index 000000000..e43a09571 --- /dev/null +++ b/ts/docs/commands/contact/list.md @@ -0,0 +1,71 @@ +# wallet-cli contact list + +List every recipient in the local address book. + +## Synopsis + +``` +wallet-cli contact list [options] +``` + +## Description + +Prints all stored contacts, sorted by name. Local only — no network access, no wallet unlock. + +## Options + +Only the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli contact list +``` + +```console +| Name | Address | Note | +| ----- | ---------------------------------- | ------------- | +| alice | TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t | Alice mainnet | +``` + +```bash +wallet-cli contact list -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"contact.list","data":{"contacts":[{"name":"alice","address":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","note":"Alice mainnet","family":"tron"}]},"meta":{"durationMs":14,"warnings":[]}} +``` + +An empty address book is a success, not an error — `data.contacts` is `[]`. + +## Output + +`data` is a local result — no `chain` block. + +| Field | Type | Meaning | +|---|---|---| +| `contacts` | array | Stored contacts, sorted by name | +| `contacts[].name` | string | Contact name | +| `contacts[].address` | string | TRON base58 address | +| `contacts[].note` | string \| null | The note, or `null` | +| `contacts[].family` | string | Chain family — `tron` | + +## Storage + +Contacts are kept in `contacts.json` under the wallet home. The file is not encrypted, but it must +be a regular file owned by you with mode `0600`, and each entry is re-validated on every read. +A file that fails those checks fails the command: + +| Code | Cause | +|---|---| +| `insecure_permissions` | Symlink, wrong owner, or mode other than `0600` | +| `encoding_error` | Not valid JSON, wrong schema, duplicate names, or larger than 4 MiB | + +## Exit status + +`0` · `1` execution failure (`insecure_permissions`, `encoding_error`) · `2` usage error. + +## See also + +[`contact add`](add.md) · [`contact remove`](remove.md) · [`list`](../list.md) — wallet accounts, +not recipients diff --git a/ts/docs/commands/contact/remove.md b/ts/docs/commands/contact/remove.md new file mode 100644 index 000000000..44ee9c491 --- /dev/null +++ b/ts/docs/commands/contact/remove.md @@ -0,0 +1,66 @@ +# wallet-cli contact remove + +Remove one recipient from the local address book. + +## Synopsis + +``` +wallet-cli contact remove [options] +``` + +## Description + +Deletes a single contact. This is a purely local edit — it changes **no on-chain state**, moves no +funds, and does not affect any transaction that already used the name. + +The lookup is case-insensitive, so the name may be given in any casing. An unknown name fails with +`not_found` rather than silently succeeding. + +Use this to repoint a name at a different address: remove it, then +[`contact add`](add.md) it again — [`contact add`](add.md) refuses to overwrite an existing entry. + +## Arguments + +- `name` — contact name to remove (positional) + +## Options + +Only the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli contact remove alice +``` + +```console +✅ Contact removed + Name alice + Address TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t +``` + +Removing a name that is not there: + +```console +error [not_found]: contact not found: alice +``` + +## Output + +`data` is the removed contact — the address is echoed so you can confirm what was dropped. + +| Field | Type | Meaning | +|---|---|---| +| `name` | string | Removed contact name | +| `address` | string | Address it pointed to | +| `note` | string \| null | The note, or `null` | +| `family` | string | Chain family — `tron` | + +## Exit status + +`0` · `1` execution failure (`insecure_permissions`, `encoding_error`) · `2` usage error +(`not_found`, missing positional). + +## See also + +[`contact add`](add.md) · [`contact list`](list.md) diff --git a/ts/docs/commands/current.md b/ts/docs/commands/current.md index f2dbddb0c..65a670cb5 100644 --- a/ts/docs/commands/current.md +++ b/ts/docs/commands/current.md @@ -5,12 +5,36 @@ Show the current (active) account. ## Synopsis ``` -wallet-cli current [options] +wallet-cli current [--qr] [options] ``` +## Description + +Shows the selected account entirely locally — no unlock, no network access. By default that is the +persisted **active** account; `--account ` inspects a different one +without changing the active selection (use [`use`](use.md) for that). + +`--qr` appends a scannable TRON **receive-address** QR code, encoding exactly the address and +nothing else — no amount, no memo, no URI scheme. The full address is printed underneath so you can +verify by eye what the code contains before anyone scans it. + +`--qr` applies to text output only: + +- In `-o json` it is ignored — the envelope stays a stable data frame rather than gaining terminal + art. +- If the terminal is non-interactive or too narrow to render a complete code, the command warns + (`terminal is non-interactive or too narrow for a complete QR code; showing the full address + only`) and prints the address alone. A **partial** QR is never shown — a truncated code could + scan as a different address. +- An account with no TRON address fails with `invalid_value`. + ## Options -[Global options](index.md) only. +| Option | Description | +|---|---| +| `--qr` | Render a terminal receive QR for the selected TRON address (text output, interactive TTY) | + +Plus the [global options](index.md). ## Examples @@ -31,6 +55,31 @@ wallet-cli current -o json {"schema":"wallet-cli.result.v1","success":true,"command":"current","data":{"accountId":"wlt_758891fa.1","label":"main-1","type":"seed","index":1,"active":true,"addresses":{"tron":"TRs9HgTuY3dT3yDasdFdP9WQHqL37891Ax"},"seedId":"wlt_758891fa"},"meta":{"durationMs":13,"warnings":[]}} ``` +Show a receive QR to be scanned by a phone wallet: + +```bash +wallet-cli current --qr +``` + +```console +Active account: main-1 + TRON address TRs9HgTuY3dT3yDasdFdP9WQHqL37891Ax + +█▀▀▀▀▀█ ▀▄█ ▄▀ █▀▀▀▀▀█ +█ ███ █ █▄▀ ▄█ █ ███ █ +█ ▀▀▀ █ ▀ █▄▄▀ █ ▀▀▀ █ +▀▀▀▀▀▀▀ █▄█▄▀ ▀▀▀▀▀▀▀ + +Receive address TRs9HgTuY3dT3yDasdFdP9WQHqL37891Ax +``` + +Inspect another account without switching to it — the heading reads `Selected account:` rather than +`Active account:`, because it is not the active one: + +```bash +wallet-cli current --qr --account treasury +``` + With no active account yet, it fails with `missing_wallet_address` (exit 1): ```bash @@ -51,7 +100,7 @@ error [missing_wallet_address]: no active account; import one first | `label` | string | Account label | | `type` | string | `seed` / `privateKey` / `watch` / `ledger` | | `index` | number \| null | HD derivation index; `null` for non-HD accounts | -| `active` | boolean | Always `true` | +| `active` | boolean | Whether this is the active account — `false` when `--account` selected another | | `addresses.tron` | string | Base58 TRON address | | `seedId` | string | Owning seed wallet id (`seed` accounts only) | | `family` | string | Chain family, e.g. `tron` (`watch` accounts only) | diff --git a/ts/docs/commands/encoding/convert.md b/ts/docs/commands/encoding/convert.md new file mode 100644 index 000000000..c274b2bf6 --- /dev/null +++ b/ts/docs/commands/encoding/convert.md @@ -0,0 +1,155 @@ +# wallet-cli encoding convert + +Convert and validate address, hex, Base64, and Base58Check encodings. + +## Synopsis + +``` +wallet-cli encoding convert [options] +wallet-cli encoding convert --input [options] +``` + +## Description + +Auto-detects what you passed and prints every equivalent form. Runs entirely locally: no network, +no wallet, no unlock. + +Detection is by shape, and falls into two families: + +**Address / public key** — prints the TRON base58, TRON hex, and EVM forms: + +| Input | Recognized as | Validated by | +|---|---|---| +| `T…` (base58, 26–41 chars) | `tron-base58` | Base58Check checksum | +| `41…` / `0x41…` (21 bytes hex) | `tron-hex` | length + prefix | +| `0x…` (20 bytes hex) | `evm` | EIP-55 checksum, when the input is mixed-case | +| `02…` / `03…` (33 bytes) or `04…` (65 bytes) | `public-key` | secp256k1 point validity | + +**Generic bytes** — prints the hex, Base64, and Base58Check forms: + +| Input | Recognized as | +|---|---| +| `[0-9a-f]+` / `0x…`, even number of digits | `hex` | +| Canonical Base58Check | `base58check` | +| Canonical Base64 | `base64` | + +Checksums are enforced, not merely displayed. A base58 address with a typo fails +(`base58 checksum mismatch (typo in the address?)`), and a mixed-case EVM address whose EIP-55 +capitalization does not match is rejected — an all-lowercase or all-uppercase EVM address carries no +checksum, so it is accepted and re-emitted in checksummed form. + +### Private-key safety + +A generic input that decodes to **exactly 32 bytes** is refused: + +```console +error [invalid_value]: 32-byte input may be a private key and is not accepted on argv +``` + +32 bytes is the size of a secp256k1 private key, and argv is visible in the process list and shell +history. The command cannot tell a private key from any other 32-byte blob, so it declines the +whole class rather than risk leaking one. Decoded input is otherwise limited to 1 byte – 1 MiB. + +## Arguments + +- `input` — the value to convert (positional, or `--input `) + +> **Pass `0x…` values with `--input`.** A `0x`-prefixed value given *positionally* is interpreted as +> a number by the argv parser and rejected before it reaches the converter: +> +> ```console +> error [invalid_value]: invalid --input: Invalid input: expected string, received number +> ``` +> +> `wallet-cli encoding convert --input 0x…` is unaffected, as is the un-prefixed `41…` hex form. + +## Options + +| Option | Description | +|---|---| +| `--input ` | The value to convert; equivalent to the positional, and required for `0x…` inputs | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +A TRON address, in every form: + +```bash +wallet-cli encoding convert TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t +``` + +```console +TRON TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t +TRON hex 41a614f803b6fd780986a42c78ec9c7f77e6ded13c +EVM 0xa614f803B6FD780986A42c78Ec9c7f77e6DeD13C +``` + +The conversion is symmetric — feeding back the EVM form yields the same three rows: + +```bash +wallet-cli encoding convert --input 0xa614f803B6FD780986A42c78Ec9c7f77e6DeD13C +``` + +```console +TRON TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t +TRON hex 41a614f803b6fd780986a42c78ec9c7f77e6ded13c +EVM 0xa614f803B6FD780986A42c78Ec9c7f77e6DeD13C +``` + +Generic bytes: + +```bash +wallet-cli encoding convert deadbeef0102 +``` + +```console +Hex deadbeef0102 +Base64 3q2+7wEC +Base58Check DWcJPafcQr2coF +``` + +```bash +wallet-cli encoding convert TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"encoding.convert","data":{"input":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","inputType":"tron-base58","valid":true,"tron":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","tronHex":"41a614f803b6fd780986a42c78ec9c7f77e6ded13c","evm":"0xa614f803B6FD780986A42c78Ec9c7f77e6DeD13C"},"meta":{"durationMs":47,"warnings":[]}} +``` + +## Output + +`data` is a local result — no `chain` block. The shape depends on which family was detected; +`inputType` tells you which, so a consumer can branch on it. + +Address / public-key inputs: + +| Field | Type | Meaning | +|---|---|---| +| `input` | string | The value as passed | +| `inputType` | string | `tron-base58` \| `tron-hex` \| `evm` \| `public-key` | +| `valid` | boolean | Always `true` — invalid input is an error, not a `false` result | +| `tron` | string | TRON base58 address | +| `tronHex` | string | `41`-prefixed 21-byte hex address | +| `evm` | string | EIP-55 checksummed EVM address | + +Generic inputs: + +| Field | Type | Meaning | +|---|---|---| +| `input` | string | The value as passed | +| `inputType` | string | `hex` \| `base64` \| `base58check` | +| `valid` | boolean | Always `true` | +| `hex` | string | Lowercase hex, no `0x` prefix | +| `base64` | string | Canonical Base64 | +| `base58check` | string | Base58Check with a sha256 checksum | + +## Exit status + +`0` · `2` usage error — `invalid_value` for a checksum mismatch, an odd-length hex string, an +unrecognized encoding, a 32-byte input, empty input, or whitespace/control characters. + +## See also + +[`address generate`](../address/generate.md) · [`contract info`](../contract/info.md) · +[`account info`](../account/info.md) diff --git a/ts/docs/commands/encoding/index.md b/ts/docs/commands/encoding/index.md new file mode 100644 index 000000000..66da50bca --- /dev/null +++ b/ts/docs/commands/encoding/index.md @@ -0,0 +1,30 @@ +# wallet-cli encoding + +Local encoding conversion and validation. + +## Synopsis + +``` +wallet-cli encoding COMMAND +``` + +## Subcommands + +| Command | Description | Network | +|---|---|---| +| [`encoding convert`](convert.md) | Convert and validate address, hex, Base64, and Base58Check encodings | none | + +## Why this exists + +The same TRON identity has several textual spellings — base58 `T…`, the `41`-prefixed hex the +protocol actually uses, and the `0x…` EVM form — and TRON tooling mixes all three. Explorers, +contract ABIs, and node RPC responses each favour a different one, and a value copied from one +into another is a common, silent source of failure. + +`encoding convert` resolves that locally: give it any of the forms and it prints all the +equivalents, verifying the checksum on the way. + +## See also + +[`address generate`](../address/generate.md) · [`contract call`](../contract/call.md) · +[Accounts & HD](../../concepts/accounts-and-hd.md) diff --git a/ts/docs/commands/gasfree/index.md b/ts/docs/commands/gasfree/index.md new file mode 100644 index 000000000..d9b58e1c4 --- /dev/null +++ b/ts/docs/commands/gasfree/index.md @@ -0,0 +1,70 @@ +# wallet-cli gasfree + +Transfer tokens without holding any TRX, via the GasFree Open Platform. + +## Synopsis + +``` +wallet-cli gasfree COMMAND +``` + +## Subcommands + +| Command | Description | Broadcasts | +|---|---|---| +| [`gasfree info`](info.md) | Show GasFree address, activation status, nonce, balances, and fees | no | +| [`gasfree transfer`](transfer.md) | Sign and submit a TIP-712 GasFree token transfer | ✍️ yes | +| [`gasfree trace`](trace.md) | Track a GasFree transfer by provider trace id | no | + +## What GasFree is + +Normally a TRC20 transfer costs TRX — you need bandwidth and energy, so an account holding only +USDT cannot move it. GasFree removes that requirement: instead of broadcasting a transaction, you +**sign a TIP-712 `PermitTransfer` authorization** and hand it to a service provider, who broadcasts +on your behalf and takes their fee **in the token being transferred**. + +So the flow is not "send a transaction" but "sign an intent, then track it": + +``` +gasfree transfer ──sign PermitTransfer──> provider accepts (traceId) + │ + WAITING → INPROGRESS → CONFIRMING → SUCCEED | FAILED + │ + gasfree trace +``` + +Your **GasFree address** is a distinct, deterministic address derived from your account — it is +*not* your normal TRON address. Tokens must be in the GasFree address to be transferable this way; +see [`gasfree info`](info.md). + +## Configuration + +GasFree requires Open Platform credentials, stored via [`config`](../config.md): + +```bash +wallet-cli config gasfreeApiKey +wallet-cli config gasfreeApiSecret +``` + +The secret is never echoed back — reading it returns `********`. + +Endpoint and TIP-712 domain come from the network descriptor, not from your config: + +| Network | GasFree endpoint | Supported | +|---|---|---| +| `tron:mainnet` | `https://open.gasfree.io` | yes | +| `tron:nile` | `https://open-test.gasfree.io` | yes | +| `tron:shasta` | — | **no** — `unsupported_network` | + +## Integrity checks + +Provider responses are untrusted input, and the CLI validates rather than displays them. It fails +with `gasfree_integrity` if the provider reports an owner that is not the selected account, if +token and address fee metadata disagree, if fee arithmetic does not add up, or if a trace names a +token outside the current configuration. Signing likewise fails (`signing_rejected`) unless the +signed digest is exactly the expected `PermitTransfer` and recovers to the selected account. + +## See also + +[`tx send`](../tx/send.md) — the ordinary, TRX-paying transfer · +[`token balance`](../token/balance.md) · [`config`](../config.md) diff --git a/ts/docs/commands/gasfree/info.md b/ts/docs/commands/gasfree/info.md new file mode 100644 index 000000000..b370295bc --- /dev/null +++ b/ts/docs/commands/gasfree/info.md @@ -0,0 +1,77 @@ +# wallet-cli gasfree info + +Show GasFree address, activation status, nonce, balances, and fees. + +## Synopsis + +``` +wallet-cli gasfree info [options] +``` + +## Description + +Reports everything you need before a [`gasfree transfer`](transfer.md), for the active account (or +`--account`): + +- the **GasFree address** derived from your account — a different address from your ordinary TRON + one, and the address that must actually hold the tokens +- whether it is **active**; an inactive address pays a one-time activation fee on its first transfer +- the current **nonce**, which the signed authorization is bound to +- per supported token: the balance held at the GasFree address, and the current activation and + transfer fees **denominated in that token** + +Read-only: no signing, no unlock, and nothing is submitted. Requires GasFree credentials in +[`config`](../config.md). + +Fees are quoted by the provider and change over time — read them here rather than assuming a +constant. The CLI cross-checks the fee metadata from the token list against the address response +and fails with `gasfree_integrity` if they disagree. + +## Options + +Only the [global options](../index.md#global-options-every-command) (`--account`, `--network`, …). + +## Examples + +```bash +wallet-cli gasfree info --network tron:nile +``` + +```console +Owner TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ +GasFree address TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 +Status active +Nonce 3 + +| Token | Balance | Activation fee | Transfer fee | +| ----- | ---------- | -------------- | ------------ | +| USDT | 125.5 USDT | 1 USDT | 1 USDT | +``` + +`Status not activated` means the first transfer will additionally deduct the activation fee. + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `ownerAddress` | string | The selected account's TRON address | +| `gasFreeAddress` | string | Derived GasFree address that holds the tokens | +| `active` | boolean | Whether the GasFree address is activated | +| `nonce` | string | Current authorization nonce | +| `tokens[].symbol` | string | Token symbol | +| `tokens[].address` | string | Token contract address | +| `tokens[].decimals` | number | Token decimals | +| `tokens[].balance` | string | Raw balance at the GasFree address, in base units | +| `tokens[].activateFee` | string | One-time activation fee, in token base units | +| `tokens[].transferFee` | string | Per-transfer service fee, in token base units | + +Amounts are raw base-unit strings in JSON; the text renderer applies `decimals`. + +## Exit status + +`0` · `1` execution failure (`gasfree_integrity`, provider unreachable, missing/invalid +credentials) · `2` usage error (`unsupported_network` on `tron:shasta`). + +## See also + +[`gasfree transfer`](transfer.md) · [`gasfree trace`](trace.md) · [`config`](../config.md) diff --git a/ts/docs/commands/gasfree/trace.md b/ts/docs/commands/gasfree/trace.md new file mode 100644 index 000000000..ab033e8e9 --- /dev/null +++ b/ts/docs/commands/gasfree/trace.md @@ -0,0 +1,94 @@ +# wallet-cli gasfree trace + +Track a GasFree transfer by provider trace id. + +## Synopsis + +``` +wallet-cli gasfree trace [options] +``` + +## Description + +A GasFree transfer is not on-chain when it is accepted — the provider queues it, then broadcasts. +[`gasfree transfer`](transfer.md) returns a `traceId`; this command turns that id into the current +state. + +| State | Meaning | +|---|---| +| `WAITING` | Accepted, not yet picked up | +| `INPROGRESS` | Being processed by the provider | +| `CONFIRMING` | Broadcast, awaiting chain confirmation | +| `SUCCEED` | Terminal — on chain, `txId` is populated | +| `FAILED` | Terminal — `failureReason` explains why | + +Once the transfer settles, the reported amount and fees switch from the estimates to the **actual** +settled values, so a completed trace is the authoritative record of what was deducted. + +No wallet unlock and no signing; it only needs GasFree credentials and the trace id. Because it is +wallet-independent, you can track a transfer from a machine that holds no keys. + +## Arguments + +- `traceId` — the provider trace id returned by [`gasfree transfer`](transfer.md) (positional) + +## Options + +Only the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli gasfree trace 7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527 --network tron:nile +``` + +```console +Trace ID 7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527 +Status succeed +TxID 5b1c0d9e7a4f2c8b6d3e1a0f9c7b5d3a1e8f6c4b2d0a9e7c5b3d1f8a6c4e2b0d +Token USDT +Amount 25 USDT +Service fee 1 USDT +Activation fee 0 USDT +Total 26 USDT +To TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t +``` + +Poll until terminal in a script: + +```bash +until wallet-cli gasfree trace "$TRACE" -o json \ + | jq -e '.data.state == "SUCCEED" or .data.state == "FAILED"' >/dev/null; do + sleep 5 +done +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `traceId` | string | Provider trace id | +| `state` | string | `WAITING` \| `INPROGRESS` \| `CONFIRMING` \| `SUCCEED` \| `FAILED` | +| `txId` | string | On-chain transaction hash — present once broadcast | +| `token` | string | Token symbol | +| `tokenAddress` | string | Token contract address | +| `decimals` | number | Token decimals | +| `amount` | string | Settled amount if available, else the submitted amount (base units) | +| `serviceFee` | string | Settled or estimated transfer fee (base units) | +| `activateFee` | string | Settled or estimated activation fee (base units) | +| `totalDeducted` | string | Total taken from the GasFree address (base units) | +| `from` | string | GasFree address the tokens left | +| `owner` | string | Account that authorized the transfer | +| `to` | string | Recipient | +| `nonce` | string | Authorization nonce | +| `failureReason` | string | Present only when `state` is `FAILED` | + +## Exit status + +`0` — including for a `FAILED` transfer: the *query* succeeded, so check `data.state` rather than +the exit code · `1` execution failure (`gasfree_integrity`, provider unreachable, unknown trace +id) · `2` usage error (malformed trace id, `unsupported_network`). + +## See also + +[`gasfree transfer`](transfer.md) · [`gasfree info`](info.md) · [`tx status`](../tx/status.md) diff --git a/ts/docs/commands/gasfree/transfer.md b/ts/docs/commands/gasfree/transfer.md new file mode 100644 index 000000000..caf8b4ef3 --- /dev/null +++ b/ts/docs/commands/gasfree/transfer.md @@ -0,0 +1,154 @@ +# wallet-cli gasfree transfer + +Sign and submit a TIP-712 GasFree token transfer. ✍️ + +## Synopsis + +``` +wallet-cli gasfree transfer --to --amount + [--token ] [--dry-run] [options] +``` + +## Description + +Moves tokens **without spending any TRX**. Instead of building and broadcasting a transaction, the +command signs a TIP-712 `PermitTransfer` authorization and submits it to the GasFree provider, who +broadcasts it and takes the fee in the transferred token. + +Two consequences worth internalizing: + +1. The tokens must sit in your **GasFree address**, not your ordinary TRON address — check with + [`gasfree info`](info.md). +2. Success here means **accepted by the provider**, not confirmed on chain. The command returns a + `traceId`; follow it with [`gasfree trace`](trace.md) or use `--wait`. + +`--to` accepts a TRON address or a local [contact](../contact/index.md) name. When a contact is +used, the receipt shows both — `To alice (TR7NHq…)` — so the resolution stays auditable. + +`--token` defaults to `USDT` and must be one the provider supports. + +### What is deducted + +| Component | When | +|---|---| +| the transfer `amount` | always | +| the **transfer fee** | always | +| the **activation fee** | only if your GasFree address is not yet active | + +The command checks the GasFree balance covers `amount + activation + transfer fee` up front and +fails with `insufficient_token_balance` (with `balance` and `required` in the error details) rather +than letting the provider reject it later. + +Note that the **authorized max fee** in the signature is always `activationFee + transferFee`, even +for an already-active address — it is an upper bound the provider may not exceed, not the amount +charged. Both appear in the output so the difference is visible. + +The signed authorization is bound to the current nonce and to a provider-supplied deadline, so it +cannot be replayed. + +### `--dry-run` + +Resolves the recipient, selects the token and provider, computes the exact fee breakdown, and +checks the balance — then stops. Nothing is signed, no unlock is needed, and nothing is submitted. +`--dry-run` cannot be combined with `--wait`. + +### `--wait` + +Polls the trace until a terminal state, then reports the **settled** amount and fees rather than +the estimates, with `stage` of `confirmed` (`SUCCEED`) or `failed` (`FAILED`). If the timeout +elapses first, it warns and returns the submitted receipt — the transfer is still in flight, so +track it with [`gasfree trace`](trace.md). + +## Options + +| Option | Description | +|---|---| +| `--to ` | **Required.** Recipient TRON address or local contact name | +| `--amount ` | **Required.** Human token amount; must be greater than zero | +| `--token ` | Token symbol (default `USDT`) | +| `--dry-run` | Check balance and fee breakdown without unlocking, signing, or submitting | +| `--wait` / `--wait-timeout ` | Poll the trace until it reaches a terminal state | +| `--password-stdin` | Master password from stdin (software accounts) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Price it first — no unlock, nothing submitted: + +```bash +wallet-cli gasfree transfer --to TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t \ + --amount 25 --network tron:nile --dry-run +``` + +```console +⏳ Dry run — GasFree transfer 25 USDT (not submitted) + From TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 + To TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t + Service fee 1 USDT + Activation fee 0 USDT + Authorized max fee 2 USDT + Total 26 USDT + Status not submitted +``` + +Submit it, to a contact by name: + +```bash +echo "$PW" | wallet-cli gasfree transfer --to alice --amount 25 \ + --network tron:nile --password-stdin +``` + +```console +⏳ Submitted to GasFree — send 25 USDT + Trace ID 7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527 + From TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 + To alice (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t) + Service fee 1 USDT + Activation fee 0 USDT + Total 26 USDT + Status waiting +! Track it: wallet-cli gasfree trace 7f3e9a02-58c1-4d2e-b6a4-91d0c3f8e527 +``` + +Submit and wait for the terminal state: + +```bash +echo "$PW" | wallet-cli gasfree transfer --to alice --amount 25 \ + --network tron:nile --wait --password-stdin +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `"gasfree-transfer"` | +| `stage` | string | `"dry-run"` \| `"submitted"` \| `"confirmed"` \| `"failed"` | +| `traceId` | string | Provider trace id — absent for `--dry-run` | +| `state` | string | Provider state (`WAITING` … `SUCCEED` / `FAILED`) | +| `txId` | string | On-chain hash, once the provider has broadcast | +| `token`, `tokenAddress`, `decimals` | — | Token identity | +| `amount` | string | Transfer amount in base units | +| `serviceFee` | string | Transfer fee in base units | +| `activateFee` | string | Activation fee in base units — `0` when already active | +| `authorizedMaxFee` | string | Fee ceiling covered by the signature | +| `totalDeducted` | string | `amount + activateFee + serviceFee` | +| `owner` | string | Account that authorized the transfer | +| `from` | string | GasFree address the tokens leave | +| `to` | string | Resolved recipient address | +| `toContact` | string | Contact name, when `--to` was a contact | +| `serviceProvider` | string | Provider address | +| `nonce`, `deadline` | string | Authorization binding | +| `failureReason` | string | Present when `stage` is `failed` | + +## Exit status + +`0` submitted (or dry-run) · `1` execution failure — `insufficient_token_balance`, +`gasfree_rejected` (address not permitted to submit), `gasfree_integrity`, `signing_rejected`, +`auth_failed` · `2` usage error — `invalid_option` (`--dry-run` with `--wait`), `invalid_amount`, +`unsupported_network` (`tron:shasta`), unknown token or contact. + +## See also + +[`gasfree info`](info.md) · [`gasfree trace`](trace.md) · [`tx send`](../tx/send.md) · +[`contact add`](../contact/add.md) diff --git a/ts/docs/commands/index.md b/ts/docs/commands/index.md index 130df9032..37a895295 100644 --- a/ts/docs/commands/index.md +++ b/ts/docs/commands/index.md @@ -19,6 +19,8 @@ Every command — including every subcommand — has its own page, following a f | `backup` | [backup.md](backup.md) | | `delete` | [delete.md](delete.md) | | `change-password` | [change-password.md](change-password.md) | +| `address` (group) | [address/index.md](address/index.md) | +| `address generate` | [address/generate.md](address/generate.md) | ## Transactions @@ -28,9 +30,28 @@ Every command — including every subcommand — has its own page, following a f | `tx send` | [tx/send.md](tx/send.md) | | `tx sign` | [tx/sign.md](tx/sign.md) | | `tx broadcast` | [tx/broadcast.md](tx/broadcast.md) | +| `tx approvals` | [tx/approvals.md](tx/approvals.md) | +| `tx multisig` | [tx/multisig.md](tx/multisig.md) | | `tx status` | [tx/status.md](tx/status.md) | | `tx info` | [tx/info.md](tx/info.md) | +## Multi-signature permissions + +| Command | Page | +|---|---| +| `permission` (group) | [permission/index.md](permission/index.md) | +| `permission show` | [permission/show.md](permission/show.md) | +| `permission update` | [permission/update.md](permission/update.md) *(can lock you out — read first)* | + +## Gas-free transfers + +| Command | Page | +|---|---| +| `gasfree` (group) | [gasfree/index.md](gasfree/index.md) | +| `gasfree info` | [gasfree/info.md](gasfree/info.md) | +| `gasfree transfer` | [gasfree/transfer.md](gasfree/transfer.md) | +| `gasfree trace` | [gasfree/trace.md](gasfree/trace.md) | + ## On-chain queries | Command | Page | @@ -40,6 +61,8 @@ Every command — including every subcommand — has its own page, following a f | `account info` | [account/info.md](account/info.md) | | `account history` | [account/history.md](account/history.md) | | `account portfolio` | [account/portfolio.md](account/portfolio.md) | +| `account activate` | [account/activate.md](account/activate.md) | +| `account set` | [account/set.md](account/set.md) | | `block` | [block.md](block.md) | | `chain` (group) | [chain/index.md](chain/index.md) | | `chain params` | [chain/params.md](chain/params.md) | @@ -98,6 +121,12 @@ Every command — including every subcommand — has its own page, following a f |---|---| | `config` | [config.md](config.md) | | `networks` | [networks.md](networks.md) | +| `contact` (group) | [contact/index.md](contact/index.md) | +| `contact add` | [contact/add.md](contact/add.md) | +| `contact list` | [contact/list.md](contact/list.md) | +| `contact remove` | [contact/remove.md](contact/remove.md) | +| `encoding` (group) | [encoding/index.md](encoding/index.md) | +| `encoding convert` | [encoding/convert.md](encoding/convert.md) | ## Global options (every command) @@ -110,4 +139,15 @@ Every command — including every subcommand — has its own page, following a f -h, --help / -V, --version ``` -Broadcast (✍️) commands additionally take `--wait` / `--wait-timeout ` (cap default: config `waitTimeoutMs`, built-in 60000) and `--dry-run` / `--sign-only`. +Broadcast (✍️) commands additionally take `--wait` / `--wait-timeout ` (cap default: config `waitTimeoutMs`, built-in 60000) and the mutually exclusive execution-mode flags: + +``` +--dry-run build and estimate only — no signature, no broadcast +--sign-only sign and output the complete transaction hex +--build-only build unsigned transaction hex without unlocking +--permission-id <0-9> TRON permission group authorizing this transaction (default 0) +--expiration expiration duration, 1–86400000; only with --sign-only / --build-only +``` + +Not every broadcast command exposes all of these — `gasfree transfer` signs a TIP-712 authorization +rather than building a TRON transaction, so it takes only `--dry-run`. Check the command's page. diff --git a/ts/docs/commands/permission/index.md b/ts/docs/commands/permission/index.md new file mode 100644 index 000000000..7ebf3ab86 --- /dev/null +++ b/ts/docs/commands/permission/index.md @@ -0,0 +1,65 @@ +# wallet-cli permission + +Inspect and replace a TRON account's multi-signature permission structure. + +## Synopsis + +``` +wallet-cli permission COMMAND +``` + +## Subcommands + +| Command | Description | Broadcasts | +|---|---|---| +| [`permission show`](show.md) | Show owner, witness, and active permission groups | no | +| [`permission update`](update.md) | Replace the complete account permission structure | ✍️ yes | + +## The TRON permission model + +Every TRON account has a permission structure that decides **which keys may authorize what**. It +has three kinds of group: + +| Group | id | Purpose | +|---|---|---| +| **owner** | `0` | Full control, including replacing the permission structure itself. Exactly one. | +| **witness** | `1` | Block production. Only meaningful for super representatives; at most one, with exactly one key. | +| **active** | `2`–`9` | Scoped day-to-day authority — each active group carries an explicit list of allowed operations. Up to 8. | + +Each group has: + +- **keys** — 1 to 5 addresses, each with a positive **weight** +- a **threshold** — the total weight a transaction must accumulate to be authorized; it may not + exceed the sum of the group's key weights + +A single-key account is just the degenerate case: one key of weight 1 and a threshold of 1. Once a +threshold exceeds any one key's weight, transactions need co-signing — that is what the +[`tx sign`](../tx/sign.md) / [`tx approvals`](../tx/approvals.md) / +[`tx multisig`](../tx/multisig.md) workflow exists for. + +Active groups additionally carry an **operations bitmap** — 32 bytes, one bit per TRON contract +type — which is what limits an active permission to, say, transfers and staking but not permission +changes. + +## Choosing the permission for a transaction + +Signing commands take `--permission-id <0-9>` to say *which group authorizes this transaction* +(default `0`, the owner). The id must be a group that actually permits the operation, and the +signer must be one of its keys. + +## The lockout risk + +`permission update` is a **complete replacement**, not a patch, and the account's own owner +permission is what governs future changes. A structure whose owner group contains no key this +wallet holds — or whose threshold your local keys cannot reach — leaves the account +permanently unusable from here. There is no recovery path and no support channel that can undo it. + +The CLI emits warnings for the recognizable cases (`owner_lockout`, `owner_lockout_partial`, +`active_can_update_permission`, `active_unknown_operations`), but they are warnings, not blocks. +Always run [`permission update --dry-run`](update.md) first and read the rendered structure back +before broadcasting. + +## See also + +[`tx sign`](../tx/sign.md) · [`tx approvals`](../tx/approvals.md) · +[`tx multisig`](../tx/multisig.md) · [Security model](../../concepts/security.md) diff --git a/ts/docs/commands/permission/show.md b/ts/docs/commands/permission/show.md new file mode 100644 index 000000000..6875bf8e0 --- /dev/null +++ b/ts/docs/commands/permission/show.md @@ -0,0 +1,103 @@ +# wallet-cli permission show + +Show owner, witness, and active permission groups. + +## Synopsis + +``` +wallet-cli permission show [--account ] [options] +``` + +## Description + +Reads the account's permission structure from the node and renders it with the operation bitmaps +**decoded** — so an active group shows `Transfer TRX · Vote · TRX Stake (2.0)` rather than 32 bytes +of hex. Read-only; no unlock, no broadcast. + +`--account` accepts a local account (id or label) **or any activated TRON address**, so you can +inspect an account this wallet does not hold keys for. + +Keys that belong to this wallet are annotated with the owning account label: + +``` + TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ 1 (this wallet: main) +``` + +That annotation is what tells you whether you can still authorize owner-level operations — read it +before running [`permission update`](update.md). + +Both forms are shown for active groups: the decoded operation labels and the raw `operationsHex`. +Bits the build has no name for are listed explicitly as `Unknown contract type ` rather than +being dropped, so no granted scope is invisible. + +## Options + +Only the [global options](../index.md#global-options-every-command) (`--account`, `--network`, …). + +## Examples + +```bash +wallet-cli permission show --network tron:nile +``` + +```console +Account main (TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ) + +Permission Name owner (id 0) +Threshold 2 +Authorized To Address Weight + TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ 1 (this wallet: main) + TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 1 + +Permission Name operations (id 2, active) +Operation(s) Transfer TRX · Transfer TRC10 · Vote · TRX Stake (2.0) (4 total) +Operations Hex 1600000000004000000000000000000000000000000000000000000000000000 +Threshold 1 +Authorized To Address Weight + TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 1 +``` + +Inspect any account by address: + +```bash +wallet-cli permission show --account TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t \ + --network tron:nile -o json +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `address` | string | Account whose permissions these are | +| `owner` | object | The owner group (id `0`) | +| `witness` | object \| null | The witness group (id `1`), or `null` | +| `actives` | array | Active groups (ids `2`–`9`) | + +Every group carries: + +| Field | Type | Meaning | +|---|---|---| +| `id` | number | Permission group id | +| `name` | string | Group name | +| `threshold` | number | Weight required to authorize | +| `keys[].address` | string | Authorized key | +| `keys[].weight` | number | That key's weight | +| `keys[].local` | string \| null | Local account label if this wallet holds the key, else `null` | + +Active groups additionally carry: + +| Field | Type | Meaning | +|---|---|---| +| `operations` | string[] | Allowed contract types, e.g. `["TransferContract","VoteWitnessContract"]` | +| `operationLabels` | string[] | Human labels for the same, e.g. `["Transfer TRX","Vote"]` | +| `operationsHex` | string | The raw 32-byte bitmap | +| `unknownOperationIds` | number[] | Set bits this build has no name for | + +## Exit status + +`0` · `1` execution failure (node unreachable, account not activated) · `2` usage error. + +## See also + +[`permission update`](update.md) · [`account info`](../account/info.md) · +[`tx approvals`](../tx/approvals.md) diff --git a/ts/docs/commands/permission/update.md b/ts/docs/commands/permission/update.md new file mode 100644 index 000000000..d68279e87 --- /dev/null +++ b/ts/docs/commands/permission/update.md @@ -0,0 +1,195 @@ +# wallet-cli permission update + +Replace the complete account permission structure. ✍️ + +> **This can permanently lock you out of the account.** It is a full replacement, and the owner +> permission is what authorizes all future changes. Run `--dry-run` first and read the rendered +> structure back before broadcasting. See [the lockout risk](index.md#the-lockout-risk). + +## Synopsis + +``` +wallet-cli permission update (--file | --json ) + [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +Submits one `AccountPermissionUpdateContract` that replaces the account's **owner, witness, and +active** permissions in a single atomic change. There is no partial update: whatever you pass is +the account's entire permission structure afterwards, and anything you omit is gone. + +The structure is validated strictly before anything is built, and rejected with `invalid_permission` +(exit 2) on any violation — never silently normalized. + +Exactly one of `--file` / `--json`. Files are read with a 1 MiB cap. Numbers are parsed +losslessly, so a threshold or weight beyond the safe-integer range is rejected rather than rounded. + +### The permission JSON + +```json +{ + "owner": { + "id": 0, + "name": "owner", + "threshold": 2, + "keys": [ + { "address": "TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ", "weight": 1 }, + { "address": "TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2", "weight": 1 } + ] + }, + "actives": [ + { + "id": 2, + "name": "operations", + "threshold": 1, + "operations": [ + "TransferContract", + "TransferAssetContract", + "VoteWitnessContract", + "FreezeBalanceV2Contract" + ], + "keys": [ + { "address": "TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2", "weight": 1 } + ] + } + ] +} +``` + +| Field | Required | Rules | +|---|---|---| +| `address` | no | If present, must equal the selected account's address | +| `owner` | **yes** | `id` must be `0`; must **not** define `operations` | +| `witness` | no | `id` must be `1`; exactly one key; must **not** define `operations`; omit or `null` for non-SR accounts | +| `actives` | no | At most 8 groups; ids `2`–`9` and unique; defaults to `[]` | + +Rules that apply to every group: + +- `keys` — 1 to 5 entries, valid TRON addresses, **no duplicates** +- `weight` — a positive safe integer +- `threshold` — a positive safe integer that **may not exceed the sum of the group's key weights** + (an unreachable threshold is a lockout, so it is refused) +- `name` — at most 32 UTF-8 bytes, no control characters; defaults to the group kind + +Active groups must grant at least one operation. Contract-type names are the TRON protocol names +(`TransferContract`, `TriggerSmartContract`, `AccountPermissionUpdateContract`, …); an unrecognized +one is refused rather than ignored. Use [`permission show`](show.md) on an existing account to see +the exact spelling. + +### `operations` and `operationsHex` + +Normally you list `operations` and the bitmap is computed for you. You may also supply +`operationsHex` — but then it must **agree** with `operations`, and any set bit that has no known +contract type must be declared verbatim in `unknownOperationIds`: + +```json +{ "id": 2, "name": "ops", "threshold": 1, + "operations": ["TransferContract"], + "operationsHex": "02000000000000000000000000000000000000000000000000000000000000c0", + "unknownOperationIds": [254, 255], + "keys": [{ "address": "T…", "weight": 1 }] } +``` + +This exists so a bitmap cannot quietly widen a permission while the human-readable `operations` +list stays unchanged. A mismatch is `invalid_permission`. + +### Safety warnings + +Warnings are emitted into `meta.warnings` before the transaction is built. They do **not** block +the update: + +| Code | Meaning | +|---|---| +| `owner_lockout` | Local keys hold **no** owner weight — applying this may permanently lock out this wallet | +| `owner_lockout_partial` | Local keys hold less than the owner threshold — co-signers will be required | +| `active_can_update_permission` | An active group can itself replace the permission structure | +| `active_unknown_operations` | An active group grants contract types this build cannot name | +| `permission_postcheck_mismatch` | After confirmation, the on-chain structure differs from what was requested | + +The command also refuses to broadcast when the account balance is below the network's permission +update fee (`insufficient_balance`) — this fee is substantial on mainnet, so check it with +`--dry-run`. + +## Options + +| Option | Description | +|---|---| +| `--file ` | Complete replacement permission JSON file (≤ 1 MiB) | +| `--json ` | The same JSON inline | +| `--dry-run` | Validate, build, and estimate without signing or broadcasting | +| `--sign-only` | Build and sign, then output the complete transaction hex | +| `--build-only` | Build and output unsigned transaction hex without unlocking | +| `--permission-id <0-9>` | Permission group authorizing *this* transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | +| `--password-stdin` | Master password from stdin (software accounts) | + +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Always start here — validate and price the change without signing: + +```bash +wallet-cli permission update --file permissions.json --network tron:nile --dry-run +``` + +```console +⏳ Permission update dry run + Fee 100 TRX + Status not submitted + +Account TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ + +Permission Name owner (id 0) +Threshold 2 +Authorized To Address Weight + TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ 1 (this wallet: main) + TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 1 +``` + +Apply it once the rendered structure is what you intended: + +```bash +echo "$PW" | wallet-cli permission update --file permissions.json \ + --network tron:nile --wait --password-stdin +``` + +Build on an online machine, sign on an offline one: + +```bash +wallet-cli permission update --file permissions.json --network tron:nile --build-only +# → unsigned transaction hex; sign it with `tx sign --hex`, then `tx broadcast --hex` +``` + +A structure whose threshold cannot be met is refused, not submitted: + +```console +error [invalid_permission]: owner.threshold exceeds the total key weight +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `"permission-update"` | +| `stage` | string | `"submitted"` / `"confirmed"` / `"failed"` | +| `mode` | string | `"dry-run"` / `"sign-only"` / `"build-only"` when a mode flag was used | +| `txId` | string | Transaction id | +| `hex` | string | Complete transaction hex (`--sign-only` / `--build-only`) | +| `permissions` | object | The canonical structure — as requested for non-broadcast modes, as read back from the chain after confirmation. Same shape as [`permission show`](show.md) | +| `blockNumber`, `feeSun` | — | Present after `--wait` | + +## Exit status + +`0` · `1` execution failure (`insufficient_balance`, `not_authorized`, `auth_failed`, node +rejection) · `2` usage error — `invalid_permission` (any structural violation), neither/both of +`--file` / `--json`, conflicting mode flags. + +## See also + +[`permission show`](show.md) · [`tx sign`](../tx/sign.md) · [`tx approvals`](../tx/approvals.md) · +[Security model](../../concepts/security.md) diff --git a/ts/docs/commands/tx/approvals.md b/ts/docs/commands/tx/approvals.md new file mode 100644 index 000000000..7376b4e9e --- /dev/null +++ b/ts/docs/commands/tx/approvals.md @@ -0,0 +1,115 @@ +# wallet-cli tx approvals + +Show permission, signature approvals, current weight, and expiration. + +## Synopsis + +``` +wallet-cli tx approvals (--hex | --file ) [options] +``` + +## Description + +Answers the question every multi-signature workflow keeps asking: **is this transaction ready to +broadcast yet, and if not, who still has to sign?** + +Given a partially signed transaction artifact, it decodes the transaction, asks the node which +permission group governs it, and reports: + +- what the transaction actually does — contract type, from, to, amount +- which permission group authorizes it, and that group's threshold +- which signers have already approved, and the weight each contributes +- the accumulated weight, the missing weight, and whether the threshold is reached +- when the transaction expires — and whether it already has + +Read-only and wallet-independent: it signs nothing, unlocks nothing, and needs no account. `--hex` +and `--file` are mutually exclusive; exactly one is required. Files are read with a size cap of just +over 1 MiB, and must be regular files (not symlinks). + +It does need a node (`--network`), because approval state — which signatures count, and for how +much weight — is the chain's answer, not something derivable from the artifact alone. + +An **expired** transaction is reported, not rejected: the output flags it and tells you to rebuild, +because collecting further signatures on it would be wasted effort. + +## Options + +| Option | Description | +|---|---| +| `--hex ` | Complete `protocol.Transaction` hex | +| `--file ` | Read the transaction hex from a file | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli tx approvals --file partially-signed.hex --network tron:nile +``` + +```console +Transaction + TxID abc123… + Type Transfer TRX (TransferContract) — 1 TRX + From TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ + To TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t + Permission active "operations" (id 2) threshold 2 + Expires 2026-07-29 12:34:56 (in 58 minutes) + +Progress 1 / 2 — 1 more weight needed +| Approved signer | Weight | +| ---------------------------------- | ------ | +| TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2 | 1 | +``` + +Once enough weight has accumulated, the progress line says so instead: + +```console +Progress 2 / 2 — threshold reached +``` + +Gate a broadcast on it in a script: + +```bash +if wallet-cli tx approvals --file signed.hex --network tron:nile -o json \ + | jq -e '.data.thresholdReached and (.data.expired | not)' >/dev/null; then + wallet-cli tx broadcast --file signed.hex --network tron:nile +fi +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `txId` | string | Transaction id | +| `contractType` | string | TRON contract type, e.g. `TransferContract` | +| `operation` | string | Human label for that contract type, e.g. `Transfer TRX` | +| `from` / `to` | string | Decoded parties, when the contract type exposes them | +| `rawAmount` | string | Amount in base units, when applicable | +| `tokenContract` | string | Token contract, for token transfers | +| `permission.id` | number | Governing permission group id | +| `permission.name` | string | Group name | +| `permission.threshold` | number | Weight required | +| `currentWeight` | number | Weight accumulated so far | +| `missingWeight` | number | Weight still needed — `0` once reached | +| `thresholdReached` | boolean | Whether it can be broadcast | +| `approved[].address` | string | A signer that has approved | +| `approved[].weight` | number | That signer's weight | +| `signatures` | number | Raw signature count on the artifact | +| `expiration` | number | Expiry, epoch ms | +| `expired` | boolean | Whether it is already past | + +`signatures` counts signatures on the artifact; `currentWeight` is what the **node** counts toward +the threshold. They differ when a signature is not from a key in the governing permission — that is +exactly the case worth noticing. + +## Exit status + +`0` — including when the threshold is *not* reached: the query succeeded, so branch on +`data.thresholdReached`, not on the exit code · `1` execution failure (node unreachable, +undecodable transaction) · `2` usage error (neither/both of `--hex` / `--file`). + +## See also + +[`tx sign --check`](sign.md) · [`tx broadcast`](broadcast.md) · +[`tx multisig`](multisig.md) · [`permission show`](../permission/show.md) diff --git a/ts/docs/commands/tx/broadcast.md b/ts/docs/commands/tx/broadcast.md index f4fd1ca6a..144b55022 100644 --- a/ts/docs/commands/tx/broadcast.md +++ b/ts/docs/commands/tx/broadcast.md @@ -1,18 +1,39 @@ # wallet-cli tx broadcast -Broadcast a presigned transaction. +Validate and broadcast a presigned JSON or protobuf-hex transaction. ## Synopsis ``` -wallet-cli tx broadcast (--transaction | --tx-stdin) --network [options] +wallet-cli tx broadcast (--transaction | --tx-stdin | --hex | --file ) + --network [--dry-run] [options] ``` ## Description -Submits a transaction that was signed elsewhere — typically the `data.signed` object from [`tx send --sign-only`](send.md) on an offline or key-holding machine. No wallet unlock is needed; the transaction is already signed. +Submits a transaction that was signed elsewhere — typically the `data.signed` object from [`tx send --sign-only`](send.md) on an offline or key-holding machine, or a co-signed hex artifact from [`tx sign --out`](sign.md). No wallet unlock is needed; the transaction is already signed. -A presigned transaction carries no network of its own, so pass `--network` to say which network to broadcast to (falls back to the config default network when omitted). Exactly one of `--transaction` / `--tx-stdin`. +A presigned transaction carries no network of its own, so pass `--network` to say which network to broadcast to (falls back to the config default network when omitted). Exactly one of `--transaction` / `--tx-stdin` / `--hex` / `--file`. + +### Validation before submission + +Broadcasting is not blind. Whatever form the transaction arrives in, it is decoded and checked +first, and a transaction that cannot succeed is rejected locally instead of being sent: + +- **expired** → `tx_expired` +- **insufficient signature weight** → `not_authorized`, naming the missing weight + +That check is what makes this safe as the last step of a multi-signature workflow: a transaction +that has not yet reached its permission threshold never reaches the node. + +### `--dry-run` + +Runs exactly those checks — signatures, threshold, expiration, and the dynamic multi-sign fee — and +reports the approval state **without broadcasting**. Use it to confirm a collected transaction is +complete before committing it. `--dry-run` cannot be combined with `--wait`. + +A transaction carrying more than one signature also incurs TRON's multi-sign fee; it is reported as +`multiSignFeeSun` in both dry-run and real broadcasts. ## Options @@ -20,6 +41,9 @@ A presigned transaction carries no network of its own, so pass `--network` to sa |---|---| | `--transaction ` | Signed TRON transaction JSON inline | | `--tx-stdin` | Read the signed transaction JSON from stdin (fd 0) | +| `--hex ` | Complete signed `protocol.Transaction` hex | +| `--file ` | Read the signed transaction hex from a file (size-capped, just over 1 MiB) | +| `--dry-run` | Validate signatures, threshold, expiration, and multi-sign fee without broadcasting | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default 60000) | Plus the [global options](../index.md#global-options-every-command). @@ -55,6 +79,19 @@ wallet-cli tx broadcast --tx-stdin --network tron:nile < signed.json {"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"}} ``` +Broadcast the co-signed artifact at the end of a multi-signature flow, checking it first: + +```bash +wallet-cli tx broadcast --file signed.hex --network tron:nile --dry-run +wallet-cli tx broadcast --file signed.hex --network tron:nile +``` + +An incomplete transaction is refused locally, before the node ever sees it: + +```console +error [not_authorized]: signature threshold is not reached; missing 1 weight +``` + ## Output `data` varies by stage: @@ -63,13 +100,24 @@ wallet-cli tx broadcast --tx-stdin --network tron:nile < signed.json |---|---| | default (submit) | `kind`, `stage: "submitted"`, `txId` | | `--wait` (confirmed/failed) | above, plus `confirmed`, `blockNumber`, `failed`, and result fields | +| `--dry-run` | `kind`, `mode: "dry-run"`, no `txId` — nothing was submitted | + +All stages additionally carry: + +| Field | Type | Meaning | +|---|---|---| +| `transaction` | object | Approval state of the decoded transaction — the same shape [`tx approvals`](approvals.md) returns | +| `multiSignFeeSun` | number | Multi-sign fee in SUN; `0` for a single-signature transaction | As with `tx send`, the default return point is **submission** — confirm via `--wait` or [`tx status`](status.md). ## Exit status -`0` submitted · `1` execution failure (node rejected the tx, timeout) · `2` usage error (both/neither transaction sources). +`0` submitted (or dry-run) · `1` execution failure — `tx_expired`, `not_authorized` (threshold not +reached), node rejection, timeout · `2` usage error — no transaction source, more than one source, +`--dry-run` with `--wait`, malformed JSON. ## See also -[`tx send --sign-only`](send.md) · [`tx status`](status.md) · [Scripting guide](../../guide/scripting.md#sign-here-broadcast-there) +[`tx send --sign-only`](send.md) · [`tx sign`](sign.md) · [`tx approvals`](approvals.md) · +[`tx status`](status.md) · [Scripting guide](../../guide/scripting.md#sign-here-broadcast-there) diff --git a/ts/docs/commands/tx/index.md b/ts/docs/commands/tx/index.md index 969fb1c2f..93652d4df 100644 --- a/ts/docs/commands/tx/index.md +++ b/ts/docs/commands/tx/index.md @@ -13,7 +13,10 @@ wallet-cli tx COMMAND | Command | Description | |---|---| | [`tx send`](send.md) | Send native TRX or TRC20/TRC10 tokens with human `--amount` | -| [`tx broadcast`](broadcast.md) | Broadcast a presigned transaction | +| [`tx sign`](sign.md) | Sign transaction JSON, or append a signature to transaction hex, offline | +| [`tx broadcast`](broadcast.md) | Validate and broadcast a presigned JSON or protobuf-hex transaction | +| [`tx approvals`](approvals.md) | Show permission, signature approvals, current weight, and expiration | +| [`tx multisig`](multisig.md) | Coordinate signature collection through the TronLink service | | [`tx status`](status.md) | Show confirmation status of a transaction | | [`tx info`](info.md) | Show full transaction detail + receipt | @@ -28,6 +31,29 @@ build ──sign──> submit ──solidify──> confirmed `tx send` covers build+sign+submit in one step (with `--dry-run` / `--sign-only` stopping earlier); `tx broadcast` submits what was signed elsewhere; `tx status` / `tx info` observe the outcome. **Submission is not confirmation** — scripts must follow [machine-interface → Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed). +## Multi-signature + +When an account's [permission](../permission/index.md) threshold needs more weight than one key +carries, signing stops being a single step and becomes a collection process. Each permitted key +signs the *same* transaction in turn, and it can only be broadcast once the accumulated weight +reaches the threshold: + +``` +build ──> sign ──> sign ──> … ──> threshold reached ──> broadcast + (--build-only) each signer appends; │ + prior signatures preserved └ tx approvals: who signed, how much weight is left +``` + +Two ways to move the transaction between signers: + +| | How it travels | Command | +|---|---|---| +| **Offline / by hand** | A hex artifact you copy from signer to signer | [`tx sign --file … --out`](sign.md) | +| **Via the TronLink service** | A shared queue the service holds | [`tx multisig`](multisig.md) | + +Either way, [`tx approvals`](approvals.md) answers "is it ready yet?", and +[`tx broadcast`](broadcast.md) refuses to submit a transaction that has not reached its threshold. + ## See also [`account history`](../account/index.md) · [Networks & fees](../../concepts/networks.md) · [Scripting guide](../../guide/scripting.md) diff --git a/ts/docs/commands/tx/multisig.md b/ts/docs/commands/tx/multisig.md new file mode 100644 index 000000000..dace0fb8e --- /dev/null +++ b/ts/docs/commands/tx/multisig.md @@ -0,0 +1,178 @@ +# wallet-cli tx multisig + +Coordinate multi-signature collection through the TronLink service. ✍️ + +## Synopsis + +``` +wallet-cli tx multisig # list +wallet-cli tx multisig --create (--hex | --file ) +wallet-cli tx multisig --sign +wallet-cli tx multisig --watch +``` + +## Description + +Collecting signatures is a coordination problem, not a cryptographic one: the transaction has to +reach each co-signer, and someone has to know when enough weight has accumulated. The offline path +([`tx sign --file`](sign.md) → hand the file on → [`tx approvals`](approvals.md)) solves it by +passing an artifact around by hand. + +This command uses the official TronLink multi-sign service as a shared inbox instead: one signer +uploads the unsigned transaction, the others fetch, sign, and submit it, and the service holds the +accumulated signatures in between. TronLink wallet users see the same queue. + +The mode flags `--create`, `--sign`, and `--watch` are mutually exclusive; with none of them the +command lists. + +**The service is a transport, not an authority.** Everything it returns is treated as untrusted: +the CLI re-derives the transaction hash from the raw data, checks the reported contract type, +owner, weights, and signature progress against the transaction itself, and fails with +`provider_error` on any disagreement — rather than showing you what the service claims. Signing +still happens locally with your key. + +### Modes + +**list** (no flag) — service-managed transactions for the selected account, with each one's state, +accumulated weight against the threshold, and whether it is waiting on *you*. + +**`--create`** — upload one **unsigned** transaction and open a collection. Passing an +already-signed transaction is refused (`invalid_value`), as is an expired one (`tx_expired`) or one +whose permission does not include the selected account (`not_authorized`). Requires exactly one of +`--hex` / `--file`, which are valid only with `--create`. + +**`--sign `** — fetch the transaction with the signatures gathered so far, verify it locally, +sign it with your key, and submit the result back. Fails with `already_signed` if this account has +already contributed, `tx_expired` if it has lapsed, or `not_authorized` if the account is not one +of its signers. Once the threshold is reached, the output tells you the broadcast command. + +**`--watch`** — hold a WebSocket open and report when transactions are awaiting your signature. +By design it receives **only a count**, never transaction content, so watching leaks nothing about +what is queued. Runs until interrupted (Ctrl-C, SIGINT/SIGTERM), then reports how many +notifications arrived. + +## Configuration + +Requires TronLink service credentials in [`config`](../config.md): + +```bash +wallet-cli config tronlinkSecretId +wallet-cli config tronlinkSecretKey +wallet-cli config tronlinkChannel +``` + +`tronlinkSecretKey` is masked on read (`********`). The service endpoint comes from the network +descriptor — `api.walletadapter.org` for mainnet, and the Nile and Shasta equivalents. + +## Options + +| Option | Description | +|---|---| +| `--create` | Upload one unsigned transaction and open a signature collection | +| `--hex ` | Unsigned `protocol.Transaction` hex — only with `--create` | +| `--file ` | File containing the unsigned transaction hex — only with `--create` | +| `--sign ` | Fetch and co-sign one pending transaction by 32-byte hex txId | +| `--watch` | Keep a WebSocket open and report the count of transactions awaiting this account | +| `--password-stdin` | Master password from stdin (software accounts) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Open a collection from an unsigned transaction: + +```bash +wallet-cli tx multisig --create --file tx.unsigned.hex --network tron:nile +``` + +```console +✅ Created on TronLink multi-sig service + TxID 9c1f… + +Transaction + TxID 9c1f… + Type Transfer TRX (TransferContract) — 1 TRX + Permission active "operations" (id 2) threshold 2 + Expires 2026-07-29 12:34:56 (in 58 minutes) + +Progress 0 / 2 — 2 more weight needed +No approved signers. +! Each signer signs it with: wallet-cli tx multisig --sign 9c1f… +``` + +See what is waiting on you: + +```bash +wallet-cli tx multisig --network tron:nile +``` + +```console +Multi-sig transactions — TronLink service (1 total) +| TxID | Type | Amount | State | Progress | Expires | +| ----- | ----------------- | ------ | ------------ | -------- | ----------------------------- | +| 9c1f… | TransferContract | 1 TRX | awaiting you | 1 / 2 | 2026-07-29 12:34:56 (in 58m) | +! Co-sign one with: wallet-cli tx multisig --sign +``` + +Co-sign it: + +```bash +echo "$PW" | wallet-cli tx multisig --sign 9c1f… --network tron:nile --password-stdin +``` + +When your signature completes the threshold, the receipt ends with the broadcast command: + +```console +! Broadcast it: wallet-cli tx broadcast --hex 0a02… +``` + +Watch for work, count only: + +```bash +wallet-cli tx multisig --watch --network tron:nile +``` + +```console +Watching TronLink multi-sig service for tron:nile … (Ctrl-C to stop) +🔔 You have 1 transaction(s) to sign — view them with: wallet-cli tx multisig +``` + +## Output + +`data` is discriminated — by `transactions` for the list, and by `action` otherwise. + +**list** + +| Field | Type | Meaning | +|---|---|---| +| `address` | string | Account the queue was read for | +| `total` | number | Number of transactions | +| `transactions[].txId` | string | Transaction id | +| `transactions[].state` | string | `pending` \| `signed` \| `success` \| `failed` | +| `transactions[].contractType` | string | TRON contract type | +| `transactions[].originator` / `owner` | string | Who created it / whose account it acts on | +| `transactions[].permission` | object | `id`, `name`, `threshold` | +| `transactions[].currentWeight` / `missingWeight` / `thresholdReached` | — | Approval progress | +| `transactions[].awaitingMySignature` | boolean | Whether it is waiting on the selected account | +| `transactions[].signedByCurrentAccount` | boolean | Whether this account already signed | +| `transactions[].signatureProgress[]` | array | Per signer: `address`, `weight`, `signed`, `signedAt` | +| `transactions[].createdAt` / `expiration` / `expired` | — | Timing | + +**`--create`** — `action: "create"`, `accepted`, `hex`, and `transaction` (the same approval object +[`tx approvals`](approvals.md) returns). + +**`--sign`** — `action: "sign"`, `accepted`, `signer`, `signerWeight`, `hex`, and `transaction`. + +**`--watch`** — `action: "watch"`, `address`, `notifications`. + +## Exit status + +`0` · `1` execution failure — `not_authorized`, `already_signed`, `tx_expired`, `not_found`, +`provider_error` (any inconsistency in what the service returned), `auth_failed` · `2` usage error — +conflicting mode flags, `--hex`/`--file` without `--create`, `--create` without exactly one of +them, a malformed txId. + +## See also + +[`tx sign`](sign.md) — the offline, file-passing alternative · [`tx approvals`](approvals.md) · +[`tx broadcast`](broadcast.md) · [`permission show`](../permission/show.md) diff --git a/ts/docs/commands/tx/send.md b/ts/docs/commands/tx/send.md index 59a9e466c..0afa7d08d 100644 --- a/ts/docs/commands/tx/send.md +++ b/ts/docs/commands/tx/send.md @@ -5,7 +5,7 @@ Send native TRX or TRC20/TRC10 tokens with human `--amount`. ## Synopsis ``` -wallet-cli tx send --to
(--amount | --raw-amount ) +wallet-cli tx send --to (--amount | --raw-amount ) [--token | --contract
| --asset-id ] [--dry-run | --sign-only] [--fee-limit ] [options] ``` @@ -19,6 +19,10 @@ Builds, signs, and submits a transfer from the active account (or `--account`). - `--contract
` → TRC20 by contract address; - `--asset-id ` → TRC10 by numeric asset id. +`--to` accepts a TRON address or the name of a local [contact](../contact/index.md). When a contact +name resolves, the receipt shows both — `To alice (TR7NHq…)` — and `data.toContact` carries the +name, so the resolution stays auditable rather than implicit. + Amounts: `--amount` is human units (TRX, or token units respecting the token's decimals); `--raw-amount` is the raw integer (SUN or token base units). Exactly one of the two. Two early exits: `--dry-run` builds and estimates only — no signature, no broadcast, nothing leaves your machine; `--sign-only` signs and prints the transaction for a later [`tx broadcast`](broadcast.md). @@ -31,7 +35,7 @@ Requires an account and the master password via `--password-stdin` — signing c | Option | Description | |---|---| -| `--to ` | **Required.** Recipient TRON base58 address | +| `--to ` | **Required.** Recipient TRON base58 address, or a local [contact](../contact/index.md) name | | `--amount ` | Human amount; mutually exclusive with `--raw-amount` | | `--raw-amount ` | Raw integer amount in SUN / token base units | | `--token ` | Token symbol from the address book; excludes `--contract`, `--asset-id` | @@ -85,7 +89,7 @@ printf '%s' "$PW" | wallet-cli tx send --to TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH - | Mode | Fields | |---|---| -| default (submit) | `kind: "send"`, `stage: "submitted"`, `txId`, `rawAmount` (string), `to` | +| default (submit) | `kind: "send"`, `stage: "submitted"`, `txId`, `rawAmount` (string), `to`, plus `toContact` when `--to` was a contact name | | `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `netUsed` (bandwidth used) or `feeSun` (fee burned), `failed` | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee` (`feeModel`, e.g. `bandwidthBurnSunIfNoFreeze`), unsigned `tx` (TRON tx object incl. `txID`, `raw_data`), `rawAmount`, `to` | | `--sign-only` | `kind`, `mode: "sign-only"`, `signed` (full signed TRON tx incl. `signature[]` — feed to `tx broadcast`), `address` (signer), `txId`, `fee`, `rawAmount`, `to` | diff --git a/ts/package-lock.json b/ts/package-lock.json index e4c652524..16a9c2092 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "4.10.1", + "version": "4.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tron-walletcli/wallet-cli", - "version": "4.10.1", + "version": "4.11.0", "license": "LGPL-3.0-or-later", "dependencies": { "@ledgerhq/hw-app-trx": "^6.36.3", diff --git a/ts/package.json b/ts/package.json index 85e2c2b62..d92364000 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "4.10.1", + "version": "4.11.0", "description": "Agent-first TypeScript CLI wallet for TRON — deterministic commands, JSON output, and discoverable schemas", "type": "module", "bin": { diff --git a/ts/skills/wallet-cli/SKILL.md b/ts/skills/wallet-cli/SKILL.md index f3f73f0a6..4d27a0fbd 100644 --- a/ts/skills/wallet-cli/SKILL.md +++ b/ts/skills/wallet-cli/SKILL.md @@ -25,17 +25,33 @@ create --label new HD wallet (BIP39) import mnemonic|private-key|ledger|watch bring in existing accounts list / use / current enumerate & select active account derive / rename / backup / delete account lifecycle (backup: secret, file mode 0600) +address generate keypair made locally; NOT added to the wallet account balance|info|history|portfolio on-chain state (history needs TronGrid) -tx send --to --amount TRX; add --token SYM | --contract Txx | --asset-id N for tokens - [--dry-run | --sign-only] estimate only / sign without broadcast -tx broadcast --tx-stdin broadcast a presigned tx +account activate --address activate a new account (payer = active account) +account set --name | --id one-time on-chain name/ID — effectively immutable +tx send --to --amount TRX; add --token SYM | --contract Txx | --asset-id N for tokens + [--dry-run|--sign-only|--build-only] estimate only / sign / build unsigned, no broadcast +tx sign --transaction sign JSON built elsewhere +tx sign --file [--check] [--out] append a signature to a multi-sig artifact (--check verifies online) +tx approvals --file who signed, weight so far, missing weight, expiry +tx multisig [--create|--sign |--watch] collect signatures via the TronLink service +tx broadcast --tx-stdin|--hex|--file broadcast a presigned tx; --dry-run validates only tx status --txid state: confirmed|failed|pending|not_found tx info --txid full detail + receipt +permission show owner/witness/active groups, thresholds, decoded ops +permission update --file replace the WHOLE permission structure (lockout risk) +gasfree info|transfer|trace send tokens with no TRX; fee paid in the token stake freeze|unfreeze|withdraw|cancel-unfreeze|delegate|undelegate resource staking token / contract / message / block address book, smart contracts, signing, blocks +contact add|list|remove recipient names usable as --to +encoding convert base58 / 41-hex / 0x-EVM / hex / base64 conversions config / networks local config, known networks ``` +Multi-sig: build unsigned (`--build-only`) → each signer `tx sign --file … --out` → check with +`tx approvals` → `tx broadcast` once `thresholdReached` is true. `tx broadcast` refuses a +transaction below its threshold, so branch on `data.thresholdReached`, never on exit code alone. + Details for any command: `wallet-cli --help`. ## Transaction safety (mandatory) @@ -46,7 +62,11 @@ Details for any command: `wallet-cli --help`. ## Dangerous commands — require explicit user confirmation -`tx send` / `tx broadcast` / `contract send|deploy` on `tron:mainnet` (moves funds) · `delete` (removes accounts; HD delete cascades from the seed root) · `backup` (writes secret material to disk). +`tx send` / `tx broadcast` / `contract send|deploy` / `gasfree transfer` on `tron:mainnet` (moves funds) · `delete` (removes accounts; HD delete cascades from the seed root) · `backup` (writes secret material to disk) · `address generate --print-secret` (writes a private key to stdout). + +**`permission update` is the most dangerous command in the CLI** — it replaces the account's entire permission structure, and a structure whose owner group excludes your keys locks the account permanently, with no recovery. Never run it without `--dry-run` first and explicit user confirmation of the rendered structure. Heed `owner_lockout` / `owner_lockout_partial` warnings. + +`account activate` and `account set` are one-shot: an account activates once, and the on-chain name and ID can each be set once. ## Error handling diff --git a/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts b/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts new file mode 100644 index 000000000..cce4223dd --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { TronRpcClient } from "./tron.js"; + +const CONTRACT = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"; + +const word = (hex: string) => hex.padStart(64, "0"); +/** ABI encoding of a dynamic `string` return: offset, length, then padded utf8 bytes. */ +const dynamicString = (text: string) => { + const bytes = Buffer.from(text, "utf8").toString("hex"); + return word("20") + word((bytes.length / 2).toString(16)) + + bytes.padEnd(Math.ceil(bytes.length / 64) * 64, "0"); +}; +/** Legacy `bytes32` return: right-padded utf8 in a single word. */ +const bytes32 = (text: string) => + Buffer.from(text, "utf8").toString("hex").padEnd(64, "0"); + +const stub = (responses: Record) => { + const client = new TronRpcClient("http://localhost:1", 200); + client.tronweb.transactionBuilder.triggerConstantContract = (( + _contract: string, fn: string, + ) => { + const hex = responses[fn]; + if (hex === undefined) return Promise.resolve({ result: { result: false, message: "" } }); + return Promise.resolve({ result: { result: true }, constant_result: [hex] }); + }) as never; + return client; +}; + +describe("TronRpcClient.getTokenInfo", () => { + it("decodes metadata from view calls without relying on a published ABI", async () => { + const client = stub({ + "name()": dynamicString("Usdd Stablecoin"), + "symbol()": dynamicString("USDD"), + "decimals()": word("12"), + "totalSupply()": word("de0b6b3a7640000"), + }); + + await expect(client.getTokenInfo(CONTRACT)).resolves.toEqual({ + contract: CONTRACT, + name: "Usdd Stablecoin", + symbol: "USDD", + decimals: 18, + totalSupply: "1000000000000000000", + }); + }); + + it("decodes legacy bytes32 name/symbol returns", async () => { + const client = stub({ + "name()": bytes32("Legacy Token"), + "symbol()": bytes32("LEG"), + "decimals()": word("6"), + "totalSupply()": word("64"), + }); + + await expect(client.getTokenInfo(CONTRACT)).resolves.toMatchObject({ + name: "Legacy Token", + symbol: "LEG", + decimals: 6, + totalSupply: "100", + }); + }); + + it("leaves fields undefined when their view call reverts", async () => { + const client = stub({ "symbol()": dynamicString("USDT") }); + + await expect(client.getTokenInfo(CONTRACT)).resolves.toEqual({ + contract: CONTRACT, + name: undefined, + symbol: "USDT", + decimals: undefined, + totalSupply: undefined, + }); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 30ee537b7..f16ecb718 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -436,19 +436,20 @@ export class TronRpcClient implements TronGateway, Broadcaster { async getTokenInfo(contract: string): Promise { return this.#wrap("trc20 tokenInfo", async () => { - const c = await this.#tw.contract().at(contract); + // Read the view methods by selector rather than via contract().at(): tokens deployed without a + // published ABI (e.g. USDD on Nile) resolve to a contract object with no methods at all. + const read = async (fn: string): Promise => + this.#constant(contract, fn, []).then(([hex]) => hex).catch(() => undefined); const [name, symbol, decimals, totalSupply] = await Promise.all([ - c.name().call().catch(() => undefined), - c.symbol().call().catch(() => undefined), - c.decimals().call().catch(() => undefined), - c.totalSupply().call().catch(() => undefined), + read("name()"), read("symbol()"), read("decimals()"), read("totalSupply()"), ]); + const scale = decodeAbiUint(decimals); return { contract, - name: name?.toString?.() ?? name, - symbol: symbol?.toString?.() ?? symbol, - decimals: decimals !== undefined ? Number(decimals) : undefined, - totalSupply: totalSupply !== undefined ? BigInt(totalSupply.toString()).toString() : undefined, + name: decodeAbiString(name), + symbol: decodeAbiString(symbol), + decimals: scale !== undefined && scale <= 255n ? Number(scale) : undefined, + totalSupply: decodeAbiUint(totalSupply)?.toString(), }; }); } @@ -912,6 +913,25 @@ export interface FeeEstimate extends Record { } /** TRC20 metadata read via the contract's view methods; each field absent when the call reverts. */ +/** Decode an ABI-encoded return word as an unsigned integer. */ +function decodeAbiUint(hex?: string): bigint | undefined { + if (!hex || !/^[0-9a-fA-F]+$/.test(hex)) return undefined; + return BigInt("0x" + hex); +} + +/** Decode an ABI-encoded return value as text; handles both dynamic `string` and legacy `bytes32`. */ +function decodeAbiString(hex?: string): string | undefined { + if (!hex || !/^[0-9a-fA-F]+$/.test(hex)) return undefined; + const buf = Buffer.from(hex, "hex"); + if (buf.length === 32) return buf.toString("utf8").replace(/\0[\s\S]*$/, "") || undefined; + if (buf.length < 64) return undefined; + const offset = Number(BigInt("0x" + buf.subarray(0, 32).toString("hex"))); + if (!Number.isSafeInteger(offset) || offset + 32 > buf.length) return undefined; + const length = Number(BigInt("0x" + buf.subarray(offset, offset + 32).toString("hex"))); + if (!Number.isSafeInteger(length) || offset + 32 + length > buf.length) return undefined; + return buf.subarray(offset + 32, offset + 32 + length).toString("utf8") || undefined; +} + /** tronweb error messages are often hex-encoded ASCII; decode best-effort. */ function decodeTronMessage(message?: string): string { if (!message) return ""; diff --git a/ts/src/adapters/outbound/tokenbook/builtins.ts b/ts/src/adapters/outbound/tokenbook/builtins.ts index 792c3c1d3..9919e88dd 100644 --- a/ts/src/adapters/outbound/tokenbook/builtins.ts +++ b/ts/src/adapters/outbound/tokenbook/builtins.ts @@ -9,7 +9,11 @@ export const OFFICIAL_TOKENS: Record = { "tron:mainnet": [ { kind: "trc20", id: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", symbol: "USDT", decimals: 6, name: "Tether USD" }, { kind: "trc20", id: "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", symbol: "USDC", decimals: 6, name: "USD Coin" }, + { kind: "trc20", id: "TXDk8mbtRbXeYuMNS83CfKPaYYT8XWv9Hz", symbol: "USDD", decimals: 18, name: "Usdd Stablecoin" }, + ], + "tron:nile": [ + { kind: "trc20", id: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", symbol: "USDT", decimals: 6, name: "Tether USD" }, + { kind: "trc20", id: "TYQF9cAeJ3Faq8QXpHxTcFco72DRCQbgFt", symbol: "USDD", decimals: 18, name: "Usdd Stablecoin" }, ], - "tron:nile": [], "tron:shasta": [], }; diff --git a/ts/src/adapters/outbound/tokenbook/tokenbook.test.ts b/ts/src/adapters/outbound/tokenbook/tokenbook.test.ts index 9e4389c6f..422edc3a6 100644 --- a/ts/src/adapters/outbound/tokenbook/tokenbook.test.ts +++ b/ts/src/adapters/outbound/tokenbook/tokenbook.test.ts @@ -7,6 +7,10 @@ import { AtomicFileStore } from "../persistence/fs/index.js"; import type { TokenEntry } from "../../../domain/types/index.js"; const USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; // official mainnet +const NILE_OFFICIAL: TokenEntry[] = [ + { kind: "trc20", id: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", symbol: "USDT", decimals: 6, name: "Tether USD" }, + { kind: "trc20", id: "TYQF9cAeJ3Faq8QXpHxTcFco72DRCQbgFt", symbol: "USDD", decimals: 18, name: "Usdd Stablecoin" }, +]; const REF = "wlt_abc.0"; const customToken: TokenEntry = { kind: "trc20", id: "TCustomContractXXXXXXXXXXXXXXXXXXXX", symbol: "CUS", decimals: 8, name: "Custom" }; @@ -30,18 +34,23 @@ describe("TokenBook", () => { tb = freshBook(); }); - it("official layer ships USDT/USDC on mainnet, empty on nile", () => { - expect(tb.official("tron:mainnet").map((t) => t.symbol)).toEqual(["USDT", "USDC"]); - expect(tb.official("tron:nile")).toEqual([]); + it("official layer ships stablecoins on mainnet/nile, empty on shasta", () => { + expect(tb.official("tron:mainnet").map((t) => t.symbol)).toEqual(["USDT", "USDC", "USDD"]); + expect(tb.official("tron:nile")).toEqual(NILE_OFFICIAL); + expect(tb.official("tron:shasta")).toEqual([]); }); it("add() appends to the user layer; effective() unions official-first + tags source", () => { expect(tb.add("tron:nile", REF, customToken)).toBe("added"); const eff = tb.effective("tron:nile", REF); - expect(eff).toEqual([{ ...customToken, source: "user" }]); + expect(eff).toEqual([ + ...NILE_OFFICIAL.map((t) => ({ ...t, source: "official" })), + { ...customToken, source: "user" }, + ]); const onMainnet = tb.effective("tron:mainnet", REF); - expect(onMainnet.map((t) => `${t.source}:${t.symbol}`)).toEqual(["official:USDT", "official:USDC"]); + expect(onMainnet.map((t) => `${t.source}:${t.symbol}`)) + .toEqual(["official:USDT", "official:USDC", "official:USDD"]); }); it("add() of an official token → token_already_listed", () => { @@ -52,7 +61,7 @@ describe("TokenBook", () => { tb.add("tron:nile", REF, customToken); const action = tb.add("tron:nile", REF, { ...customToken, symbol: "NEW", decimals: 2 }); expect(action).toBe("refreshed"); - const eff = tb.effective("tron:nile", REF); + const eff = tb.effective("tron:nile", REF).filter((t) => t.source === "user"); expect(eff).toHaveLength(1); expect(eff[0]).toMatchObject({ symbol: "NEW", decimals: 2, source: "user" }); }); diff --git a/ts/src/application/use-cases/tron/permission-service.test.ts b/ts/src/application/use-cases/tron/permission-service.test.ts index b39c40ce0..4acb2a41a 100644 --- a/ts/src/application/use-cases/tron/permission-service.test.ts +++ b/ts/src/application/use-cases/tron/permission-service.test.ts @@ -107,6 +107,27 @@ describe("TRON permission service", () => { }); }); + // The validator preserving unnamed bits is worthless if the builder re-derives the bitmap from + // the named operations — that is where the original round-trip loss happened. + it("hands the builder the full bitmap, unnamed bits included, and warns about them", async () => { + const { service, gateway } = setup(); + const requested = permissions(); + requested.actives[0]!.operationsHex = "82" + "00".repeat(31); + requested.actives[0]!.unknownOperationIds = [7]; + const ctx = scope(); + await service.update(ctx, NETWORK, { dryRun: true }, requested); + expect(gateway.buildAccountPermissionUpdate).toHaveBeenCalledWith( + A, + expect.objectContaining({ + actives: [expect.objectContaining({ + operationsHex: "82" + "00".repeat(31), + unknownOperationIds: [7], + })], + }), + ); + expect(ctx.warn).toHaveBeenCalledWith(expect.objectContaining({ code: "active_unknown_operations" })); + }); + it("allows an offline build even when balance is currently low", async () => { const { service, pipeline } = setup("0"); await expect(service.update(scope(), NETWORK, { buildOnly: true }, permissions())).resolves.toBeDefined(); diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index 5caf0978f..479971fae 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 = "4.10.1" +export const VERSION = "4.11.0" /** Execute one CLI invocation. Dependency construction is delegated to the composition root. */ export async function main(argv: string[]): Promise { diff --git a/ts/src/domain/permission/index.ts b/ts/src/domain/permission/index.ts index ce32d425d..1ffb35d3b 100644 --- a/ts/src/domain/permission/index.ts +++ b/ts/src/domain/permission/index.ts @@ -26,6 +26,7 @@ export const TRON_OPERATIONS: readonly TronOperation[] = Object.freeze([ { contractTypeId: 0, contractType: "AccountCreateContract", label: "Activate Account" }, { contractTypeId: 1, contractType: "TransferContract", label: "Transfer TRX" }, { contractTypeId: 2, contractType: "TransferAssetContract", label: "Transfer TRC10" }, + { contractTypeId: 3, contractType: "VoteAssetContract", label: "Vote for TRC10 [legacy]" }, { contractTypeId: 4, contractType: "VoteWitnessContract", label: "Vote" }, { contractTypeId: 5, contractType: "WitnessCreateContract", label: "Apply to Become a SR Candidate" }, { contractTypeId: 6, contractType: "AssetIssueContract", label: "Issue TRC10" }, @@ -41,8 +42,10 @@ export const TRON_OPERATIONS: readonly TronOperation[] = Object.freeze([ { contractTypeId: 17, contractType: "ProposalApproveContract", label: "Approve Proposal" }, { contractTypeId: 18, contractType: "ProposalDeleteContract", label: "Cancel Proposal" }, { contractTypeId: 19, contractType: "SetAccountIdContract", label: "Set Account Id" }, + { contractTypeId: 20, contractType: "CustomContract", label: "Custom Contract [legacy]" }, { contractTypeId: 30, contractType: "CreateSmartContract", label: "Create Smart Contract" }, { contractTypeId: 31, contractType: "TriggerSmartContract", label: "Trigger Smart Contract" }, + { contractTypeId: 32, contractType: "GetContract", label: "Get Contract [legacy]" }, { contractTypeId: 33, contractType: "UpdateSettingContract", label: "Update Contract Parameters" }, { contractTypeId: 41, contractType: "ExchangeCreateContract", label: "Create Bancor Transaction" }, { contractTypeId: 42, contractType: "ExchangeInjectContract", label: "Inject Assets into Bancor Transaction" }, @@ -52,6 +55,7 @@ export const TRON_OPERATIONS: readonly TronOperation[] = Object.freeze([ { contractTypeId: 46, contractType: "AccountPermissionUpdateContract", label: "Update Account Permissions" }, { contractTypeId: 48, contractType: "ClearABIContract", label: "Clear Contract ABI" }, { contractTypeId: 49, contractType: "UpdateBrokerageContract", label: "Update SR Commission Ratio" }, + { contractTypeId: 51, contractType: "ShieldedTransferContract", label: "Shielded Transfer" }, { contractTypeId: 52, contractType: "MarketSellAssetContract", label: "Market Sell Asset" }, { contractTypeId: 53, contractType: "MarketCancelOrderContract", label: "Market Cancel Order" }, { contractTypeId: 54, contractType: "FreezeBalanceV2Contract", label: "TRX Stake (2.0)" }, @@ -205,6 +209,30 @@ export function encodeOperations(contractTypes: readonly string[]): string { return bytes.toString("hex"); } +/** Require unnamed bitmap bits to be declared verbatim, so no set bit escapes human review. */ +function assertDeclaredUnknownOperations(value: unknown, actual: readonly number[], index: number): void { + const field = `actives[${index}].unknownOperationIds`; + if (value === undefined) { + if (actual.length === 0) return; + return invalidPermission( + `${field} must declare the unnamed contract types set in operationsHex: ${actual.join(", ")}`, + ); + } + if (!Array.isArray(value) || value.some((id) => typeof id !== "number" || !Number.isSafeInteger(id))) { + return invalidPermission(`${field} must be an array of integers`); + } + const declaredIds = value as number[]; + if (new Set(declaredIds).size !== declaredIds.length) { + return invalidPermission(`${field} must not contain duplicate contract type ids`); + } + const declared = [...declaredIds].sort((a, b) => a - b); + if (declared.length !== actual.length || declared.some((id, position) => id !== actual[position])) { + return invalidPermission( + `${field} does not match operationsHex: declared ${declared.join(", ") || "none"}, bitmap sets ${actual.join(", ") || "none"}`, + ); + } +} + function activeGroup(value: unknown, index: number): ActivePermissionView { const input = ownRecord(value, `actives[${index}]`); if (typeof input.id !== "number" || !Number.isSafeInteger(input.id) || input.id < 2 || input.id > 9) { @@ -213,7 +241,12 @@ function activeGroup(value: unknown, index: number): ActivePermissionView { if (!Array.isArray(input.operations)) { return invalidPermission(`actives[${index}].operations must be an array`); } - const encodedKnownOperations = encodeOperations(input.operations as string[]); + const declaredOperations = input.operations as string[]; + // A node may set bits for contract types this build has no name for. Those bits are real + // permission scope, so `operations` alone cannot describe the bitmap — hence the empty-list case. + const encodedKnownOperations = declaredOperations.length === 0 + ? "00".repeat(OPERATIONS_BYTES) + : encodeOperations(declaredOperations); let operationsHex = encodedKnownOperations; if (input.operationsHex !== undefined) { if (typeof input.operationsHex !== "string") { @@ -227,9 +260,20 @@ function activeGroup(value: unknown, index: number): ActivePermissionView { ) { return invalidPermission(`actives[${index}].operationsHex does not match operations`); } + // Every set bit must be declared somewhere a reviewer can read: named types in `operations`, + // unnamed ones in `unknownOperationIds`. Without this, a bitmap can widen the permission + // while the human-readable fields stay unchanged — the failure this whole field pair prevents. + assertDeclaredUnknownOperations(input.unknownOperationIds, supplied.unknownOperationIds, index); operationsHex = supplied.operationsHex; + } else if (input.unknownOperationIds !== undefined) { + return invalidPermission( + `actives[${index}].unknownOperationIds requires operationsHex — operations cannot express unnamed contract types`, + ); } const decoded = decodeOperations(operationsHex); + if (decoded.operations.length === 0 && decoded.unknownOperationIds.length === 0) { + return invalidPermission("active.operations must contain at least one contract type"); + } const parsedKeys = keys(input.keys, `actives[${index}].keys`); const threshold = safePositiveInteger(input.threshold, `actives[${index}].threshold`); const total = parsedKeys.reduce((sum, key) => sum + BigInt(key.weight), 0n); @@ -332,6 +376,12 @@ export function permissionSafetyWarnings( message: `active permission ${active.name} (id ${active.id}) can replace the account permission structure`, }); } + if (active.unknownOperationIds.length > 0) { + warnings.push({ + code: "active_unknown_operations", + message: `active permission ${active.name} (id ${active.id}) grants contract types this build cannot name: ${active.unknownOperationIds.join(", ")}; their bitmap scope is preserved`, + }); + } } return warnings; } diff --git a/ts/src/domain/permission/permission.test.ts b/ts/src/domain/permission/permission.test.ts index 126eadb57..84c9333f7 100644 --- a/ts/src/domain/permission/permission.test.ts +++ b/ts/src/domain/permission/permission.test.ts @@ -28,6 +28,7 @@ function structure() { threshold: 1, operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], operationsHex: "0600008000000000000000000000000000000000000000000000000000000000", + unknownOperationIds: [] as number[], keys: [{ address: A, weight: 1 }], }], }; @@ -44,10 +45,24 @@ describe("TRON permission operations", () => { }); }); - it("preserves unknown set bits when reading node state", () => { - const decoded = decodeOperations("08" + "00".repeat(31)); + it("names every operation enabled by the protocol default bitmap", () => { + const decoded = decodeOperations("7fff1fc0033ef30f" + "00".repeat(24)); + expect(decoded.operations).toContain("VoteAssetContract"); + expect(decoded.operations).toContain("CustomContract"); + expect(decoded.operations).toContain("GetContract"); + expect(decoded.unknownOperationIds).toEqual([]); + }); + + it("recognizes shielded transfer even though the current default bitmap excludes it", () => { + const decoded = decodeOperations("00".repeat(6) + "08" + "00".repeat(25)); + expect(decoded.operations).toEqual(["ShieldedTransferContract"]); + expect(decoded.unknownOperationIds).toEqual([]); + }); + + it("preserves genuinely unknown set bits when reading node state", () => { + const decoded = decodeOperations("80" + "00".repeat(31)); expect(decoded.operations).toEqual([]); - expect(decoded.unknownOperationIds).toEqual([3]); + expect(decoded.unknownOperationIds).toEqual([7]); }); it("rejects unknown contract names and malformed bitmaps", () => { @@ -66,15 +81,82 @@ describe("permission replacement validation", () => { it("round-trips unknown operation bits when the known operation list matches", () => { const input = structure(); - input.actives[0]!.operationsHex = "0e000080" + "00".repeat(28); + input.actives[0]!.operationsHex = "86000080" + "00".repeat(28); + input.actives[0]!.unknownOperationIds = [7]; const parsed = validatePermissionStructure(input); expect(parsed.actives[0]).toMatchObject({ operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], operationsHex: input.actives[0]!.operationsHex, - unknownOperationIds: [3], + unknownOperationIds: [7], }); }); + // The bitmap is what the chain enforces; `operations` and `unknownOperationIds` are what a human + // reviews. A bit set in the bitmap but absent from both lists would widen the permission with + // nothing in the file to show for it — exactly how the original round-trip bug hid its damage. + it("rejects unnamed operation bits that the file does not declare", () => { + const omitted = structure(); + omitted.actives[0]!.operationsHex = "86000080" + "00".repeat(28); + delete (omitted.actives[0] as Record).unknownOperationIds; + expect(() => validatePermissionStructure(omitted)).toThrowError(/must declare the unnamed contract types.*7/); + + const emptied = structure(); + emptied.actives[0]!.operationsHex = "86000080" + "00".repeat(28); + expect(() => validatePermissionStructure(emptied)).toThrowError(/declared none, bitmap sets 7/); + }); + + it("rejects unknownOperationIds that disagree with the bitmap", () => { + const understated = structure(); + understated.actives[0]!.operationsHex = "86000088" + "00".repeat(28); + understated.actives[0]!.unknownOperationIds = [7]; + expect(() => validatePermissionStructure(understated)).toThrowError(/does not match operationsHex/); + + const overstated = structure(); + overstated.actives[0]!.unknownOperationIds = [7]; + expect(() => validatePermissionStructure(overstated)).toThrowError(/does not match operationsHex/); + }); + + it("rejects duplicate unknownOperationIds", () => { + const input = structure(); + input.actives[0]!.operationsHex = "86000080" + "00".repeat(28); + input.actives[0]!.unknownOperationIds = [7, 7]; + expect(() => validatePermissionStructure(input)).toThrowError(/must not contain duplicate/); + }); + + it("rejects unknownOperationIds without a bitmap to justify them", () => { + const input = structure(); + delete (input.actives[0] as Record).operationsHex; + input.actives[0]!.unknownOperationIds = [7]; + expect(() => validatePermissionStructure(input)).toThrowError(/requires operationsHex/); + + const malformed = structure(); + delete (malformed.actives[0] as Record).operationsHex; + (malformed.actives[0] as Record).unknownOperationIds = "3"; + expect(() => validatePermissionStructure(malformed)).toThrowError(/requires operationsHex/); + }); + + // A node permission may set only bits this build cannot name; `permission show` then emits + // operations: []. Rejecting that would leave such an account unable to restore its own backup. + it("accepts an active whose bitmap holds only unnamed contract types", () => { + const input = structure(); + input.actives[0]!.operations = []; + input.actives[0]!.operationsHex = "80" + "00".repeat(31); + input.actives[0]!.unknownOperationIds = [7]; + const parsed = validatePermissionStructure(input); + expect(parsed.actives[0]).toMatchObject({ + operations: [], + operationsHex: "80" + "00".repeat(31), + unknownOperationIds: [7], + }); + }); + + it("still rejects an active that authorizes nothing at all", () => { + const input = structure(); + input.actives[0]!.operations = []; + input.actives[0]!.operationsHex = "00".repeat(32); + expect(() => validatePermissionStructure(input)).toThrowError(/at least one contract type/); + }); + it("rejects unsafe thresholds, duplicate ids/addresses, unknown operations, and control names", () => { const high = structure(); high.owner.threshold = 3; @@ -149,4 +231,18 @@ describe("local key inventory and lockout warnings", () => { "active_can_update_permission", ]); }); + + // Preserving bits we cannot name is the safe default, but it is still scope the user cannot see + // in the operation list — --dry-run has to say so rather than render an empty warnings array. + it("warns that preserved unnamed operations are scope the reader cannot inspect", () => { + const input = structure(); + input.actives[0]!.operationsHex = "86000080" + "00".repeat(28); + input.actives[0]!.unknownOperationIds = [7]; + const inventory = new Map([[A, "main"]]); + const warnings = permissionSafetyWarnings(validatePermissionStructure(input), inventory); + expect(warnings).toContainEqual(expect.objectContaining({ + code: "active_unknown_operations", + message: expect.stringContaining("7"), + })); + }); }); diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 82bf64154..03870d284 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("4.10.1") + expect(r.stdout.trim()).toBe("4.11.0") }) it("root --help shows the TRON first-release command surface", () => { @@ -529,7 +529,7 @@ describe("golden CLI — token address-book (local, no RPC)", () => { const r = run(["--output", "json", "token", "list"]) expect(r.status).toBe(0) expect(r.json.data.network).toBe("tron:mainnet") - expect(r.json.data.tokens.map((t: { symbol: string }) => t.symbol)).toEqual(["USDT", "USDC"]) + expect(r.json.data.tokens.map((t: { symbol: string }) => t.symbol)).toEqual(["USDT", "USDC", "USDD"]) expect(r.json.data.tokens.every((t: { source: string }) => t.source === "official")).toBe(true) }) @@ -541,13 +541,12 @@ describe("golden CLI — token address-book (local, no RPC)", () => { expect(r.json.chain.network).toBe("tron:mainnet") }) - it("token list shows a user-added token tagged user (nile, empty official layer)", () => { + it("token list lists nile's official tokens first, then a user-added token tagged user", () => { const ref = seedWallet() seedToken("tron:nile", ref, CUSTOM) const r = run(["--output", "json", "token", "list", "--network", "tron:nile"]) expect(r.status).toBe(0) - expect(r.json.data.tokens).toHaveLength(1) - expect(r.json.data.tokens[0]).toMatchObject({ symbol: "CUS", source: "user" }) + expect(r.json.data.tokens.map((t: { source: string; symbol: string }) => `${t.source}:${t.symbol}`)).toEqual(["official:USDT", "official:USDD", "user:CUS"]) }) it("token remove of an official token → token_is_official, exit 2", () => { From b972bced56b950de831e2da65231f19fc3a132fa Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 29 Jul 2026 02:46:32 +0800 Subject: [PATCH 14/24] fix(ts): use accurate TRON operation labels --- ts/src/domain/permission/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ts/src/domain/permission/index.ts b/ts/src/domain/permission/index.ts index 1ffb35d3b..d3e6bb931 100644 --- a/ts/src/domain/permission/index.ts +++ b/ts/src/domain/permission/index.ts @@ -26,7 +26,7 @@ export const TRON_OPERATIONS: readonly TronOperation[] = Object.freeze([ { contractTypeId: 0, contractType: "AccountCreateContract", label: "Activate Account" }, { contractTypeId: 1, contractType: "TransferContract", label: "Transfer TRX" }, { contractTypeId: 2, contractType: "TransferAssetContract", label: "Transfer TRC10" }, - { contractTypeId: 3, contractType: "VoteAssetContract", label: "Vote for TRC10 [legacy]" }, + { contractTypeId: 3, contractType: "VoteAssetContract", label: "Vote for TRC10 [unused]" }, { contractTypeId: 4, contractType: "VoteWitnessContract", label: "Vote" }, { contractTypeId: 5, contractType: "WitnessCreateContract", label: "Apply to Become a SR Candidate" }, { contractTypeId: 6, contractType: "AssetIssueContract", label: "Issue TRC10" }, @@ -42,10 +42,10 @@ export const TRON_OPERATIONS: readonly TronOperation[] = Object.freeze([ { contractTypeId: 17, contractType: "ProposalApproveContract", label: "Approve Proposal" }, { contractTypeId: 18, contractType: "ProposalDeleteContract", label: "Cancel Proposal" }, { contractTypeId: 19, contractType: "SetAccountIdContract", label: "Set Account Id" }, - { contractTypeId: 20, contractType: "CustomContract", label: "Custom Contract [legacy]" }, + { contractTypeId: 20, contractType: "CustomContract", label: "Custom Contract" }, { contractTypeId: 30, contractType: "CreateSmartContract", label: "Create Smart Contract" }, { contractTypeId: 31, contractType: "TriggerSmartContract", label: "Trigger Smart Contract" }, - { contractTypeId: 32, contractType: "GetContract", label: "Get Contract [legacy]" }, + { contractTypeId: 32, contractType: "GetContract", label: "Get Contract" }, { contractTypeId: 33, contractType: "UpdateSettingContract", label: "Update Contract Parameters" }, { contractTypeId: 41, contractType: "ExchangeCreateContract", label: "Create Bancor Transaction" }, { contractTypeId: 42, contractType: "ExchangeInjectContract", label: "Inject Assets into Bancor Transaction" }, From 1a6879814c61d2832328aa42e5f9d1ae3b6bd75d Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 3 Aug 2026 03:33:35 +0800 Subject: [PATCH 15/24] fix(ts): correct positional parsing, tx sign defaults, and fee rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Positional arguments: yargs inferred a type for the anonymous group tail, so a number-shaped value (0xdeadbeef, 12345) reached its string-typed field as a number and was rejected — `encoding convert 0x...` could not take the EVM address its own help example shows. Type the tail as strings so the raw token survives parsing (coercing afterwards is lossy: 1e5 would already be 100000), and route single-segment leaves through the same `args..` capture so one binding path serves every positional command. tx sign: replace --check with --offline and verify online by default. A co-signer outside the transaction's permission group, or one signing twice, now fails before any signature is produced, instead of emitting a hex that only `tx broadcast` rejects — possibly after it has been passed to further signers. The default receipt carries the documented permission and weight-progress block; --offline keeps air-gapped signing and says plainly that approval state was not checked. The 4.10.0 --transaction JSON route is untouched. account activate --dry-run: render the derived account-creation fee as its total rather than "[object Object]", and stop an unrecognised fee object from reaching the scalar fallback that stringified it. BREAKING CHANGE: a field exposed as a positional no longer accepts its `--` spelling. This affects `config --key/--value`, `block --number`, `contact add --name/--address`, `contact remove --name`, `encoding convert --input` and `gasfree trace --trace-id`. The positional form is unchanged, and the global --account flag still works on `use`/`rename`/`delete`/`backup`. --- ts/docs/commands/tx/approvals.md | 2 +- ts/docs/commands/tx/sign.md | 20 ++- .../cli/commands/text-formatters.test.ts | 88 ++++++++++ .../inbound/cli/commands/tx.sign.test.ts | 114 +++++++++--- ts/src/adapters/inbound/cli/commands/tx.ts | 26 +-- ts/src/adapters/inbound/cli/render/tx.ts | 8 + ts/src/adapters/inbound/cli/shell/index.ts | 107 ++++++------ .../cli/shell/positional-contract.test.ts | 120 +++++++++++++ .../inbound/cli/shell/shell.chain.test.ts | 76 ++++++++ .../adapters/inbound/cli/shell/shell.test.ts | 162 ++++++++++++++++++ .../use-cases/tron/multisig-service.test.ts | 36 ++++ 11 files changed, 661 insertions(+), 98 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/shell/positional-contract.test.ts diff --git a/ts/docs/commands/tx/approvals.md b/ts/docs/commands/tx/approvals.md index 7376b4e9e..7335d84b8 100644 --- a/ts/docs/commands/tx/approvals.md +++ b/ts/docs/commands/tx/approvals.md @@ -111,5 +111,5 @@ undecodable transaction) · `2` usage error (neither/both of `--hex` / `--file`) ## See also -[`tx sign --check`](sign.md) · [`tx broadcast`](broadcast.md) · +[`tx sign`](sign.md) · [`tx broadcast`](broadcast.md) · [`tx multisig`](multisig.md) · [`permission show`](../permission/show.md) diff --git a/ts/docs/commands/tx/sign.md b/ts/docs/commands/tx/sign.md index 82bc0cc23..278594371 100644 --- a/ts/docs/commands/tx/sign.md +++ b/ts/docs/commands/tx/sign.md @@ -48,11 +48,15 @@ signature rather than replacing what is there. That is what TRON multi-sig requi permitted key signs the same transaction in turn — so a partially signed transaction can be passed from signer to signer and broadcast once the permission threshold is met. -This path is offline by default: it verifies transaction integrity, expiration, and append-only -signature behavior locally, but does not claim that the signer belongs to the selected permission -or that the threshold has been reached. Use `--check` when connected to a node to verify those -facts through `getsignweight` and `getapprovedlist`, or inspect the result later with -`wallet-cli tx approvals`. +By default this path verifies online, through `getsignweight` and `getapprovedlist`, that this +account is a key in the transaction's permission group and has not already signed, and it reports +the resulting approval weight. Both checks run **before** a signature is produced, so a co-signer +who is not permitted — or who is signing twice — fails immediately instead of emitting a hex that +only `tx broadcast` would reject, after it has already been passed along. + +Add `--offline` to sign without contacting a node. Transaction integrity, expiration, and +append-only signature behavior are still verified locally, but permission membership and approval +weight are not — inspect those later with `wallet-cli tx approvals`. The JSON compatibility path also preserves existing signatures. `data.signed.signature` may therefore contain signatures this wallet did not produce. `data.address` always tells you which key @@ -74,7 +78,7 @@ this invocation used, and the text receipt numbers them so you can tell them apa | `--hex ` | Complete protobuf `protocol.Transaction` hex | | `--file ` | Read complete transaction hex from a file | | `--out ` | Atomically write the resulting signed hex to a file | -| `--check` | Verify signer permission and resulting approval weight online | +| `--offline` | Sign locally without contacting a node; skips the signer-permission and approval-weight checks | | `--password-stdin` | Master password from stdin (software accounts) | Plus the [global options](../index.md#global-options-every-command). @@ -117,11 +121,11 @@ echo "$PW" | wallet-cli tx sign --file partially-signed.hex \ wallet-cli tx approvals --file signed.hex ``` -To check permission membership and approval progress during signing: +To sign on a machine with no node access, skipping the online checks: ```bash echo "$PW" | wallet-cli tx sign --file partially-signed.hex \ - --check --out signed.hex --password-stdin + --offline --out signed.hex --password-stdin ``` A transaction whose fields disagree is refused rather than signed: 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 bbc8f7afd..a756aec57 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -217,6 +217,65 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(out).toContain("29,650 energy"); expect(out).not.toContain("covered by staked energy"); }); + // `account activate` estimates its fee from two chain parameters, so its fee object carries + // neither `feeSun` nor `energy`. Every such shape must still render as TRX — and no fee shape, + // known or not, may ever reach the user as a stringified object. + const activateFee = { + feeModel: "tron-resource", + createAccountFeeSun: "100000", + systemContractFeeSun: "1000000", + minimumFeeSun: "1100000", + balanceSun: "1862126000", + }; + const dryRun = (fee: unknown) => TextFormatters.txReceipt({ + kind: "account-activate", mode: "dry-run", + fee, tx: { txID: "cc0a6f68" }, address: "TEF2CvkixrkzwbreCRFCQ7sZGj9AVFAkQq", payer: "TMSgJxtPw29", + } as any) as string; + + it("account activate dry-run: renders the total creation fee, not [object Object]", () => { + const out = dryRun(activateFee); + expect(out).not.toContain("[object Object]"); + // doc §3.4.1 shows the confirmed receipt as `Fee 1.1 TRX`; dry-run must be comparable + expect(out).toContain("1.1 TRX"); + }); + + it.each([ + ["createAccountFeeSun alone", { minimumFeeSun: "100000" }, "0.1 TRX"], + ["a zero fee", { minimumFeeSun: "0" }, "0 TRX"], + // fees use fromBaseUnits (exact decimal, no thousands separators) like every other Fee row + ["a large fee", { minimumFeeSun: "9000000000" }, "9000 TRX"], + ])("account activate dry-run: %s", (_name, fee, expected) => { + expect(dryRun(fee)).toContain(expected); + }); + + it("prefers an explicit feeSun over the derived minimum when both are present", () => { + expect(dryRun({ feeSun: "2000000", minimumFeeSun: "1100000" })).toContain("2 TRX"); + }); + + // The regression net: every fee shape the renderer already understood must keep working, so + // adding a branch cannot silently reorder or shadow an existing one. + it.each([ + ["feeSun", { feeSun: "268000" }, "0.268 TRX"], + ["bandwidth burn", { bandwidthBurnSunIfNoFreeze: "1000000" }, "1 TRX"], + ["a note", { note: "no fee for this operation" }, "no fee for this operation"], + ["a bare sun scalar", "1100000", "1.1 TRX"], + ])("still renders %s", (_name, fee, expected) => { + expect(dryRun(fee)).toContain(expected); + }); + + // The root cause of the activate bug: an unrecognized fee object fell through to a fallback that + // stringified it. Failing honestly is required; leaking "[object Object]" is not acceptable for + // any shape, including ones added later. + it.each([ + ["an unrecognized fee object", { someFutureFeeModel: "42" }], + ["an empty fee object", {}], + ["a nested fee object", { fee: { inner: "1" } }], + ])("never leaks a stringified object for %s", (_name, fee) => { + const out = dryRun(fee); + expect(out).not.toContain("[object Object]"); + expect(out).toContain("unknown"); + }); + it("stake freeze submitted: renders staked amount and resource", () => { const out = TextFormatters.txReceipt({ kind: "stake-freeze", stage: "submitted", txId: "abc", amountSun: "2000000", resource: "energy" }); expect(out).toContain("Staked"); @@ -251,6 +310,35 @@ describe("local multisig formatters", () => { expect(out).toContain("Tsigner"); }); + // Doc §3.2.1: the default `tx sign` receipt is the action block plus the same transaction and + // signature-progress block `tx approvals` prints — permission group name and threshold, a + // Progress line, and the per-signer weight table. The offline receipt cannot carry any of it. + it("prints the documented progress block on the default (checked) sign receipt", () => { + const out = TextFormatters.txSign({ + kind: "tx-sign", + signer: "Tsigner", + checked: true, + signerWeight: 1, + hex: "aabb", + transaction: { + txId: approval.txId, + contractType: approval.contractType, + permissionId: approval.permission.id, + expiration: approval.expiration, + expired: false, + signatures: 1, + }, + approval, + }) as string; + expect(out).toContain("Signature added"); + expect(out).toContain("Tsigner (weight 1)"); // signer weight on the action block + expect(out).toContain('Permission active "operations" (id 2) threshold 2'); + expect(out).toContain("Progress 1 / 2"); + expect(out).toContain("Approved signer"); // weight table header + expect(out).not.toContain("local inspection"); + expect(out).not.toContain("was not checked online"); + }); + it("shows the next broadcast command only after threshold is reached", () => { const transaction = { txId: approval.txId, diff --git a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts index ecfcbb2e7..8bb665305 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts @@ -21,7 +21,19 @@ describe("tx sign spec", () => { expect(txSignSpec.baseFields.safeParse({ transaction: "{}" }).success).toBe(true); expect(txSignSpec.baseFields.safeParse({ hex: "abcd" }).success).toBe(true); expect(txSignSpec.baseFields.safeParse({ file: "tx.hex" }).success).toBe(true); - expect(txSignSpec.baseFields.safeParse({ hex: "abcd", check: true }).success).toBe(true); + expect(txSignSpec.baseFields.safeParse({ hex: "abcd", offline: true }).success).toBe(true); + }); + + // The online permission/approval check is the default (doc §3.2.1); --offline opts out of it. + // --check is gone: it was the inverted spelling and never shipped in a release. + it("exposes --offline and no longer exposes --check", () => { + expect(Object.keys(txSignSpec.baseFields.shape)).toContain("offline"); + expect(Object.keys(txSignSpec.baseFields.shape)).not.toContain("check"); + }); + + it("defaults --offline to false so signing verifies online unless asked not to", () => { + const parsed = txSignSpec.baseFields.parse({ hex: "abcd" }); + expect(parsed.offline).toBe(false); }); // the payload is not a secret, so argv is the only channel — no --tx-stdin on this command. @@ -51,35 +63,81 @@ describe("tx sign binding", () => { .rejects.toMatchObject({ code: "invalid_value" }); }); - it("routes hex signing through the offline signing service and writes --out", async () => { - const signing = { - sign: async () => ({ kind: "tx-sign", hex: "beef", signer: "T1", checked: false, transaction: {} }), - }; - let written: unknown; - const writer = { write: (path: string, hex: string) => { written = { path, hex }; } }; - const result = await txSignTronBinding({} as never, signing as never, {} as never, writer as never) - .run(ctx, net, { hex: "abcd", out: "signed.hex" }); - expect(written).toEqual({ path: "signed.hex", hex: "beef" }); - expect(result).toMatchObject({ out: "signed.hex", hex: "beef" }); - }); - - it("routes --check through the multisig authorization service", async () => { - const signing = { sign: async () => { throw new Error("unexpected offline route"); } }; - const multisig = { - signChecked: async () => ({ - kind: "tx-sign", - hex: "beef", - signer: "T1", - checked: true, - signerWeight: 1, - transaction: {}, - approval: {}, - }), - }; - await expect(txSignTronBinding({} as never, signing as never, multisig as never, {} as never) - .run(ctx, net, { hex: "abcd", check: true })) + const offlineSigner = () => ({ + sign: async () => ({ kind: "tx-sign", hex: "beef", signer: "T1", checked: false, transaction: {} }), + }); + const checkedSigner = () => ({ + signChecked: async () => ({ + kind: "tx-sign", + hex: "beef", + signer: "T1", + checked: true, + signerWeight: 1, + transaction: {}, + approval: {}, + }), + }); + const rejectOffline = { sign: async () => { throw new Error("unexpected offline route"); } }; + const rejectChecked = { signChecked: async () => { throw new Error("unexpected checked route"); } }; + + // Default: verify signer permission and resulting weight online (doc §3.2.1). A co-signer who is + // not in the permission group, or who already signed, must fail before a signature is produced — + // not silently emit a hex that only `tx broadcast` will reject, after it has been passed on. + it("routes hex signing through the multisig authorization service by default", async () => { + await expect(txSignTronBinding({} as never, rejectOffline as never, checkedSigner() as never, {} as never) + .run(ctx, net, { hex: "abcd", offline: false })) .resolves.toMatchObject({ checked: true, signerWeight: 1 }); }); + + it("treats an absent --offline exactly like --offline false", async () => { + await expect(txSignTronBinding({} as never, rejectOffline as never, checkedSigner() as never, {} as never) + .run(ctx, net, { hex: "abcd" })) + .resolves.toMatchObject({ checked: true }); + }); + + it("routes --offline through the local signing service, never touching the node", async () => { + await expect(txSignTronBinding({} as never, offlineSigner() as never, rejectChecked as never, {} as never) + .run(ctx, net, { hex: "abcd", offline: true })) + .resolves.toMatchObject({ checked: false }); + }); + + it("writes --out on both routes", async () => { + for (const input of [{ hex: "abcd", out: "signed.hex" }, { hex: "abcd", out: "signed.hex", offline: true }]) { + let written: unknown; + const writer = { write: (path: string, hex: string) => { written = { path, hex }; } }; + const result = await txSignTronBinding( + {} as never, offlineSigner() as never, checkedSigner() as never, writer as never, + ).run(ctx, net, input); + expect(written).toEqual({ path: "signed.hex", hex: "beef" }); + expect(result).toMatchObject({ out: "signed.hex", hex: "beef" }); + } + }); + + it("rejects --offline with the JSON payload route, which has no online check to skip", async () => { + const svc = { sign: async () => ({ kind: "sign" }) }; + await expect(txSignTronBinding(svc as never, {} as never, {} as never, {} as never) + .run(ctx, net, { transaction: "{}", offline: true })) + .rejects.toMatchObject({ code: "invalid_option" }); + }); +}); + +// v4.10.0 shipped `tx sign --transaction ` as the only form. That contract must survive the +// multisig work: the JSON route still returns the transaction service's result verbatim, and the +// new `kind:"tx-sign"` shape appears only for --hex/--file, which did not exist in 4.10.0. +describe("tx sign 4.10.0 JSON compatibility", () => { + it("returns the transaction service result unwrapped and unannotated", async () => { + const legacy = { kind: "sign", mode: "sign-only", signed: { txID: "abc" }, address: "T1", txId: "abc" }; + const svc = { sign: async () => legacy }; + const result = await txSignTronBinding(svc as never, {} as never, {} as never, {} as never) + .run(ctx, net, { transaction: '{"txID":"abc"}' }); + expect(result).toEqual(legacy); + expect(result).not.toHaveProperty("checked"); + expect(result).not.toHaveProperty("approval"); + }); + + it("still accepts the 4.10.0 invocation with no new flags", () => { + expect(txSignSpec.baseFields.safeParse({ transaction: '{"txID":"abc"}' }).success).toBe(true); + }); }); describe("tx broadcast binding", () => { diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index cbb4308c1..0d06116cc 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -143,8 +143,8 @@ const signFields = z.object({ transaction: z.string().min(1).optional() .describe("unsigned TRON transaction JSON; retained for direct single-signature compatibility"), ...artifactFields, - check: z.boolean().default(false) - .describe("verify signer permission and resulting approval weight online"), + offline: z.boolean().default(false) + .describe("sign locally without contacting a node; skips the signer-permission and approval-weight checks"), out: z.string().min(1).optional().describe("atomically write co-signed transaction hex to this file"), }); @@ -153,11 +153,13 @@ export const txSignSpec: ChainSpec = { network: "optional", wallet: "optional", auth: "required", broadcasts: false, capability: "tx.sign", - summary: "Sign transaction JSON or append a signature to transaction hex offline", + summary: "Sign transaction JSON or append a signature to transaction hex", description: "With --transaction, preserve the direct JSON signing flow. With --hex/--file, append exactly\n" + - "one signature while preserving prior signatures without requiring a node. Add --check to\n" + - "verify permission membership and approval weight online. This command never broadcasts.", + "one signature while preserving prior signatures, verifying online that this account is in the\n" + + "transaction's permission group and has not already signed, and reporting the resulting\n" + + "approval weight. Add --offline to sign without contacting a node, which skips those checks.\n" + + "This command never broadcasts.", baseFields: signFields, baseRefine: (input, context) => { if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length !== 1) { @@ -170,14 +172,14 @@ export const txSignSpec: ChainSpec = { if (input.out && input.transaction) { context.addIssue({ code: "custom", path: ["out"], message: "--out is only valid with --hex or --file" }); } - if (input.check && input.transaction) { - context.addIssue({ code: "custom", path: ["check"], message: "--check is only valid with --hex or --file" }); + if (input.offline && input.transaction) { + context.addIssue({ code: "custom", path: ["offline"], message: "--offline is only valid with --hex or --file" }); } }, examples: [ { cmd: `wallet-cli tx sign --transaction '{"txID":"...","raw_data":{...},"raw_data_hex":"..."}'` }, { cmd: "wallet-cli tx sign --file partially-signed.hex --out signed.hex --password-stdin" }, - { cmd: "wallet-cli tx sign --file partially-signed.hex --check --password-stdin" }, + { cmd: "wallet-cli tx sign --file partially-signed.hex --offline --password-stdin" }, ], formatText: TextFormatters.txSign, }; @@ -192,15 +194,15 @@ export const txSignTronBinding = ( exactlyOne([input.transaction, input.hex, input.file], "provide exactly one of --transaction, --hex, or --file"); if (!input.transaction) { const hex = hexInput(input); - const result = input.check - ? await multisigService.signChecked(ctx, net, hex) - : await signingService.sign(ctx, net, hex); + const result = input.offline + ? await signingService.sign(ctx, net, hex) + : await multisigService.signChecked(ctx, net, hex); if (!input.out) return result; writer.write(input.out, result.hex); return { ...result, out: input.out }; } if (input.out) throw new UsageError("invalid_option", "--out is only valid with --hex or --file"); - if (input.check) throw new UsageError("invalid_option", "--check is only valid with --hex or --file"); + if (input.offline) throw new UsageError("invalid_option", "--offline is only valid with --hex or --file"); let tx: unknown; try { tx = JSON.parse(input.transaction); diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index f8d478745..f9b5a961d 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -240,6 +240,10 @@ function formatFee(fee: unknown, family: ChainFamily): string { const f = asObj(fee) if (f.feeSun) return `${formatSun(f.feeSun)} TRX` if (f.bandwidthBurnSunIfNoFreeze) return `${formatSun(f.bandwidthBurnSunIfNoFreeze)} TRX` + // account activate: the fee is derived from two chain parameters rather than quoted by the + // node, so it arrives as its components plus their sum. Show the sum — the same single total + // the confirmed receipt prints — and leave the breakdown to json. + if (f.minimumFeeSun !== undefined) return `${formatSun(f.minimumFeeSun)} 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) { @@ -249,6 +253,10 @@ function formatFee(fee: unknown, family: ChainFamily): string { return `~${energy.toLocaleString()} energy${covered}` } if (f.note) return String(f.note) + // An unrecognised fee object must not reach feeFallback: that formats a scalar sun amount and + // would stringify the object into "[object Object]". Saying "unknown" is honest, and it keeps + // a fee shape added later from silently rendering as garbage instead of failing visibly. + return "unknown" } return FAMILY_RENDER[family].feeFallback(fee) } diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts index 0feffe8cc..d14e24333 100644 --- a/ts/src/adapters/inbound/cli/shell/index.ts +++ b/ts/src/adapters/inbound/cli/shell/index.ts @@ -5,10 +5,10 @@ */ import yargs, { type Argv } from "yargs" import { randomBytes } from "node:crypto" -import { z, type RefinementCtx, type ZodObject, type ZodRawShape, type ZodType } from "zod" -import type { AccountDescriptor, NetworkDescriptor } from "../../../../domain/types/index.js"; -import { isChainCommand } from "../contracts/index.js"; -import type { ChainCommandDefinition, ChainSpec, CommandDefinition, CommandExecutionSpec, ExecutionContext, Globals, SessionRef, StreamManager } from "../contracts/index.js"; +import { type RefinementCtx, type ZodObject, type ZodRawShape, type ZodType } from "zod" +import type { AccountDescriptor, NetworkDescriptor } from "../../../../domain/types/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" @@ -70,18 +70,20 @@ export function buildCli(opts: ShellOptions): Argv { cli.command( `${head} [verb] [args..]`, `${head} commands`, - (y) => applyCommands(y, cmds.map((c) => c.fields)), + (y) => + applyCommands( + stringGroupTail(y), + cmds.map((c) => c.fields), + ), (argv) => dispatchNeutral(opts, [head, typeof argv.verb === "string" ? argv.verb : undefined].filter(Boolean) as string[], argv), ) } else { // single-segment leaf (create / list / use / current / rename / derive / delete / backup / networks) const cmd = cmds[0]! cli.command( - cmd.positionals?.length - ? `${head} ${cmd.positionals.map((p) => `[${p.placeholder ?? p.field}]`).join(" ")}` - : head, + cmd.positionals?.length ? `${head} [args..]` : head, cmd.summary ?? "", - (y) => applyCommands(y, [cmd.fields]), + (y) => applyCommands(stringGroupTail(y), [cmd.fields]), (argv) => dispatchNeutral(opts, [head], argv), ) } @@ -90,10 +92,9 @@ export function buildCli(opts: ShellOptions): Argv { 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] : []), - ]), + ...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 assembledLeaves = assembledChainCommands.filter((c) => c.spec.path[0] === group) @@ -101,13 +102,13 @@ export function buildCli(opts: ShellOptions): Argv { 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]`, + assembledLeaves[0]?.spec.positionals?.length ? `${group} [args..]` : group, assembledLeaves[0]?.spec.summary ?? "", - (y) => applyCommands(y, fieldsOfLogicalGroup(group)), + (y) => applyCommands(stringGroupTail(y), fieldsOfLogicalGroup(group)), (argv) => { // a non-numeric positional (e.g. `block get`) is a mistyped subcommand, not a block height: // report it as an unknown command rather than a confusing "--number received NaN". - const pos = (argv as any).number + const pos = (argv as any).args?.[0] if (pos !== undefined && !Number.isFinite(Number(pos))) { throw new UsageError("unknown_command", `unknown command: ${group} ${pos}`) } @@ -119,7 +120,7 @@ export function buildCli(opts: ShellOptions): Argv { cli.command( `${group} [verb] [args..]`, `${group} commands`, - (y) => applyCommands(y, fieldsOfLogicalGroup(group)), + (y) => applyCommands(stringGroupTail(y), fieldsOfLogicalGroup(group)), (argv) => dispatchLogical(opts, [group, typeof argv.verb === "string" ? argv.verb : undefined].filter(Boolean) as string[], argv), ) } @@ -153,14 +154,22 @@ async function dispatchLogical(opts: ShellOptions, path: string[], argv: any): P throw new UsageError("unknown_command", `unknown command: ${path.join(" ")}`) } +/** + * Type the group tail as strings. Declared fields already get `type: "string"` from applyArity, + * but `verb`/`args..` are anonymous captures, so yargs would infer their type and hand back a + * number for anything number-shaped (`0xdeadbeef`, `12345`) — which every positional-bound field, + * being string-typed in zod, then rejects. Coercing after the fact is not enough: the inference is + * lossy (`1e5` → 100000, oversized ints → float), so the raw token has to survive parsing. + */ +function stringGroupTail(y: Argv): Argv { + return y.positional("verb", { type: "string" }).positional("args", { type: "string" }) +} + /** * A yargs group has one positional layout while each leaf can have a different one. * Capture the group tail as args[], then bind it only after resolving the exact leaf. */ -export function bindGroupedPositionals( - command: Pick, - argv: Record, -): Record { +export function bindGroupedPositionals(command: Pick, argv: Record): Record { if (!("args" in argv)) return argv const bound = { ...argv } const raw = bound.args @@ -168,18 +177,12 @@ export function bindGroupedPositionals( const values = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw] const declared = command.positionals ?? [] if (values.length > declared.length) { - throw new UsageError( - "usage_error", - `too many positional arguments for ${command.path.join(" ")}: expected ${declared.length}, received ${values.length}`, - ) + throw new UsageError("usage_error", `too many positional arguments for ${command.path.join(" ")}: expected ${declared.length}, received ${values.length}`) } values.forEach((value, index) => { const field = declared[index]!.field if (bound[field] !== undefined) { - throw new UsageError( - "invalid_option", - `${field} was provided both positionally and as --${camelToKebab(field)}`, - ) + throw new UsageError("invalid_option", `${field} was provided both positionally and as --${camelToKebab(field)}`) } bound[field] = value }) @@ -191,24 +194,22 @@ async function executeChainCommand(opts: ShellOptions, def: ChainCommandDefiniti const { spec } = def const id = commandId({ path: spec.path }) session.current = { commandId: id } - argv = bindGroupedPositionals(spec, argv) 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 ${spec.path.join(" ")} supports ${families} but selected network ${net.id} is ${net.family}`, - ) + throw new UsageError("network_family_mismatch", `command ${spec.path.join(" ")} supports ${families} but selected network ${net.id} is ${net.family}`) } 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) + // order matters: the flag check must see argv before positionals are bound onto their fields assertKnownFlags(executionSpec, argv) + argv = bindGroupedPositionals(spec, argv) deps.prompter.setInteractive(isInteractiveCommand(spec)) caps.check(spec, net) @@ -230,8 +231,9 @@ async function executeChainCommand(opts: ShellOptions, def: ChainCommandDefiniti async function executeCommand(opts: ShellOptions, cmd: CommandDefinition, argv: any): Promise { const { globals, deps, targetResolver, caps, streams, formatter, session } = opts session.current = { commandId: commandId(cmd) } - argv = bindGroupedPositionals(cmd, argv) + // order matters: the flag check must see argv before positionals are bound onto their fields assertKnownFlags(cmd, argv) + argv = bindGroupedPositionals(cmd, argv) // Gate all interactive prompts (gap-fill, password, secret, account-select, delete confirm) on a // per-command allowlist: only create/import/delete/backup may prompt; everything else fails fast. @@ -278,10 +280,7 @@ async function executeCommand(opts: ShellOptions, cmd: CommandDefinition, argv: } /** Runtime counterpart to CommandDefinition's network-policy discrimination. */ -function requireResolvedNetwork( - cmd: Pick, - net: NetworkDescriptor | undefined, -): NetworkDescriptor { +function requireResolvedNetwork(cmd: Pick, net: NetworkDescriptor | undefined): NetworkDescriptor { if (net) return net throw new Error(`network resolver returned no network for ${commandId(cmd)}`) } @@ -370,8 +369,8 @@ 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: Pick, argv: any): void { - const allowed = new Set(["_", "$0", "group", "verb", "source", "key", "value"]) +function assertKnownFlags(cmd: Pick, argv: any): void { + const allowed = new Set(["_", "$0", "group", "verb", "args", "source"]) const add = (name: string) => { allowed.add(name) allowed.add(camelToKebab(name)) @@ -380,10 +379,24 @@ function assertKnownFlags(cmd: Pick, ar for (const k of Object.keys(GLOBAL_OPTS)) add(k) for (const f of GLOBAL_FLAG_SPECS) if (f.alias) allowed.add(f.alias) // -o / -v short aliases for (const p of cmd.path) add(p) - for (const k of Object.keys(cmd.fields.shape)) add(k) - const unknown = Object.keys(argv).filter((k) => !allowed.has(k)) + // Positional fields are deliberately absent: they arrive in `args` and are bound to their field + // AFTER this check, so a field name present here can only be a `--field` the user typed — which + // this command does not accept (see CommandDefinition.positionals). A positional named after a + // global (`use [account]`) stays allowed via GLOBAL_OPTS above; supplying both is then caught by + // bindGroupedPositionals. + const positional = new Set((cmd.positionals ?? []).map((p) => p.field)) + for (const k of Object.keys(cmd.fields.shape)) if (!positional.has(k)) add(k) + // dedupe: camel-case-expansion puts `--trace-id` in argv as both trace-id and traceId, and both + // render to the same flag name. + const unknown = [ + ...new Set( + Object.keys(argv) + .filter((k) => !allowed.has(k)) + .map(camelToKebab), + ), + ] if (unknown.length > 0) { - throw new UsageError("invalid_option", `unknown option(s): ${unknown.map((u) => `--${camelToKebab(u)}`).join(", ")}`) + throw new UsageError("invalid_option", `unknown option(s): ${unknown.map((u) => `--${u}`).join(", ")}`) } } @@ -408,11 +421,7 @@ function withFields(spec: ChainSpec, fields: ZodObject): CommandExe return { ...spec, fields } } -function composeRefines( - fields: ZodObject, - baseRefine?: (value: any, ctx: RefinementCtx) => void, - familyRefine?: (value: any, ctx: RefinementCtx) => void, -): ZodType { +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) diff --git a/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts new file mode 100644 index 000000000..0b8215793 --- /dev/null +++ b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildCli, type ShellOptions } from "./index.js"; +import { isChainCommand, type SessionRef } from "../contracts/index.js"; +import { composeCliRuntime } from "../../../../bootstrap/composition.js"; + +/** + * The other shell tests drive synthetic command definitions, which proves the mechanism but not + * that any shipped command is wired to it. This one enumerates the REAL registry: every command + * that declares positionals must reject the `--` spelling of each one. New positional + * commands are covered automatically — the table is derived, not hand-written. + * + * Rejection happens in assertKnownFlags, before run(), so nothing here touches the network, + * the keystore, or a signing path. + */ +describe("every registered positional command rejects its -- spelling", () => { + let previousHome: string | undefined; + + beforeAll(() => { + previousHome = process.env.WALLET_CLI_HOME; + process.env.WALLET_CLI_HOME = mkdtempSync(join(tmpdir(), "wallet-cli-positional-")); + }); + + afterAll(() => { + if (previousHome === undefined) delete process.env.WALLET_CLI_HOME; + else process.env.WALLET_CLI_HOME = previousHome; + }); + + // fresh per invocation: StreamManager permits one result emission, so a runtime cannot be shared + // across commands that actually run. + function newRuntime() { + return composeCliRuntime({ + globals: { output: "json", verbose: false }, + secretPaths: {}, + startedAt: Date.now(), + }); + } + + function shellOpts(): ShellOptions { + const runtime = newRuntime(); + return { + registry: runtime.registry, + globals: { output: "json", verbose: false }, + deps: runtime.deps, + targetResolver: runtime.targetResolver, + caps: runtime.capabilities, + streams: runtime.streams, + formatter: runtime.formatter, + session: {} as SessionRef, + }; + } + + function positionalCommands() { + return newRuntime().registry.all().flatMap((c) => { + const { path, positionals, fields } = isChainCommand(c) + ? { path: c.spec.path, positionals: c.spec.positionals, fields: c.spec.baseFields } + : { path: c.path, positionals: c.positionals, fields: c.fields }; + return positionals?.length ? [{ path, positionals, fields }] : []; + }); + } + + it("finds the shipped positional commands (guards against an empty, vacuously-passing table)", () => { + const found = positionalCommands().map((c) => c.path.join(" ")).sort(); + expect(found).toEqual([ + "backup", "block", "config", "contact add", "contact remove", + "delete", "encoding convert", "gasfree trace", "rename", "use", + ]); + }); + + it("declares every positional as a real field of its own schema", () => { + for (const cmd of positionalCommands()) { + for (const p of cmd.positionals) { + expect(Object.keys(cmd.fields.shape), `${cmd.path.join(" ")} → ${p.field}`).toContain(p.field); + } + } + }); + + it("rejects -- for each positional of each command", async () => { + for (const cmd of positionalCommands()) { + for (const p of cmd.positionals) { + // `account` is also a global flag, legitimately accepted everywhere (use/rename/delete/backup) + if (p.field === "account") continue; + const kebab = p.field.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + const tokens = [...cmd.path, `--${kebab}`, "x"]; + await expect( + buildCli(shellOpts()).parseAsync(tokens), + `${tokens.join(" ")} should be rejected`, + ).rejects.toMatchObject({ code: "invalid_option", message: `unknown option(s): --${kebab}` }); + } + } + }); + + // Optional positionals must stay omittable. `config` is the sharpest case: 0, 1 or 2 of them, + // and it moved from a `config [key] [value]` yargs layout to the shared `[args..]` capture. + // Both readings are local-only (no network, no keystore). + it.each([ + [["config"], "no positionals"], + [["config", "timeoutMs"], "first of two"], + ])("accepts %s (%s)", async (tokens) => { + await expect(buildCli(shellOpts()).parseAsync(tokens)).resolves.toBeDefined(); + }); + + it("still enforces the positional count limit", async () => { + await expect(buildCli(shellOpts()).parseAsync(["config", "a", "b", "c"])) + .rejects.toMatchObject({ message: /too many positional arguments for config/ }); + }); + + // `use`/`rename`/`delete`/`backup` name their positional after the global --account. The global + // stays valid; supplying both spellings at once is the case bindGroupedPositionals catches. + it("keeps the global --account usable on the commands whose positional shares its name", async () => { + for (const cmd of positionalCommands().filter((c) => c.positionals.some((p) => p.field === "account"))) { + await expect(buildCli(shellOpts()).parseAsync([...cmd.path, "--account", "no-such-account"])) + .rejects.not.toMatchObject({ code: "invalid_option" }); + await expect(buildCli(shellOpts()).parseAsync([...cmd.path, "positional", "--account", "flag"])) + .rejects.toMatchObject({ message: /both positionally and as --account/ }); + } + }); +}); diff --git a/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts index b2fe73c74..a594a912d 100644 --- a/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts +++ b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts @@ -69,3 +69,79 @@ describe("ChainCommandDefinition dispatch", () => { expect(JSON.parse(out[0]!).data).toEqual({ block: { number: "123" } }); }); }); + +// executeChainCommand is a second, independent dispatch path: it binds positionals and rejects +// flagged ones at its own call site. A ` [args..]` chain leaf exercises the grouped +// tail there, which the single-segment `block` case above does not reach. +describe("grouped chain leaf positionals", () => { + function chainFixture(tokens: string[]) { + const tmpRoot = mkdtempSync(join(tmpdir(), "wallet-cli-chain-test-")); + const prompter = new Prompter({ + isTTY: () => false, + async question() { return ""; }, + async readKey() { return { name: "return" }; }, + write() {}, beginRaw() {}, endRaw() {}, + } as any); + const out: string[] = []; + const streams = new StreamManager("json", false, (s) => out.push(s)); + const secrets = new SecretResolver(streams, {}, prompter); + const keystore = new Keystore(tmpRoot, new AtomicFileStore(), () => 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: ["gasfree", "trace"], + network: "optional", wallet: "none", auth: "none", + positionals: [{ field: "traceId" }], + examples: [], + baseFields: z.object({ traceId: z.string().optional() }), + }; + const run = vi.fn(async (_ctx: any, _net: any, input: any) => ({ traceId: input.traceId })); + registry.addChain(spec, "tron", { run }); + // sibling leaf → the head is dispatched as `gasfree [verb] [args..]` + registry.addChain( + { path: ["gasfree", "info"], network: "optional", wallet: "none", auth: "none", examples: [], baseFields: z.object({}) }, + "tron", + { run: async () => ({}) }, + ); + + return { + run, + shellOpts: { + registry, + globals: { output: "json" as const, verbose: false, network: "tron:mainnet" }, + deps: { config, networkRegistry, streams, secrets, keystore, prompter, formatter }, + targetResolver: new TargetResolver({ networkRegistry, keystore }), + caps: new CapabilityRegistry(), + streams, + formatter, + session: {} as SessionRef, + } satisfies ShellOptions, + }; + } + + it("passes a numeric-looking positional through verbatim as a string", async () => { + const tokens = ["gasfree", "trace", "12345"]; + const { shellOpts, run } = chainFixture(tokens); + await buildCli(shellOpts).parseAsync(tokens); + expect(run.mock.calls[0]![2]).toMatchObject({ traceId: "12345" }); + }); + + it("rejects the -- spelling, matching the kebab form of a camelCase field", async () => { + const tokens = ["gasfree", "trace", "--trace-id", "12345"]; + const { shellOpts, run } = chainFixture(tokens); + // exactly once: camel-case-expansion lands both spellings in argv, they must not double-report + await expect(buildCli(shellOpts).parseAsync(tokens)) + .rejects.toMatchObject({ code: "invalid_option", message: "unknown option(s): --trace-id" }); + expect(run).not.toHaveBeenCalled(); + }); + + it("rejects the camelCase spelling too", async () => { + const tokens = ["gasfree", "trace", "--traceId", "12345"]; + const { shellOpts } = chainFixture(tokens); + await expect(buildCli(shellOpts).parseAsync(tokens)) + .rejects.toMatchObject({ code: "invalid_option", message: /unknown option\(s\): --trace-id/ }); + }); +}); diff --git a/ts/src/adapters/inbound/cli/shell/shell.test.ts b/ts/src/adapters/inbound/cli/shell/shell.test.ts index d83684cab..69bf3f879 100644 --- a/ts/src/adapters/inbound/cli/shell/shell.test.ts +++ b/ts/src/adapters/inbound/cli/shell/shell.test.ts @@ -246,6 +246,168 @@ import { Keystore } from "../../../outbound/keystore/index.js"; import { SecretResolver } from "../input/secret/index.js"; import { Prompter } from "../input/prompt/index.js"; +// ── positional parsing end-to-end ───────────────────────────────────────────── + +describe("grouped positional parsing through yargs", () => { + function positionalFixture(tokens: string[]): { shellOpts: ShellOptions; seen: () => unknown } { + const tmpRoot = mkdtempSync(join(tmpdir(), "wallet-cli-test-")); + const store = new AtomicFileStore(); + const prompter = new Prompter({ + isTTY: () => false, + async question() { return ""; }, + async readKey() { return { name: "return" }; }, + write() {}, + beginRaw() {}, + endRaw() {}, + } as any); + const streams = new StreamManager("text", false); + const secrets = new SecretResolver(streams, {}, prompter); + const keystore = new Keystore(tmpRoot, store, () => secrets.masterPassword()); + const config = ConfigLoader.load(); + const networkRegistry = new NetworkRegistry(config); + + let received: unknown; + const fields = z.object({ input: z.string().min(1), label: z.string().optional() }); + const registry = new CommandRegistry(); + // two leaves under one head → the ` [verb] [args..]` group form + registry.add({ + path: ["encoding", "convert"], + network: "none", wallet: "none", auth: "none", + positionals: [{ field: "input" }], + fields, input: fields, examples: [], + run: async (_ctx: any, _net: any, input: any) => { received = input.input; return {}; }, + } as unknown as CommandDefinition); + registry.add({ + path: ["encoding", "other"], + network: "none", wallet: "none", auth: "none", + fields: z.object({}), input: z.object({}), examples: [], + run: async () => ({}), + } as unknown as CommandDefinition); + // mirrors `use [account]`: positional field named after the global --account flag + const acctFields = z.object({ account: z.string().min(1) }); + registry.add({ + path: ["acct"], + network: "none", wallet: "none", auth: "none", + positionals: [{ field: "account" }], + fields: acctFields, input: acctFields, examples: [], + run: async (_ctx: any, _net: any, input: any) => { received = input.account; return {}; }, + } as unknown as CommandDefinition); + const pairFields = z.object({ first: z.string(), second: z.string() }); + registry.add({ + path: ["pair"], + network: "none", wallet: "none", auth: "none", + positionals: [{ field: "first" }, { field: "second" }], + fields: pairFields, input: pairFields, examples: [], + run: async () => ({}), + } as unknown as CommandDefinition); + // single-segment leaf: yargs binds the positional by field name, no `args..` capture + registry.add({ + path: ["cfg"], + network: "none", wallet: "none", auth: "none", + positionals: [{ field: "input" }], + fields, input: fields, examples: [], + run: async (_ctx: any, _net: any, input: any) => { received = input.input; return {}; }, + } as unknown as CommandDefinition); + + const formatter = createOutputFormatter("text", streams, Date.now()); + return { + seen: () => received, + shellOpts: { + registry, + globals: { output: "text" as const, verbose: false }, + deps: { config, networkRegistry, streams, secrets, keystore, prompter, formatter }, + targetResolver: new TargetResolver({ networkRegistry, keystore }), + caps: new CapabilityRegistry(), + streams, + formatter, + session: {} as SessionRef, + }, + }; + } + + it.each([ + ["0x12E94f5a3c88b17d2F6E0b9a45Cd310f8E7a6D29", "0x12E94f5a3c88b17d2F6E0b9a45Cd310f8E7a6D29"], + ["0xdeadbeef", "0xdeadbeef"], + ["12345", "12345"], + ["1e5", "1e5"], + ["TBhCfAytTEh52WFL6HYr64i2nmc3u3TCUp", "TBhCfAytTEh52WFL6HYr64i2nmc3u3TCUp"], + ])("passes %s through verbatim as a string", async (argument, expected) => { + const tokens = ["encoding", "convert", argument]; + const { shellOpts, seen } = positionalFixture(tokens); + await buildCli(shellOpts).parseAsync(tokens); + expect(seen()).toBe(expected); + }); + + // A field exposed as a positional is documented under Args, not Flags — the `--` spelling + // is not part of the contract. argv alone cannot tell the two apart (yargs writes both to the + // same key), so the rejection is driven by the raw tokens. + it.each([ + ["grouped leaf", ["encoding", "convert", "--input", "0xdeadbeef"]], + ["grouped leaf, = form", ["encoding", "convert", "--input=0xdeadbeef"]], + ["single-segment leaf", ["cfg", "--input", "0xdeadbeef"]], + ["positional and flag together", ["encoding", "convert", "0xcafe", "--input", "0xdeadbeef"]], + ])("rejects the -- spelling of a positional (%s)", async (_name, tokens) => { + const { shellOpts } = positionalFixture(tokens); + await expect(buildCli(shellOpts).parseAsync(tokens)) + .rejects.toMatchObject({ code: "invalid_option", message: /unknown option\(s\): --input/ }); + }); + + it("still accepts the positional form on a single-segment leaf", async () => { + const tokens = ["cfg", "0xdeadbeef"]; + const { shellOpts, seen } = positionalFixture(tokens); + await buildCli(shellOpts).parseAsync(tokens); + expect(seen()).toBe("0xdeadbeef"); + }); + + it("still accepts a non-positional flag on a command that has positionals", async () => { + const tokens = ["encoding", "convert", "0xdeadbeef", "--verbose"]; + const { shellOpts, seen } = positionalFixture(tokens); + await buildCli(shellOpts).parseAsync(tokens); + expect(seen()).toBe("0xdeadbeef"); + }); + + it("names every flagged positional in one message", async () => { + const tokens = ["pair", "--first", "a", "--second", "b"]; + const { shellOpts } = positionalFixture(tokens); + await expect(buildCli(shellOpts).parseAsync(tokens)) + .rejects.toMatchObject({ message: /unknown option\(s\): --first, --second/ }); + }); + + it("leaves a command that declares no positionals alone", async () => { + const tokens = ["encoding", "other", "--nope"]; + const { shellOpts } = positionalFixture(tokens); + // still rejected, but by assertKnownFlags — not by the positional guard + await expect(buildCli(shellOpts).parseAsync(tokens)) + .rejects.toMatchObject({ message: /unknown option\(s\): --nope/ }); + }); + + // The token scan must not mistake a flag-shaped *value* for a flag the user typed. Both ways of + // passing a literal `--input` as data survive: `=` keeps it on the right-hand side, and `--` + // ends flag parsing entirely. + it("does not treat a flag-shaped value as a typed flag", async () => { + const tokens = ["encoding", "convert", "--label=--input", "0xdeadbeef"]; + const { shellOpts, seen } = positionalFixture(tokens); + await buildCli(shellOpts).parseAsync(tokens); + expect(seen()).toBe("0xdeadbeef"); + }); + + // `use [account]` / `rename` / `delete` / `backup` name their positional after the global + // --account flag. Rejecting it there would break a documented global on four real commands. + it("does not reject a positional whose name is also a global flag", async () => { + const tokens = ["acct", "--account", "main"]; + const { shellOpts, seen } = positionalFixture(tokens); + await buildCli(shellOpts).parseAsync(tokens); + expect(seen()).toBe("main"); + }); + + it("ignores tokens after the -- terminator", async () => { + const tokens = ["encoding", "convert", "0xdeadbeef", "--", "--input"]; + const { shellOpts, seen } = positionalFixture(tokens); + await buildCli(shellOpts).parseAsync(tokens); + expect(seen()).toBe("0xdeadbeef"); + }); +}); + describe("dispatch password-prime wiring", () => { it("calls primePassword with mode='set' for auth=required command when keystore is uninitialized", async () => { const tmpRoot = mkdtempSync(join(tmpdir(), "wallet-cli-test-")); diff --git a/ts/src/application/use-cases/tron/multisig-service.test.ts b/ts/src/application/use-cases/tron/multisig-service.test.ts index 092ed13ba..cef3d72f5 100644 --- a/ts/src/application/use-cases/tron/multisig-service.test.ts +++ b/ts/src/application/use-cases/tron/multisig-service.test.ts @@ -146,6 +146,42 @@ describe("local TRON multi-signature workflow", () => { expect(signer.sign).not.toHaveBeenCalled(); }); + // `tx sign` verifies online by default precisely so these two never yield a signature. Producing + // one anyway is worse than failing: the hex looks fine and gets passed to the next co-signer, + // and only `tx broadcast` — possibly hours and several people later — rejects it. + const spySigner = (address: string): Signer => ({ + kind: "software", + address, + sign: vi.fn(async (transaction) => { + const mutable = transaction as { signature?: string[] }; + mutable.signature = [...(mutable.signature ?? []), SIG_A]; + return transaction; + }), + signMessage: async () => "", + signTypedData: async () => ({ signature: "", digest: "", primaryType: "" }), + }); + + it("refuses a signer outside the permission group before producing a signature", async () => { + const outsider = "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8"; + const signer = spySigner(outsider); + const scoped = { ...scope(), resolveAddress: () => outsider } as TransactionScope; + await expect(service(fakeGateway(), signer).signChecked(scoped, NETWORK, unsignedHex())) + .rejects.toMatchObject({ code: "not_authorized" }); + expect(signer.sign).not.toHaveBeenCalled(); + }); + + it("refuses a repeat signature before producing a signature", async () => { + const signer = spySigner(A); + // gateway reports A as already approved for any transaction carrying a signature + const alreadySigned = encodeTransactionHex({ + ...decodeTransactionHex(unsignedHex()), + signature: [SIG_B], + } as never); + await expect(service(fakeGateway(), signer).signChecked(scope(), NETWORK, alreadySigned)) + .rejects.toMatchObject({ code: "already_signed" }); + expect(signer.sign).not.toHaveBeenCalled(); + }); + it("does not sign when the authorized account and resolved signer diverge", async () => { const signer: Signer = { kind: "software", From b850324070b4764b6316ff9312c4a0912dc51e7a Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 3 Aug 2026 11:57:45 +0800 Subject: [PATCH 16/24] fix(ts): keep machine values out of text, and unblock the permission round trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text receipts carried machine-readable values that belong in json: the contract-type enum sat next to the human operation name on every Type row, and `permission show` printed the raw operations bitmap. Both are now json-only; the enum remains the fallback when a contract type has no human name. `tx broadcast` resolves the full approval state to decide broadcastability but projected none of it into text: --dry-run printed a header and a fee line while json carried permission, weight progress and approved signers. Reuse renderApproval for it, which also exposed two smaller faults — the multi-sign fee was printed twice whenever it was non-zero, and the Tx row was always empty because the broadcast view names the field `transaction`, not `tx`. renderApproval moves to its own module so tx.ts can use it without a cycle back into multisig.ts. `permission update` rejected the workflow its own documentation recommends. `permission show` always emits unknownOperationIds, almost always `[]`, and an empty list forced operationsHex to be supplied alongside it — so a structure exported by show could not be fed back after editing `operations`. An empty list declares "no unnamed bits", exactly what re-encoding `operations` produces, so it now needs no bitmap; a non-empty list still does, leaving the review guarantee intact. The remaining mismatch error now names the fix instead of only stating the disagreement. Eight fields across four commands shipped with no help description, five of them positionals rendered as bare names under Args. All are filled in, and a registry-wide test now fails if a command ships an undocumented input. --- .../adapters/inbound/cli/commands/contact.ts | 8 +- .../adapters/inbound/cli/commands/encoding.ts | 3 +- .../adapters/inbound/cli/commands/gasfree.ts | 12 +- .../cli/commands/text-formatters.test.ts | 137 ++++++++++++++++++ .../adapters/inbound/cli/render/approval.ts | 38 +++++ .../adapters/inbound/cli/render/multisig.ts | 35 +---- .../adapters/inbound/cli/render/permission.ts | 1 - ts/src/adapters/inbound/cli/render/tx.ts | 20 ++- ts/src/domain/permission/index.ts | 15 +- ts/src/domain/permission/permission.test.ts | 42 ++++++ 10 files changed, 260 insertions(+), 51 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/render/approval.ts diff --git a/ts/src/adapters/inbound/cli/commands/contact.ts b/ts/src/adapters/inbound/cli/commands/contact.ts index 218271339..041af6d27 100644 --- a/ts/src/adapters/inbound/cli/commands/contact.ts +++ b/ts/src/adapters/inbound/cli/commands/contact.ts @@ -9,8 +9,10 @@ export function registerContactCommands( service: ContactService, ): void { const addFields = z.object({ - name: z.string().min(1).max(256), - address: z.string().min(1).max(128), + name: z.string().min(1).max(256) + .describe("local name for this recipient; usable anywhere an address is accepted"), + address: z.string().min(1).max(128) + .describe("recipient address to store under this name"), note: z.string().max(512).optional() .describe("free-form note, up to 128 safe characters"), }); @@ -50,7 +52,7 @@ export function registerContactCommands( } satisfies CommandDefinition); const removeFields = z.object({ - name: z.string().min(1).max(256), + name: z.string().min(1).max(256).describe("name of the contact to delete"), }); registry.add({ path: ["contact", "remove"], diff --git a/ts/src/adapters/inbound/cli/commands/encoding.ts b/ts/src/adapters/inbound/cli/commands/encoding.ts index 749462795..014e5954e 100644 --- a/ts/src/adapters/inbound/cli/commands/encoding.ts +++ b/ts/src/adapters/inbound/cli/commands/encoding.ts @@ -9,7 +9,8 @@ export function registerEncodingCommands( service: EncodingService, ): void { const fields = z.object({ - input: z.string().min(1).max(2 * 1024 * 1024), + input: z.string().min(1).max(2 * 1024 * 1024) + .describe("value to convert: TRON base58 / hex address, EVM 0x address, public key, hex, or Base64"), }); registry.add({ path: ["encoding", "convert"], diff --git a/ts/src/adapters/inbound/cli/commands/gasfree.ts b/ts/src/adapters/inbound/cli/commands/gasfree.ts index c425f2b2b..068d42b2f 100644 --- a/ts/src/adapters/inbound/cli/commands/gasfree.ts +++ b/ts/src/adapters/inbound/cli/commands/gasfree.ts @@ -33,9 +33,12 @@ const transferFields = z.object({ .refine( (value) => !/^0+(\.0+)?$/.test(value), "must be greater than zero", - ), - token: z.string().trim().min(1).max(32).default("USDT"), - dryRun: z.boolean().default(false), + ) + .describe("human token amount to transfer, in token units"), + token: z.string().trim().min(1).max(32).default("USDT") + .describe("token symbol supported by the GasFree provider"), + dryRun: z.boolean().default(false) + .describe("check token balance and the fee breakdown without unlocking, signing, or submitting"), }); export const gasFreeTransferSpec: ChainSpec = { @@ -81,7 +84,8 @@ export const gasFreeTransferTronBinding = ( const traceFields = z.object({ traceId: z.string().trim().min(1).max(128) - .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/), + .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/) + .describe("trace id returned by `gasfree transfer`"), }); export const gasFreeTraceSpec: ChainSpec = { 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 a756aec57..8d6447932 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -6,6 +6,7 @@ import { registerNetworkCommands } from "./network.js"; import { registerTronChainCommands, type TronChainCommandDependencies } from "../../../../bootstrap/families/tron.js"; import { commandId } from "../command-id.js"; import { TextFormatters } from "../render/index.js"; +import { introspectFields } from "../arity/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"; @@ -33,6 +34,63 @@ describe("text formatters", () => { expect(missing).toEqual([]); }); + + // Help renders a field's description for both flags and positionals (help/index.ts Args + Flags). + // A field without one prints a bare name and leaves the reader — often an agent — guessing what + // to pass. Registry-wide so a new command cannot quietly ship undocumented inputs. + it("every registered command field carries a help description", () => { + const registry = new CommandRegistry(); + registerWalletCommands(registry, {} as Parameters[1]); + registerConfigCommands(registry, {} as ConfigService); + registerNetworkCommands(registry); + registerContactCommands(registry, {} as never); + registerAddressCommands(registry, {} as never); + registerEncodingCommands(registry, {} as never); + registerTronChainCommands(registry, {} as TronChainCommandDependencies); + + const missing: string[] = []; + for (const cmd of registry.all()) { + const spec = isChainCommand(cmd) ? cmd.spec : cmd; + const fields = isChainCommand(cmd) ? cmd.spec.baseFields : cmd.fields; + if (!fields) continue; + for (const field of introspectFields(fields)) { + if (!field.description) missing.push(`${spec.path.join(" ")} --${field.kebab}`); + } + } + + expect(missing.sort()).toEqual([]); + }); +}); + +describe("permissionShow formatter", () => { + const view = { + address: "Towner", + owner: { id: 0, name: "owner", threshold: 1, keys: [{ address: "Towner", weight: 1, local: "main" }] }, + actives: [{ + id: 2, name: "finance", threshold: 2, + keys: [{ address: "TQkX", weight: 1 }, { address: "TXe4", weight: 1, local: "cold" }], + operations: ["TransferContract"], + operationsHex: "7fff1fc0033e0100000000000000000000000000000000000000000000000000", + operationLabels: ["Transfer TRX"], + unknownOperationIds: [], + }], + } as any; + + // Doc §3.1.1 keeps operationsHex in json — it is a machine value, and the human column already + // carries the decoded operation labels next to it. + it("does not print the operations bitmap in the text card", () => { + const out = TextFormatters.permissionShow(view, ctx()) as string; + expect(out).not.toContain("Operations Hex"); + expect(out).not.toContain("7fff1fc0033e0100"); + }); + + it("still prints the decoded operation labels, the total, and local key annotations", () => { + const out = TextFormatters.permissionShow(view, ctx()) as string; + expect(out).toContain("Transfer TRX"); + expect(out).toContain("(1 total)"); + expect(out).toContain("(this wallet: cold)"); + expect(out).toContain('finance (id 2, active)'); + }); }); describe("accountBalance formatter", () => { @@ -276,6 +334,59 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(out).toContain("unknown"); }); + // `tx broadcast` carries the full approval view in json, but text projected none of it: the + // dry-run receipt was a bare header plus a fee line, and the fee was printed twice whenever the + // multi-sign fee was non-zero (receiptRows pushed one row, the dry-run branch another). The QA + // pass missed the duplicate because its sample fee was 0, which is falsy. + const broadcastApproval = { + txId: "abc123", contractType: "TransferContract", operation: "Transfer TRX", + from: "Towner", to: "Trecipient", rawAmount: "1000000", + permission: { id: 2, name: "finance", threshold: 2 }, + currentWeight: 2, missingWeight: 0, thresholdReached: true, + approved: [{ address: "TQkX", weight: 1 }, { address: "TXe4", weight: 1 }], + expiration: 1784388720000, expired: false, signatures: 2, + }; + + it("broadcast dry-run: projects the permission and approval block json already carries", () => { + const out = TextFormatters.txReceipt({ + kind: "broadcast", mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: 1000000, + } as any) as string; + expect(out).toContain("Dry run tx broadcast"); + expect(out).toContain('Permission active "finance" (id 2) threshold 2'); + expect(out).toContain("Progress 2 / 2 — threshold reached"); + expect(out).toContain("Approved signer"); + expect(out).toContain("TQkX"); + }); + + it("broadcast dry-run: identifies the transaction instead of leaving an empty Tx row", () => { + const out = TextFormatters.txReceipt({ + kind: "broadcast", mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: 0, + } as any) as string; + expect(out).toContain("abc123"); + }); + + it.each([ + ["non-zero multi-sign fee", 1000000, "1 TRX"], + ["zero multi-sign fee", 0, "0 TRX"], + ])("broadcast dry-run: states the multi-sign fee exactly once (%s)", (_n, fee, expected) => { + const out = TextFormatters.txReceipt({ + kind: "broadcast", mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: fee, + } as any) as string; + expect(out.match(/multi-sign fee/gi) ?? []).toHaveLength(1); + expect(out).toContain(expected); + }); + + it("broadcast submitted: keeps txid, status and the tracking hint, and does not duplicate the fee", () => { + const out = TextFormatters.txReceipt({ + kind: "broadcast", stage: "submitted", txId: "abc123", + transaction: broadcastApproval, multiSignFeeSun: 1000000, + } as any) as string; + expect(out).toContain("abc123"); + expect(out).toContain("pending — not yet on-chain"); + expect(out).toContain("Track it:"); + expect(out.match(/multi-sign fee/gi) ?? []).toHaveLength(1); + }); + it("stake freeze submitted: renders staked amount and resource", () => { const out = TextFormatters.txReceipt({ kind: "stake-freeze", stage: "submitted", txId: "abc", amountSun: "2000000", resource: "energy" }); expect(out).toContain("Staked"); @@ -339,6 +450,32 @@ describe("local multisig formatters", () => { expect(out).not.toContain("was not checked online"); }); + // Doc §3.2: text gets the human operation name; the machine-readable contractType enum stays in + // json. Printing both put a machine value in a human column. + it("prints the human operation name without the contract-type enum", () => { + const out = TextFormatters.txApprovals({ ...approval, operation: "Transfer TRX" }) as string; + expect(out).toContain("Transfer TRX"); + expect(out).not.toContain("(TransferContract)"); + }); + + it("falls back to the raw contract type when no human name is known", () => { + const out = TextFormatters.txApprovals({ ...approval, operation: undefined }) as string; + expect(out).toContain("TransferContract"); + }); + + it("prints the human operation name on the offline sign receipt too", () => { + const out = TextFormatters.txSign({ + kind: "tx-sign", signer: "Tsigner", checked: false, hex: "aabb", + transaction: { + txId: approval.txId, contractType: "TransferContract", operation: "Transfer TRX", + rawAmount: "1000000", permissionId: 2, expiration: approval.expiration, + expired: false, signatures: 1, + }, + } as any) as string; + expect(out).toContain("Transfer TRX"); + expect(out).not.toContain("(TransferContract)"); + }); + it("shows the next broadcast command only after threshold is reached", () => { const transaction = { txId: approval.txId, diff --git a/ts/src/adapters/inbound/cli/render/approval.ts b/ts/src/adapters/inbound/cli/render/approval.ts new file mode 100644 index 000000000..f312266d4 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/approval.ts @@ -0,0 +1,38 @@ +import type { TxApprovalView } from "../../../../domain/types/index.js"; +import { formatAtWithRelative, formatInt, formatSun } from "./scalars.js"; +import { query, table } from "./layout.js"; + +function transactionType(value: TxApprovalView): string { + // human name only — the machine-readable contractType enum stays in json (doc §3.2). The raw + // enum is the fallback, since an unmapped contract type has no human name to show. + const label = value.operation ?? value.contractType; + if (!value.rawAmount) return label; + return value.contractType === "TransferContract" + ? `${label} — ${formatSun(value.rawAmount)} TRX` + : `${label} — ${value.rawAmount} base units`; +} + +export function renderApproval(value: TxApprovalView): string { + const permissionKind = value.permission.id === 0 ? "owner" : value.permission.id === 1 ? "witness" : "active"; + const expires = `${formatAtWithRelative(value.expiration)}${value.expired ? " [EXPIRED]" : ""}`; + const transaction = query([ + ["TxID", value.txId], + ["Type", transactionType(value)], + ["From", value.from ?? ""], + ["To", value.to ?? ""], + ["Permission", `${permissionKind} "${value.permission.name}" (id ${value.permission.id}) threshold ${formatInt(value.permission.threshold)}`], + ["Expires", expires], + ]); + const progress = value.thresholdReached + ? `Progress ${formatInt(value.currentWeight)} / ${formatInt(value.permission.threshold)} — threshold reached` + : `Progress ${formatInt(value.currentWeight)} / ${formatInt(value.permission.threshold)} — ${formatInt(value.missingWeight)} more weight needed`; + const approved = value.approved.length === 0 + ? "No approved signers." + : table( + ["Approved signer", "Weight"], + value.approved.map((signer) => [signer.address, formatInt(signer.weight)]), + ); + const expired = value.expired ? "\n! Transaction expired; build a new transaction before collecting signatures." : ""; + return `Transaction\n${transaction.split("\n").map((line) => ` ${line}`).join("\n")}\n\n${progress}\n${approved}${expired}`; +} + diff --git a/ts/src/adapters/inbound/cli/render/multisig.ts b/ts/src/adapters/inbound/cli/render/multisig.ts index 680c55749..19744e94c 100644 --- a/ts/src/adapters/inbound/cli/render/multisig.ts +++ b/ts/src/adapters/inbound/cli/render/multisig.ts @@ -7,40 +7,9 @@ import type { import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; import { formatAtWithRelative, formatInt, formatSun } from "./scalars.js"; import { ok, query, receipt, table } from "./layout.js"; +import { renderApproval } from "./approval.js"; import { TxFormatters } from "./tx.js"; -function transactionType(value: TxApprovalView): string { - const label = value.operation ? `${value.operation} (${value.contractType})` : value.contractType; - if (!value.rawAmount) return label; - return value.contractType === "TransferContract" - ? `${label} — ${formatSun(value.rawAmount)} TRX` - : `${label} — ${value.rawAmount} base units`; -} - -export function renderApproval(value: TxApprovalView): string { - const permissionKind = value.permission.id === 0 ? "owner" : value.permission.id === 1 ? "witness" : "active"; - const expires = `${formatAtWithRelative(value.expiration)}${value.expired ? " [EXPIRED]" : ""}`; - const transaction = query([ - ["TxID", value.txId], - ["Type", transactionType(value)], - ["From", value.from ?? ""], - ["To", value.to ?? ""], - ["Permission", `${permissionKind} "${value.permission.name}" (id ${value.permission.id}) threshold ${formatInt(value.permission.threshold)}`], - ["Expires", expires], - ]); - const progress = value.thresholdReached - ? `Progress ${formatInt(value.currentWeight)} / ${formatInt(value.permission.threshold)} — threshold reached` - : `Progress ${formatInt(value.currentWeight)} / ${formatInt(value.permission.threshold)} — ${formatInt(value.missingWeight)} more weight needed`; - const approved = value.approved.length === 0 - ? "No approved signers." - : table( - ["Approved signer", "Weight"], - value.approved.map((signer) => [signer.address, formatInt(signer.weight)]), - ); - const expired = value.expired ? "\n! Transaction expired; build a new transaction before collecting signatures." : ""; - return `Transaction\n${transaction.split("\n").map((line) => ` ${line}`).join("\n")}\n\n${progress}\n${approved}${expired}`; -} - function renderTronLink(value: TronLinkMultisigView): string { if ("transactions" in value) { const heading = `Multi-sig transactions — TronLink service (${value.total} total)`; @@ -112,7 +81,7 @@ function renderSign(value: TxSignView): string { function renderSignedTransaction(value: TxSignTransactionView): string { const permissionKind = value.permissionId === 0 ? "owner" : value.permissionId === 1 ? "witness" : "active"; - const label = value.operation ? `${value.operation} (${value.contractType})` : value.contractType; + const label = value.operation ?? value.contractType; const type = !value.rawAmount ? label : value.contractType === "TransferContract" diff --git a/ts/src/adapters/inbound/cli/render/permission.ts b/ts/src/adapters/inbound/cli/render/permission.ts index 0105892de..2bcbdd214 100644 --- a/ts/src/adapters/inbound/cli/render/permission.ts +++ b/ts/src/adapters/inbound/cli/render/permission.ts @@ -33,7 +33,6 @@ function permissionCard(permission: PermissionGroupView, active = false): string lines.push(`Operation(s) ${wrapped[0] ?? ""}`); for (const continuation of wrapped.slice(1)) lines.push(` ${continuation}`); lines[lines.length - 1] += ` (${operations.length} total)`; - lines.push(`Operations Hex ${activePermission.operationsHex}`); } lines.push(`Threshold ${formatInt(permission.threshold)}`); lines.push("Authorized To Address Weight"); diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index f9b5a961d..d793baa7f 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -2,6 +2,8 @@ import type { TxInfoView, TxReceiptKind, TxReceiptView, TxStatusView } from "../ import type { TextFormatter, TextRenderContext } from "../contracts/index.js" import { ChainFamily } from "../../../../domain/family/index.js" import { fromBaseUnits } from "../../../../domain/amounts/index.js" +import type { TxApprovalView } from "../../../../domain/types/index.js" +import { renderApproval } from "./approval.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" @@ -33,13 +35,15 @@ export const TxFormatters = { function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { const family = renderFamily(ctx) if (r.mode === "dry-run") { - return receipt(pending(), `Dry run ${actionLabel(r.kind)}`, [ + // receiptRows already states a multi-sign fee; only estimated fees need their own row here. + const body = receipt(pending(), `Dry run ${actionLabel(r.kind)}`, [ ...receiptRows(r), - ["Fee", r.multiSignFeeSun === undefined - ? formatFee(r.fee, family) - : `${formatSun(r.multiSignFeeSun)} TRX multi-sign fee`], - ["Tx", summarizeTx(r.tx)], + ...(r.multiSignFeeSun === undefined ? [["Fee", formatFee(r.fee, family)] as Pair] : []), + ["Tx", summarizeTx(r.tx ?? r.transaction)], ]) + // `tx broadcast --dry-run` resolves the full approval state to decide broadcastability; show + // it rather than leaving text with a fee line while json carries permission and progress. + return r.transaction ? `${body}\n\n${renderApproval(r.transaction as TxApprovalView)}` : body } if (r.mode === "build-only") { return r.hex ?? receipt(pending(), `Built ${actionLabel(r.kind)}`, [ @@ -157,7 +161,9 @@ function receiptSummary(r: TxReceiptView, family: ChainFamily): string { /** action-specific extra rows (To/From/Address/Contract), by kind. */ function receiptRows(r: TxReceiptView): Pair[] { const rows: Pair[] = [] - if (r.multiSignFeeSun) rows.push(["Multi-sign fee", `${formatSun(r.multiSignFeeSun)} TRX`]) + // `undefined` means "not a multi-sig broadcast"; 0 is a real answer (single signature, + // no extra fee) and must still be stated rather than silently dropped as falsy. + if (r.multiSignFeeSun !== undefined) rows.push(["Multi-sign fee", `${formatSun(r.multiSignFeeSun)} TRX`]) 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 ?? "")]) @@ -277,5 +283,5 @@ function signatureRows(signed: unknown): Pair[] { 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))) + return shorten(String(o.txid ?? o.txID ?? o.txId ?? o.hash ?? JSON.stringify(o))) } diff --git a/ts/src/domain/permission/index.ts b/ts/src/domain/permission/index.ts index d3e6bb931..0a80aa3ca 100644 --- a/ts/src/domain/permission/index.ts +++ b/ts/src/domain/permission/index.ts @@ -209,6 +209,10 @@ export function encodeOperations(contractTypes: readonly string[]): string { return bytes.toString("hex"); } +function isEmptyUnknownList(value: unknown): boolean { + return Array.isArray(value) && value.length === 0; +} + /** Require unnamed bitmap bits to be declared verbatim, so no set bit escapes human review. */ function assertDeclaredUnknownOperations(value: unknown, actual: readonly number[], index: number): void { const field = `actives[${index}].unknownOperationIds`; @@ -258,14 +262,21 @@ function activeGroup(value: unknown, index: number): ActivePermissionView { supplied.operations.length !== known.operations.length || supplied.operations.some((operation, operationIndex) => operation !== known.operations[operationIndex]) ) { - return invalidPermission(`actives[${index}].operationsHex does not match operations`); + return invalidPermission( + `actives[${index}].operationsHex does not match operations; remove operationsHex to regenerate ` + + "it from operations, or edit both together", + ); } // Every set bit must be declared somewhere a reviewer can read: named types in `operations`, // unnamed ones in `unknownOperationIds`. Without this, a bitmap can widen the permission // while the human-readable fields stay unchanged — the failure this whole field pair prevents. assertDeclaredUnknownOperations(input.unknownOperationIds, supplied.unknownOperationIds, index); operationsHex = supplied.operationsHex; - } else if (input.unknownOperationIds !== undefined) { + // `[]` declares "there are no unnamed bits", which is exactly what encoding `operations` alone + // produces — so it needs no bitmap to justify it. `permission show` always emits the field, and + // demanding operationsHex alongside an empty list blocked the documented show/edit/update round + // trip. Only a non-empty list still requires the bitmap it was read from. + } else if (input.unknownOperationIds !== undefined && !isEmptyUnknownList(input.unknownOperationIds)) { return invalidPermission( `actives[${index}].unknownOperationIds requires operationsHex — operations cannot express unnamed contract types`, ); diff --git a/ts/src/domain/permission/permission.test.ts b/ts/src/domain/permission/permission.test.ts index 84c9333f7..e811ae6ee 100644 --- a/ts/src/domain/permission/permission.test.ts +++ b/ts/src/domain/permission/permission.test.ts @@ -123,6 +123,48 @@ describe("permission replacement validation", () => { expect(() => validatePermissionStructure(input)).toThrowError(/must not contain duplicate/); }); + // The documented workflow is `permission show -o json > f` → edit `operations` → `update --file`. + // show always emits unknownOperationIds, almost always `[]`. An empty list declares "no unnamed + // bits", which is exactly what regenerating the bitmap from `operations` produces — demanding + // operationsHex alongside it blocks the round trip without protecting anything. + it("accepts an empty unknownOperationIds without a bitmap, regenerating it from operations", () => { + const input = structure(); + delete (input.actives[0] as Record).operationsHex; + input.actives[0]!.unknownOperationIds = []; + const result = validatePermissionStructure(input); + expect(result.actives[0]).toMatchObject({ + operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], + operationsHex: encodeOperations(["TransferContract", "TransferAssetContract", "TriggerSmartContract"]), + unknownOperationIds: [], + }); + }); + + it("accepts the edited round trip: operations changed, operationsHex dropped, empty unknown list kept", () => { + const input = structure(); + delete (input.actives[0] as Record).operationsHex; + input.actives[0]!.operations = ["TransferContract"]; // reviewer narrowed the permission + input.actives[0]!.unknownOperationIds = []; + expect(validatePermissionStructure(input).actives[0]).toMatchObject({ + operations: ["TransferContract"], + operationsHex: encodeOperations(["TransferContract"]), + }); + }); + + it("still refuses a non-empty unknownOperationIds without a bitmap", () => { + const input = structure(); + delete (input.actives[0] as Record).operationsHex; + input.actives[0]!.unknownOperationIds = [7]; + expect(() => validatePermissionStructure(input)).toThrowError(/requires operationsHex/); + }); + + // The mismatch is reached by editing `operations` and leaving the exported bitmap behind. Saying + // only "does not match" leaves the reader to guess which side to change. + it("tells the reader how to resolve an operations/operationsHex mismatch", () => { + const input = structure(); + input.actives[0]!.operations = ["TransferContract"]; // edited; operationsHex left stale + expect(() => validatePermissionStructure(input)).toThrowError(/remove operationsHex/); + }); + it("rejects unknownOperationIds without a bitmap to justify them", () => { const input = structure(); delete (input.actives[0] as Record).operationsHex; From 0f140d1539d97ff613ba287220af9f24d366d454 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 3 Aug 2026 23:38:45 +0800 Subject: [PATCH 17/24] fix(ts): address PR #963 review findings Resolves the 15 findings raised on the PR. Verified each against the code before changing anything; several turned out to differ from the report. Correctness - contract deploy reported an address derived from the pre-prepare txID. --permission-id / --expiration rewrite raw_data, moving the txID and with it the deployed address. Derived in refreshTransactionIdentity now, and captured from the prepared transaction. The default path was never wrong. - Post-checks that run after an already-confirmed, already-paid transaction no longer convert it into a command failure (account activate / set, permission update). New warnOnPostCheck degrades both the unreadable-read and the mismatch paths to warnings, keeping the txid. - GasFree polling no longer discards the accepted receipt: transport flakiness retries inside the deadline, other non-integrity errors return submitted with the traceId, and integrity errors now carry it in details. - getTokenInfo no longer swallows transport failures. Measured against a live node: a view method the contract lacks returns result:true with an empty payload, so the catch was only ever hiding node outages, which GasFree then escalated to gasfree_integrity and tx send to exit 2. - Keypair writer removes its own unfinished file, so a failed write no longer blocks every retry of the same path via O_EXCL. - TronLink signer rosters are bound to the on-chain permission, not just the signed subset; weights render from chain. A stale snapshot after a permission update was displayed as chain-validated. Contract and output - Bootstrap failures produce a v1 error envelope and the right exit code instead of a bare fatal: line; config read/parse failures classify as invalid_config without echoing file content. - Exclusive-output conflicts use one code, class and exit status (output_exists / UsageError / 2) across backup and address generate. - Text output escapes bidi and zero-width characters rather than passing them through, so a chain-controlled name cannot reorder what is printed beside it. JSON stays byte-exact. - Shielded transfers are named in the codec table so the refusal is actionable; four contract types cannot be decoded, not the three the docs claimed. Architecture - TransactionArtifactWriter port added; the inbound command no longer names the outbound implementation. Pinned by a boundary test, since depcruise cannot see type-only edges without failing on pre-existing type-only cycles. Docs - meta.warnings declared as the mixed type it actually is, with guidance for consumers. - --wait receipts documented as reporting the outcome in data.stage, never in success. - The 11 leaf pages that had fallen behind the shared transaction options now document --build-only / --permission-id / --expiration, and no longer claim every mode needs the master password. Guarded by a test. - Corrected claims that were never implemented (vote status warning code), no longer reachable (account set provider_error), or absent (config file mode requirement and its Windows exemption). --- ts/docs/commands/account/activate.md | 19 +++- ts/docs/commands/account/set.md | 16 +++- ts/docs/commands/address/generate.md | 9 +- ts/docs/commands/config.md | 8 ++ ts/docs/commands/contract/deploy.md | 15 ++-- ts/docs/commands/contract/send.md | 16 ++-- ts/docs/commands/gasfree/index.md | 4 + ts/docs/commands/gasfree/transfer.md | 18 +++- ts/docs/commands/permission/update.md | 4 + ts/docs/commands/reward/withdraw.md | 13 ++- ts/docs/commands/stake/cancel-unfreeze.md | 13 ++- ts/docs/commands/stake/delegate.md | 13 ++- ts/docs/commands/stake/freeze.md | 13 ++- ts/docs/commands/stake/undelegate.md | 13 ++- ts/docs/commands/stake/unfreeze.md | 13 ++- ts/docs/commands/stake/withdraw.md | 13 ++- ts/docs/commands/tx/multisig.md | 14 ++- ts/docs/commands/tx/send.md | 19 ++-- ts/docs/commands/tx/sign.md | 11 ++- ts/docs/commands/vote/cast.md | 13 ++- ts/docs/commands/vote/status.md | 7 +- ts/docs/concepts/security.md | 18 ++++ ts/docs/machine-interface.md | 22 ++++- ts/src/adapters/inbound/boundary.test.ts | 37 ++++++++ .../cli/commands/transaction-options.test.ts | 31 +++++++ ts/src/adapters/inbound/cli/commands/tx.ts | 2 +- .../inbound/cli/output/output.test.ts | 55 ++++++++++++ ts/src/adapters/inbound/cli/render/scalars.ts | 19 +++- .../transaction-codec.deploy-address.test.ts | 72 ++++++++++++++++ .../chain/tron/transaction-codec.test.ts | 28 ++++++ .../outbound/chain/tron/transaction-codec.ts | 28 +++++- .../chain/tron/tron.token-info.test.ts | 53 +++++++++++- ts/src/adapters/outbound/chain/tron/tron.ts | 8 +- .../adapters/outbound/config/config.test.ts | 37 +++++++- ts/src/adapters/outbound/config/index.ts | 24 +++++- .../persistence/keypair-writer.test.ts | 44 ++++++++++ .../outbound/persistence/keypair-writer.ts | 24 +++++- .../transaction-artifact-writer.test.ts | 8 +- .../transaction-artifact-writer.ts | 3 +- .../ports/transaction-artifact-writer.ts | 4 + ts/src/application/services/post-check.ts | 33 +++++++ .../use-cases/tron/account-service.test.ts | 84 ++++++++++++++++++ .../use-cases/tron/account-service.ts | 31 ++++--- .../tron/contract-service.deploy.test.ts | 56 ++++++++++-- .../use-cases/tron/contract-service.ts | 19 ++-- .../use-cases/tron/gasfree-service.test.ts | 86 ++++++++++++++++++- .../use-cases/tron/gasfree-service.ts | 63 +++++++++++--- .../multisig-collaboration-service.test.ts | 36 ++++++++ .../tron/multisig-collaboration-service.ts | 56 +++++++++++- .../use-cases/tron/permission-service.test.ts | 43 +++++++++- .../use-cases/tron/permission-service.ts | 14 +-- ts/src/bootstrap/families/tron.ts | 4 +- ts/src/bootstrap/runner.test.ts | 70 ++++++++++++++- ts/src/bootstrap/runner.ts | 42 ++++++++- 54 files changed, 1273 insertions(+), 145 deletions(-) create mode 100644 ts/src/adapters/inbound/boundary.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/transaction-codec.deploy-address.test.ts create mode 100644 ts/src/application/ports/transaction-artifact-writer.ts create mode 100644 ts/src/application/services/post-check.ts diff --git a/ts/docs/commands/account/activate.md b/ts/docs/commands/account/activate.md index 75cc0e945..9e6a53976 100644 --- a/ts/docs/commands/account/activate.md +++ b/ts/docs/commands/account/activate.md @@ -91,10 +91,25 @@ An address that is already on chain is refused rather than paid for twice: | `payer` | string | Address that paid the creation fee | | `blockNumber`, `feeSun` | number \| string | Present after `--wait` | +### Warnings + +After a confirmed activation the CLI re-reads the account to verify it. That check never fails the +command — the fee is already spent and the transaction is already on chain — so both outcomes arrive +as `{code, message}` entries in `meta.warnings`: + +| Code | Meaning | +|---|---| +| `account_activate_postcheck_unavailable` | Confirmed on chain, but the verification read could not be performed (node lag, timeout, rate limit) | +| `account_activate_postcheck_mismatch` | The read succeeded, but the selected node still cannot see the account | + +Either way the receipt and `txId` stand — do not re-run the command; confirm with +[`account info`](info.md). + ## Exit status -`0` · `1` execution failure (`account_already_active`, `insufficient_balance`, `provider_error`, -`auth_failed`) · `2` usage error (`invalid_value`, conflicting mode flags). +`0` · `1` execution failure (`account_already_active`, `insufficient_balance`, `provider_error` +(chain parameters unavailable), `auth_failed`) · `2` usage error (`invalid_value`, conflicting mode +flags). ## See also diff --git a/ts/docs/commands/account/set.md b/ts/docs/commands/account/set.md index 38110238f..a14dc3d5b 100644 --- a/ts/docs/commands/account/set.md +++ b/ts/docs/commands/account/set.md @@ -82,11 +82,23 @@ error [invalid_option]: provide exactly one of --name or --id | `address` | string | Account that was updated | | `stage`, `txId`, `blockNumber`, `feeSun` | — | Standard broadcast receipt fields | +### Warnings + +After confirmation the CLI re-reads the account to check the written value. Because the field is +one-time and already on chain, that check never fails the command — both outcomes arrive as +`{code, message}` entries in `meta.warnings`: + +| Code | Meaning | +|---|---| +| `account_set_postcheck_unavailable` | Confirmed on chain, but the verification read could not be performed (node lag, timeout, rate limit) | +| `account_set_postcheck_mismatch` | The read succeeded, but the on-chain value differs from what was submitted | + +The receipt and `txId` stand in both cases; confirm with [`account info`](info.md). + ## Exit status `0` · `1` execution failure (account not activated, chain rejected an already-set field, -`provider_error`, `auth_failed`) · `2` usage error (neither/both of `--name` / `--id`, conflicting -mode flags). +`auth_failed`) · `2` usage error (neither/both of `--name` / `--id`, conflicting mode flags). ## See also diff --git a/ts/docs/commands/address/generate.md b/ts/docs/commands/address/generate.md index f98665818..fabb2d575 100644 --- a/ts/docs/commands/address/generate.md +++ b/ts/docs/commands/address/generate.md @@ -87,8 +87,13 @@ wallet-cli address generate -o json ## Exit status -`0` · `1` execution failure — `entropy_failure`, `file_exists` (refusing to overwrite an existing -keypair file), `io_error` · `2` usage error. +`0` · `1` execution failure — `entropy_failure`, `io_error` · `2` usage error, including +`output_exists` when the target file already exists (the same code [`backup`](../backup.md) uses — +the key is never written over an existing file, and the conflict is deterministic, so retrying the +identical command cannot help; choose another `--out` path). + +A failed write leaves nothing behind: the partially written file is removed, so the same `--out` +path is free to retry once the underlying cause (a full disk, for instance) is resolved. ## See also diff --git a/ts/docs/commands/config.md b/ts/docs/commands/config.md index 0a576927e..5dabf4d04 100644 --- a/ts/docs/commands/config.md +++ b/ts/docs/commands/config.md @@ -50,6 +50,14 @@ These are the only wallet-cli settings that hold credentials, and unlike wallet stored in the config document rather than the encrypted keystore — they authenticate you to a third-party service, they do not control funds. +Because they sit in plain text, a config file holding either secret must be private: on POSIX +systems it must not be a symlink and must be mode `0600`, or **every** command fails at startup with +`insecure_config` (exit 2) until you run `chmod 600` on it. Windows uses ACLs rather than POSIX +modes, so the check is skipped there and the file is not protected by wallet-cli — restrict it +yourself. A file that cannot be read, or that is not valid YAML, fails the same way with +`invalid_config`; the parser's detail is withheld because it quotes the offending line, which may be +the credential itself. + Precedence for a value that has both a flag and a config key (highest first): command-line flag > config value > built-in default — e.g. `--timeout` > config `timeoutMs` > built-in 60000. An invalid value returns `invalid_value` (exit 2). `networks` is read-only — writing it fails with diff --git a/ts/docs/commands/contract/deploy.md b/ts/docs/commands/contract/deploy.md index af1beaf26..af72dc8c0 100644 --- a/ts/docs/commands/contract/deploy.md +++ b/ts/docs/commands/contract/deploy.md @@ -7,16 +7,16 @@ Deploy a smart contract. ``` wallet-cli contract deploy --abi --bytecode --fee-limit [--constructor-sig --params ] - [--dry-run | --sign-only] [--wait [--wait-timeout ]] [options] + [--dry-run | --sign-only | --build-only] [--wait [--wait-timeout ]] [options] ``` ## Description Deploys compiled contract bytecode from the active account (or `--account`) and reports the new contract address. `--fee-limit` is **required** here (deployments are energy-heavy; there is no safe default). Constructor arguments go via `--constructor-sig` + `--params`. -Same execution model as other broadcast commands: `--dry-run` previews, `--sign-only` outputs a signed transaction for [`tx broadcast`](../tx/broadcast.md), default returns at submission, `--wait` blocks until confirmed/failed. +Same execution model as other broadcast commands: `--dry-run` previews, `--sign-only` outputs a signed transaction for [`tx broadcast`](../tx/broadcast.md), `--build-only` outputs the unsigned transaction for [multi-party or offline signing](../tx/index.md), default returns at submission, `--wait` blocks until confirmed/failed. -Requires an account and the master password via `--password-stdin`; watch-only accounts fail with `watch_only_no_signer`. +Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -27,11 +27,16 @@ Requires an account and the master password via `--password-stdin`; watch-only a | `--fee-limit ` | **Required.** Max energy fee to burn, in SUN | | `--constructor-sig ` | Constructor signature, e.g. `constructor(uint256)`; omit when no constructor args | | `--params ` | Constructor args as a JSON array of `{type,value}` | -| `--dry-run` | Estimate only; excludes `--sign-only` | -| `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--dry-run` | Estimate only | +| `--sign-only` | Sign without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + Plus the [global options](../index.md#global-options-every-command). ## Examples diff --git a/ts/docs/commands/contract/send.md b/ts/docs/commands/contract/send.md index 42cea5c76..4d8f496cd 100644 --- a/ts/docs/commands/contract/send.md +++ b/ts/docs/commands/contract/send.md @@ -7,18 +7,18 @@ State-changing contract call (triggerSmartContract). ``` wallet-cli contract send --contract
--method [--params ] [--call-value-sun ] [--fee-limit ] - [--dry-run | --sign-only] [--wait [--wait-timeout ]] [options] + [--dry-run | --sign-only | --build-only] [--wait [--wait-timeout ]] [options] ``` ## Description Builds, signs, and broadcasts a state-changing contract call from the active account (or `--account`). Parameters follow the same `{type,value}` JSON-array convention as [`contract call`](call.md); `--call-value-sun` attaches native TRX to the call. -Two early exits: `--dry-run` previews the energy cost (estimateEnergy) without signing or broadcasting; `--sign-only` signs and prints the transaction for a later [`tx broadcast`](../tx/broadcast.md). +Three early exits: `--dry-run` previews the energy cost (estimateEnergy) without signing or broadcasting; `--sign-only` signs and prints the transaction for a later [`tx broadcast`](../tx/broadcast.md); `--build-only` prints it unsigned, for [multi-party or offline signing](../tx/index.md). **By default the command returns at submission** (`stage: "submitted"`) — add `--wait` to block until confirmed/failed. With `--wait`, an on-chain execution failure (revert / `OUT_OF_ENERGY`) comes back as `stage: "failed"` with the `result` reason. -Requires an account and the master password via `--password-stdin`; watch-only accounts fail with `watch_only_no_signer`. +Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -29,11 +29,16 @@ Requires an account and the master password via `--password-stdin`; watch-only a | `--params ` | JSON array of ABI parameters as `{type,value}` | | `--call-value-sun ` | Native TRX attached to the call, in SUN (default 0) | | `--fee-limit ` | Max energy fee to burn, in SUN (default 100000000) | -| `--dry-run` | Estimate energy only, no signature/broadcast; excludes `--sign-only` | -| `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--dry-run` | Estimate energy only, no signature/broadcast | +| `--sign-only` | Sign without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + Plus the [global options](../index.md#global-options-every-command). ## Examples @@ -102,6 +107,7 @@ echo "$PW" | wallet-cli contract send --contract TXYZopYRdj2D9XRtbG411XZZ3kM5VkA | `--wait` (confirmed/failed) | above, but `stage: "confirmed"` or `"failed"`, plus `confirmed`, `blockNumber`, `feeSun`, `energyUsed`, `result` (`SUCCESS` / `OUT_OF_ENERGY`, etc.), `failed` | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee` (`feeModel`, estimated `energy`, `availableEnergy`), unsigned `tx` | | `--sign-only` | `kind`, `mode: "sign-only"`, `signed` (feed to `tx broadcast`), `address` (signer), `txId`, `fee`, `method`, `contract` | +| `--build-only` | `kind`, `mode: "build-only"`, unsigned `tx`, `hex` (complete protobuf artifact — feed to `tx sign`), `fee`, `method`, `contract` | ## Exit status diff --git a/ts/docs/commands/gasfree/index.md b/ts/docs/commands/gasfree/index.md index d9b58e1c4..dcb724dd6 100644 --- a/ts/docs/commands/gasfree/index.md +++ b/ts/docs/commands/gasfree/index.md @@ -64,6 +64,10 @@ token and address fee metadata disagree, if fee arithmetic does not add up, or i token outside the current configuration. Signing likewise fails (`signing_rejected`) unless the signed digest is exactly the expected `PermitTransfer` and recovers to the selected account. +`gasfree_integrity` means the provider's answer is inconsistent — it is never raised because *your* +node was unreachable. Cross-checking reads TRC20 metadata from the chain, and a failure there +surfaces as `rpc_error` (retryable) instead, so the two causes stay distinguishable. + ## See also [`tx send`](../tx/send.md) — the ordinary, TRX-paying transfer · diff --git a/ts/docs/commands/gasfree/transfer.md b/ts/docs/commands/gasfree/transfer.md index caf8b4ef3..0518994ad 100644 --- a/ts/docs/commands/gasfree/transfer.md +++ b/ts/docs/commands/gasfree/transfer.md @@ -59,6 +59,21 @@ the estimates, with `stage` of `confirmed` (`SUCCEED`) or `failed` (`FAILED`). I elapses first, it warns and returns the submitted receipt — the transfer is still in flight, so track it with [`gasfree trace`](trace.md). +Polling begins only after the provider has accepted the transfer, so a polling failure says nothing +about the outcome and never discards the receipt. Transport flakiness (429, 5xx, connection failure, +request timeout) is retried until the deadline; any other provider error stops the wait immediately. +Both paths warn and return `stage: "submitted"` with the `traceId` intact — reconcile with +`gasfree trace ` rather than resubmitting, which would send the tokens twice. The one +exception is a `gasfree_integrity` violation, which still fails the command; its `error.details` +carries the `traceId` so it stays reconcilable too. + +**A `FAILED` terminal state does not fail the command.** Reaching a terminal state means the wait +did its job, so the envelope stays `success: true` at exit `0` and the failure is reported in +`data.stage` (`"failed"`), `data.state` (`"FAILED"`), and `data.failureReason`. Text mode marks the +receipt as failed, but JSON consumers must branch on `data.stage` — treating exit `0` as "the +tokens moved" will record a failed transfer as a successful one. See +[machine interface — script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed). + ## Options | Option | Description | @@ -143,7 +158,8 @@ echo "$PW" | wallet-cli gasfree transfer --to alice --amount 25 \ ## Exit status -`0` submitted (or dry-run) · `1` execution failure — `insufficient_token_balance`, +`0` submitted, dry-run, **or a `--wait` that settled as `FAILED`** (see [`--wait`](#--wait)) · +`1` execution failure — `insufficient_token_balance`, `gasfree_rejected` (address not permitted to submit), `gasfree_integrity`, `signing_rejected`, `auth_failed` · `2` usage error — `invalid_option` (`--dry-run` with `--wait`), `invalid_amount`, `unsupported_network` (`tron:shasta`), unknown token or contact. diff --git a/ts/docs/commands/permission/update.md b/ts/docs/commands/permission/update.md index d68279e87..a2ebdc8ef 100644 --- a/ts/docs/commands/permission/update.md +++ b/ts/docs/commands/permission/update.md @@ -106,6 +106,10 @@ the update: | `active_can_update_permission` | An active group can itself replace the permission structure | | `active_unknown_operations` | An active group grants contract types this build cannot name | | `permission_postcheck_mismatch` | After confirmation, the on-chain structure differs from what was requested | +| `permission_postcheck_unavailable` | The update is confirmed on chain, but the re-read could not be performed (node lag, timeout, rate limit) — the receipt stands; verify with `permission show` | + +These entries use the `{code, message}` object form; see +[reading `meta.warnings`](../../machine-interface.md#reading-metawarnings). The command also refuses to broadcast when the account balance is below the network's permission update fee (`insufficient_balance`) — this fee is substantial on mainnet, so check it with diff --git a/ts/docs/commands/reward/withdraw.md b/ts/docs/commands/reward/withdraw.md index 8a5a1bdb0..7cef0ed3f 100644 --- a/ts/docs/commands/reward/withdraw.md +++ b/ts/docs/commands/reward/withdraw.md @@ -5,7 +5,7 @@ Withdraw accrued rewards to balance. ## Synopsis ``` -wallet-cli reward withdraw [--dry-run | --sign-only] [--wait [--wait-timeout ]] [options] +wallet-cli reward withdraw [--dry-run | --sign-only | --build-only] [--wait [--wait-timeout ]] [options] ``` ## Description @@ -14,17 +14,22 @@ Moves your accumulated voting rewards (plus block rewards if you are an SR) into `Amount` in the receipt: at the `submitted` stage it is the claimable amount read at broadcast time; the `--wait` confirmed receipt shows the actual on-chain amount (they differ only by seconds of accrual — negligible). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account and the master password via `--password-stdin`; watch-only accounts fail with `watch_only_no_signer`. +**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| -| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` | -| `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--dry-run` | Build and estimate only, no signature/broadcast | +| `--sign-only` | Sign without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + Plus the [global options](../index.md#global-options-every-command). ## Examples diff --git a/ts/docs/commands/stake/cancel-unfreeze.md b/ts/docs/commands/stake/cancel-unfreeze.md index 778d1c26f..909f26f17 100644 --- a/ts/docs/commands/stake/cancel-unfreeze.md +++ b/ts/docs/commands/stake/cancel-unfreeze.md @@ -5,24 +5,29 @@ Cancel all pending unstakes (roll back to frozen). ## Synopsis ``` -wallet-cli stake cancel-unfreeze [--dry-run | --sign-only] [--wait [--wait-timeout ]] [options] +wallet-cli stake cancel-unfreeze [--dry-run | --sign-only | --build-only] [--wait [--wait-timeout ]] [options] ``` ## Description Cancels **every** unstake still in its waiting period and rolls those amounts back to staked — resource allowance and voting power return accordingly. It is all-or-nothing: TRON has no per-entry cancel. Any entries that have already expired are withdrawn to balance as part of the same transaction. -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account and the master password via `--password-stdin`; watch-only accounts fail with `watch_only_no_signer`. +**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| -| `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` | -| `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--dry-run` | Estimate only, no signature/broadcast | +| `--sign-only` | Sign without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + Plus the [global options](../index.md#global-options-every-command). ## Examples diff --git a/ts/docs/commands/stake/delegate.md b/ts/docs/commands/stake/delegate.md index faa5a3286..3a1c7a200 100644 --- a/ts/docs/commands/stake/delegate.md +++ b/ts/docs/commands/stake/delegate.md @@ -7,7 +7,7 @@ Delegate resource to another address. ``` wallet-cli stake delegate --receiver
--amount-sun [--resource energy|bandwidth] [--lock [--lock-period ]] - [--dry-run | --sign-only] [--wait [--wait-timeout ]] [options] + [--dry-run | --sign-only | --build-only] [--wait [--wait-timeout ]] [options] ``` ## Description @@ -18,7 +18,7 @@ Lends the resource backed by your staked TRX to another address — the receiver Check how much you can still delegate with [`stake delegated`](delegated.md) (`Max delegatable`). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account and the master password via `--password-stdin`; watch-only accounts fail with `watch_only_no_signer`. +**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -29,11 +29,16 @@ Check how much you can still delegate with [`stake delegated`](delegated.md) (`M | `--resource ` | Resource type to delegate (default `bandwidth`) | | `--lock` | Lock the delegation; prevents early undelegation | | `--lock-period ` | Lock duration in blocks (~3 s/block); requires `--lock` | -| `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` | -| `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--dry-run` | Estimate only, no signature/broadcast | +| `--sign-only` | Sign without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + Plus the [global options](../index.md#global-options-every-command). ## Examples diff --git a/ts/docs/commands/stake/freeze.md b/ts/docs/commands/stake/freeze.md index 18c20b74d..d1fd917cd 100644 --- a/ts/docs/commands/stake/freeze.md +++ b/ts/docs/commands/stake/freeze.md @@ -6,7 +6,7 @@ Stake TRX for energy/bandwidth. ``` wallet-cli stake freeze --amount-sun [--resource energy|bandwidth] - [--dry-run | --sign-only] [--wait [--wait-timeout ]] [options] + [--dry-run | --sign-only | --build-only] [--wait [--wait-timeout ]] [options] ``` ## Description @@ -15,7 +15,7 @@ Stakes TRX from the active account's balance (Stake 2.0) in exchange for a stead Amount is in SUN (1 TRX = 1,000,000 SUN). Staked TRX stays yours; to get it back, [`stake unfreeze`](unfreeze.md) and, after the waiting period, [`stake withdraw`](withdraw.md). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account and the master password via `--password-stdin`; watch-only accounts fail with `watch_only_no_signer`. +**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -23,11 +23,16 @@ Amount is in SUN (1 TRX = 1,000,000 SUN). Staked TRX stays yours; to get it back |---|---| | `--amount-sun ` | **Required.** Amount to stake, in SUN | | `--resource ` | Resource type to obtain (default `bandwidth`) | -| `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` | -| `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--dry-run` | Estimate only, no signature/broadcast | +| `--sign-only` | Sign without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + Plus the [global options](../index.md#global-options-every-command). ## Examples diff --git a/ts/docs/commands/stake/undelegate.md b/ts/docs/commands/stake/undelegate.md index 618c60594..46782ca9f 100644 --- a/ts/docs/commands/stake/undelegate.md +++ b/ts/docs/commands/stake/undelegate.md @@ -7,7 +7,7 @@ Reclaim delegated resource. ``` wallet-cli stake undelegate --receiver
--amount-sun [--resource energy|bandwidth] - [--dry-run | --sign-only] [--wait [--wait-timeout ]] [options] + [--dry-run | --sign-only | --build-only] [--wait [--wait-timeout ]] [options] ``` ## Description @@ -16,7 +16,7 @@ Takes back resource you previously delegated with [`stake delegate`](delegate.md Reclaiming is immediate (no waiting period — the TRX was staked all along, only the resource moves back). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account and the master password via `--password-stdin`; watch-only accounts fail with `watch_only_no_signer`. +**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -25,11 +25,16 @@ Reclaiming is immediate (no waiting period — the TRX was staked all along, onl | `--amount-sun ` | **Required.** Staked-TRX amount backing the resource to reclaim, in SUN | | `--receiver ` | **Required.** Address that previously received the delegation | | `--resource ` | Resource type to reclaim (default `bandwidth`) | -| `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` | -| `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--dry-run` | Estimate only, no signature/broadcast | +| `--sign-only` | Sign without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + Plus the [global options](../index.md#global-options-every-command). ## Examples diff --git a/ts/docs/commands/stake/unfreeze.md b/ts/docs/commands/stake/unfreeze.md index 0f515ca7c..d7a26bde2 100644 --- a/ts/docs/commands/stake/unfreeze.md +++ b/ts/docs/commands/stake/unfreeze.md @@ -6,7 +6,7 @@ Unstake TRX. ``` wallet-cli stake unfreeze --amount-sun [--resource energy|bandwidth] - [--dry-run | --sign-only] [--wait [--wait-timeout ]] [options] + [--dry-run | --sign-only | --build-only] [--wait [--wait-timeout ]] [options] ``` ## Description @@ -15,7 +15,7 @@ Starts unstaking: the amount leaves the staked pool and enters a **redemption wa Stake 2.0 allows at most **32 pending unstakes** per account at a time; check remaining slots with [`stake info`](info.md). A pending unstake can be rolled back with [`stake cancel-unfreeze`](cancel-unfreeze.md). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account and the master password via `--password-stdin`; watch-only accounts fail with `watch_only_no_signer`. +**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options @@ -23,11 +23,16 @@ Stake 2.0 allows at most **32 pending unstakes** per account at a time; check re |---|---| | `--amount-sun ` | **Required.** Amount to unstake, in SUN | | `--resource ` | Resource type to release (default `bandwidth`) | -| `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` | -| `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--dry-run` | Estimate only, no signature/broadcast | +| `--sign-only` | Sign without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + Plus the [global options](../index.md#global-options-every-command). ## Examples diff --git a/ts/docs/commands/stake/withdraw.md b/ts/docs/commands/stake/withdraw.md index 1ec441d75..9e95c1248 100644 --- a/ts/docs/commands/stake/withdraw.md +++ b/ts/docs/commands/stake/withdraw.md @@ -5,7 +5,7 @@ Withdraw expired unfrozen TRX. ## Synopsis ``` -wallet-cli stake withdraw [--dry-run | --sign-only] [--wait [--wait-timeout ]] [options] +wallet-cli stake withdraw [--dry-run | --sign-only | --build-only] [--wait [--wait-timeout ]] [options] ``` ## Description @@ -14,17 +14,22 @@ Claims every pending unstake whose waiting period has expired, moving the TRX ba Withdrawing also frees up unstake slots (max 32 pending unstakes per account). -**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account and the master password via `--password-stdin`; watch-only accounts fail with `watch_only_no_signer`. +**By default the command returns at submission**; `--wait` blocks until confirmed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| -| `--dry-run` | Estimate only, no signature/broadcast; excludes `--sign-only` | -| `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--dry-run` | Estimate only, no signature/broadcast | +| `--sign-only` | Sign without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + Plus the [global options](../index.md#global-options-every-command). ## Examples diff --git a/ts/docs/commands/tx/multisig.md b/ts/docs/commands/tx/multisig.md index dace0fb8e..842b93bf7 100644 --- a/ts/docs/commands/tx/multisig.md +++ b/ts/docs/commands/tx/multisig.md @@ -27,9 +27,17 @@ command lists. **The service is a transport, not an authority.** Everything it returns is treated as untrusted: the CLI re-derives the transaction hash from the raw data, checks the reported contract type, -owner, weights, and signature progress against the transaction itself, and fails with -`provider_error` on any disagreement — rather than showing you what the service claims. Signing -still happens locally with your key. +owner, weights, and signature progress against the transaction itself **and against the on-chain +permission**, and fails with `provider_error` on any disagreement — rather than showing you what the +service claims. Signing still happens locally with your key. + +Concretely, for the signer roster: every address the service reports must be a key in the +transaction's on-chain permission, and each `weight` shown is read from the chain, never from the +service — so a reported weight that has gone stale (the permission was updated while this +transaction sat pending) is corrected rather than displayed. If the selected account is not itself a +key in that permission, the command fails with `not_authorized` instead of reporting the transaction +as awaiting your signature. A roster that omits a key is accepted: every figure you act on +(`threshold`, `currentWeight`, `missingWeight`) is chain-derived regardless. ### Modes diff --git a/ts/docs/commands/tx/send.md b/ts/docs/commands/tx/send.md index 0afa7d08d..ee4ac8c98 100644 --- a/ts/docs/commands/tx/send.md +++ b/ts/docs/commands/tx/send.md @@ -7,7 +7,7 @@ Send native TRX or TRC20/TRC10 tokens with human `--amount`. ``` wallet-cli tx send --to (--amount | --raw-amount ) [--token | --contract
| --asset-id ] - [--dry-run | --sign-only] [--fee-limit ] [options] + [--dry-run | --sign-only | --build-only] [--fee-limit ] [options] ``` ## Description @@ -25,11 +25,11 @@ name, so the resolution stays auditable rather than implicit. Amounts: `--amount` is human units (TRX, or token units respecting the token's decimals); `--raw-amount` is the raw integer (SUN or token base units). Exactly one of the two. -Two early exits: `--dry-run` builds and estimates only — no signature, no broadcast, nothing leaves your machine; `--sign-only` signs and prints the transaction for a later [`tx broadcast`](broadcast.md). +Three early exits: `--dry-run` builds and estimates only — no signature, no broadcast, nothing leaves your machine; `--sign-only` signs and prints the transaction for a later [`tx broadcast`](broadcast.md); `--build-only` prints it unsigned, without ever unlocking the wallet, for [multi-party or offline signing](index.md). **By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed, or poll [`tx status`](status.md). -Requires an account and the master password via `--password-stdin` — signing commands do not show an interactive prompt, so without it the command fails with `auth_required`. +Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Signing commands do not show an interactive prompt, so a signing mode without the password fails with `auth_required`. ## Options @@ -42,11 +42,16 @@ Requires an account and the master password via `--password-stdin` — signing c | `--contract ` | TRC20 contract address | | `--asset-id ` | TRC10 numeric asset id | | `--fee-limit ` | Max TRX energy fee to burn for TRC20 transfers, in SUN (default 100000000) | -| `--dry-run` | Build and estimate only; excludes `--sign-only` | -| `--sign-only` | Sign without broadcasting; excludes `--dry-run` | +| `--dry-run` | Build and estimate only | +| `--sign-only` | Sign without broadcasting | +| `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](index.md) | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default 60000; on cap returns the submitted receipt) | | `--password-stdin` | Master password from stdin | +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + Plus the [global options](../index.md#global-options-every-command). ## Examples @@ -91,13 +96,17 @@ printf '%s' "$PW" | wallet-cli tx send --to TGkbaCYB4kRBc3Q6wjqkACefUvRwf2KzkH - |---|---| | default (submit) | `kind: "send"`, `stage: "submitted"`, `txId`, `rawAmount` (string), `to`, plus `toContact` when `--to` was a contact name | | `--wait` (confirmed) | the above, but `stage: "confirmed"`, plus `confirmed`, `blockNumber`, `netUsed` (bandwidth used) or `feeSun` (fee burned), `failed` | +| `--wait` (reverted) | the same fields, but `stage: "failed"` and `failed: true` — the transaction was mined and then reverted. **The envelope is still `success: true` at exit `0`**; a reverted transaction is a completed command, not a CLI failure, so scripts must branch on `data.stage` | | `--dry-run` | `kind`, `mode: "dry-run"`, `fee` (`feeModel`, e.g. `bandwidthBurnSunIfNoFreeze`), unsigned `tx` (TRON tx object incl. `txID`, `raw_data`), `rawAmount`, `to` | | `--sign-only` | `kind`, `mode: "sign-only"`, `signed` (full signed TRON tx incl. `signature[]` — feed to `tx broadcast`), `address` (signer), `txId`, `fee`, `rawAmount`, `to` | +| `--build-only` | `kind`, `mode: "build-only"`, unsigned `tx`, `hex` (complete protobuf artifact — feed to `tx sign`), `fee`, `rawAmount`, `to` | ## Exit status `0` submitted (or built/signed in early-exit modes) · `1` execution failure (`rpc_error`, `timeout` — **on timeout the tx may still be in flight; check `tx status` before resending**) · `2` usage error (conflicting selectors/amounts/modes). +`0` also covers `--wait` reporting `stage: "failed"`: the exit code reflects the command, not the on-chain result. See [machine interface — script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed). + ## See also [`tx status`](status.md) · [`tx broadcast`](broadcast.md) · [Fees & resources](../../concepts/networks.md#fees-the-tron-resource-model) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/tx/sign.md b/ts/docs/commands/tx/sign.md index 278594371..fd2c0e5b4 100644 --- a/ts/docs/commands/tx/sign.md +++ b/ts/docs/commands/tx/sign.md @@ -34,10 +34,15 @@ So a transaction whose `raw_data` reads "1 TRX" can carry the `txID` and `raw_da 1. `txID` is the sha256 of `raw_data_hex` — always enforced, for every contract type; and 2. `raw_data` re-encodes to exactly those bytes — enforced wherever the contract type can be - decoded. A handful of types (`MarketSellAssetContract`, `MarketCancelOrderContract`, - `ShieldedTransferContract`) cannot be re-encoded; those are still bound by check 1. + decoded. -Neither check rejects anything a correct transaction builder produces. +Four contract types cannot be decoded by the bundled TronWeb — `UnfreezeAssetContract`, +`ShieldedTransferContract`, `MarketSellAssetContract`, and `MarketCancelOrderContract`. Because +check 2 cannot be satisfied for them, `--hex` / `--file` input carrying one is **refused** +(`invalid_transaction`), not accepted under check 1 alone. Sign those through `--transaction` JSON, +or with a client that can round-trip them. + +Neither check rejects anything a correct transaction builder produces for the supported types. Watch-only accounts cannot sign (`watch_only_no_signer`). Ledger accounts sign on the device. diff --git a/ts/docs/commands/vote/cast.md b/ts/docs/commands/vote/cast.md index a45c0939e..834b9d71e 100644 --- a/ts/docs/commands/vote/cast.md +++ b/ts/docs/commands/vote/cast.md @@ -5,7 +5,7 @@ Cast or replace your full vote allocation. ## Synopsis ``` -wallet-cli vote cast --for ... [--dry-run | --sign-only] +wallet-cli vote cast --for ... [--dry-run | --sign-only | --build-only] [--wait [--wait-timeout ]] [options] ``` @@ -20,18 +20,23 @@ Two hard rules from the chain: Votes take effect at the next maintenance cycle (~6 h). Each vote uses 1 TP (it is allocated, not spent — re-casting or unstaking frees it); available TP = staked TRX minus votes already placed ([`vote status`](status.md)). -**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account and the master password via `--password-stdin`; watch-only accounts fail with `watch_only_no_signer`. +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. ## Options | Option | Description | |---|---| | `--for ` | **Required, repeatable.** SR address = vote count (positive integer); the whole set becomes your full allocation (1–30 entries) | -| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` | -| `--sign-only` | Sign without broadcasting (feed to [`tx broadcast`](../tx/broadcast.md)); excludes `--dry-run` | +| `--dry-run` | Build and estimate only, no signature/broadcast | +| `--sign-only` | Sign without broadcasting (feed to [`tx broadcast`](../tx/broadcast.md)) | +| `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | +| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | +| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin (fd 0) | +`--dry-run`, `--sign-only`, and `--build-only` are mutually exclusive. + Plus the [global options](../index.md#global-options-every-command). ## Examples diff --git a/ts/docs/commands/vote/status.md b/ts/docs/commands/vote/status.md index ef5142ff6..367ced3d1 100644 --- a/ts/docs/commands/vote/status.md +++ b/ts/docs/commands/vote/status.md @@ -14,7 +14,7 @@ One read-only screen for the stake → vote → reward loop: your current vote d - **Voting power (TP)** — total = staked TRX; used = votes placed; available = total − used. - **APR / Reward ratio** — same semantics and sources as [`vote list`](list.md). Worth re-checking: an SR can change its ratio at any time (on-chain UpdateBrokerage) — votes placed at 80% silently stop earning if it drops to 0%. -- **0% warning** — if any votes sit on an SR with a 0% reward ratio, text output appends a `!` line and json adds a `zero_reward_ratio` entry (`{code, message}`) to `meta.warnings`. +- **0% warning** — if any votes sit on an SR with a 0% reward ratio, text output appends a `!` line and json adds a plain-string entry to `meta.warnings`, one per affected SR. - **Claimable** — same source as [`reward balance`](../reward/balance.md); claim with [`reward withdraw`](../reward/withdraw.md). ## Options @@ -44,7 +44,7 @@ wallet-cli vote status --account main --network tron:nile -o json ``` ```json -{"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"}} +{"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":["400 votes on TT5... (Binance Staking) earn nothing: reward ratio is 0%"]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} ``` ## Output @@ -56,7 +56,8 @@ wallet-cli vote status --account main --network tron:nile -o json | `claimableRewardSun` | string | Currently claimable reward, in SUN | | `votes[]` | array | Current distribution: `witness`, `name`, `count`, `rewardRatioPct`, `brokeragePct`, `aprPct` | -Zero-reward-ratio warnings appear in `meta.warnings` as `{code: "zero_reward_ratio", message}`. +Zero-reward-ratio warnings appear in `meta.warnings` as plain strings — see +[reading `meta.warnings`](../../machine-interface.md#reading-metawarnings). ## Exit status diff --git a/ts/docs/concepts/security.md b/ts/docs/concepts/security.md index fb038d125..31edb9cc2 100644 --- a/ts/docs/concepts/security.md +++ b/ts/docs/concepts/security.md @@ -23,6 +23,24 @@ Corollary for scripts: source the piped secret from a secret store, not from a t Unexpected internal exceptions are collapsed to a generic `internal_error` message before reaching the output envelope, so a third-party library error that happens to echo key material can never leak through a result or a log that captured it. +## Terminal output cannot be spoofed by chain data + +Several fields you read before approving something are controlled by whoever wrote them on chain or +by a third-party service — permission names, token names and symbols, co-signer labels. In text +mode every output frame is neutralised before it reaches your terminal: ANSI/OSC control bytes are +stripped, so a crafted name cannot repaint the screen, and invisible formatting characters are +replaced by a visible escape such as ``. + +That escape is the important one. `U+202E` reverses the display order of everything after it, which +would let a permission name change how the address or weight printed beside it appears; the +zero-width characters can make two different names look identical. Seeing `` or `` +in a name means the underlying string really contains it — treat it as a red flag, not a rendering +glitch. Ordinary right-to-left text (Arabic, Hebrew) contains no such characters and displays +normally. + +JSON mode is never rewritten — machine consumers get the bytes exactly as they came, and are +expected to do their own neutralising before display. + ## Files that contain secrets `backup` writes secret + metadata with file mode **0600** and never overwrites an existing file. After exporting: move it to your secure storage and treat the file exactly like the key it contains — it is outside wallet-cli's protection from that moment. diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index ec72528fe..fe8bf3153 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -62,11 +62,25 @@ Schema id: `wallet-cli.result.v1`. | `error.message` | string | error only | Human-readable; **not** stable — never parse it | | `error.details` | object | optional | Structured extras when available | | `meta.durationMs` | number | always | Wall time | -| `meta.warnings` | string[] | always | Non-fatal notices | +| `meta.warnings` | `(string \| {code, message})[]` | always | Non-fatal notices; **elements are not uniformly typed** — see below | | `chain` | object | chain commands only | `family` / `network` / `chainId`; neutral commands (`list`, `config`, …) omit it | Encoding rules: `bigint` values are serialized as decimal **strings** (e.g. `"balance": "1976489000"`), binary as hex. Treat every on-chain amount as a string. +### Reading `meta.warnings` + +An entry is either a plain string or a `{code, message}` object. The object form is used where the condition is worth branching on — currently the [`permission update`](commands/permission/update.md#safety-warnings) safety warnings and the post-confirmation checks; everything else is a bare string. Normalise before display, and never assume a uniform element type: + +```bash +# text for humans — works for both forms +jq -r '.meta.warnings[] | if type == "string" then . else .message end' + +# branch on a specific condition — objects only +jq -e '.meta.warnings[] | select(type == "object" and .code == "owner_lockout")' >/dev/null && exit 1 +``` + +`join`-style helpers that assume strings (`.meta.warnings | join("\n")`, `Array.prototype.join`) will fail or print `[object Object]` on the object form. Warning `code` values are stable and additive within v1 — new codes may appear, existing ones keep their meaning. Warning `message` text is **not** stable; treat it like `error.message` and never parse it. + ## Error codes The **exit code is the hard contract**: `2` means the call was malformed (it will still be wrong on retry), `1` means execution failed (network / device / chain / wallet). `error.code` is a machine-readable string that refines the exit code — branch on the exit code first, then optionally on `error.code`. The code set is **open and non-exhaustive**: it grows as commands are added, and a few strings (e.g. `invalid_value`, `aborted`) can appear under either exit code depending on where they are raised. Always tolerate an unknown code by falling back to its exit-code class. @@ -85,7 +99,9 @@ Common codes at exit **2** (usage — fix the call): | `tty_required` | An interactive prompt is needed but no TTY is attached — pass the matching `*-stdin` flag | | `missing_network` / `unsupported_network` | `--network` absent, or not a known canonical id | | `unknown_command` | No such command | -| `output_exists` | Target file already exists and is never overwritten (e.g. `backup --out`) | +| `output_exists` | Target file already exists and is never overwritten (`backup --out`, `address generate --out`). Deterministic — retrying the same path always fails | +| `invalid_config` | `config.yaml` cannot be read or is not valid YAML — fix or remove the file. The underlying parser detail is withheld: it quotes the offending line, which may carry a credential | +| `insecure_config` | `config.yaml` holds service credentials but is a symlink or is group/world-readable — run `chmod 600` on it (POSIX only; not enforced on Windows) | | `token_not_in_book` / `token_is_official` / `token_metadata_unavailable` | Token address-book conditions | Common codes at exit **1** (execution — runtime failure): @@ -131,6 +147,8 @@ This is a wallet; a wrong success check loses money. The rules: 2. To block until the outcome is known, pass `--wait` (polls until confirmed/failed, capped by `--wait-timeout`, default 60000 ms; on cap it returns the submitted receipt). + **A `--wait` receipt reports the transaction outcome in `data.stage`, never in `success`.** A transaction that was accepted, mined, and then reverted is a *successful command* carrying a *failed transaction*: the envelope stays `success: true` and the exit code stays `0`, while `data.stage` is `"failed"`. Exit codes say whether the CLI could carry out your request, not whether the chain accepted the result — so after any `--wait`, branch on `data.stage` (`confirmed` / `failed` / `submitted`) before recording the operation as done. This holds for every `--wait` receipt, including [`gasfree transfer`](commands/gasfree/transfer.md), which additionally mirrors the provider's terminal state in `data.state` (`SUCCEED` / `FAILED`) and explains it in `data.failureReason`. + 3. Or poll yourself with `tx status`, which has a **four-state model**: | `data.state` | Meaning | Terminal? | diff --git a/ts/src/adapters/inbound/boundary.test.ts b/ts/src/adapters/inbound/boundary.test.ts new file mode 100644 index 000000000..7a260036f --- /dev/null +++ b/ts/src/adapters/inbound/boundary.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; + +/** + * depcruise already forbids inbound → outbound, but it only sees the graph that survives + * compilation: `import type` is erased, so a type-only edge across the boundary is invisible to it + * (switching on `tsPreCompilationDeps` would catch it, but it also closes several type-only cycles + * through the domain barrels and would fail `no-circular` for reasons unrelated to any boundary). + * + * The edge still matters. It names a concrete implementation where a port belongs, and it is the + * thing a later refactor quietly turns into a runtime dependency — which is exactly how + * `commands/tx.ts` came to reference the outbound `TransactionArtifactWriter` class directly. + */ +describe("inbound adapters do not name outbound implementations", () => { + const INBOUND = new URL("./", import.meta.url).pathname; + + function sources(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() + ? sources(join(directory, entry.name)) + : entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") + ? [join(directory, entry.name)] + : [] + ); + } + + it("has no import of adapters/outbound, type-only included", () => { + const offenders = sources(INBOUND) + .map((path) => ({ path, text: readFileSync(path, "utf8") })) + .filter(({ text }) => /from\s+"[^"]*adapters\/outbound\//.test(text) + || /from\s+"(?:\.\.\/)+outbound\//.test(text)) + .map(({ path }) => relative(INBOUND, path)); + + expect(offenders).toEqual([]); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts index 83e12e749..872ebc8d8 100644 --- a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts +++ b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; import { z } from "zod"; import { permissionUpdateSpec } from "./permission.js"; import { txModeFields } from "./shared.js"; @@ -27,3 +29,32 @@ describe("transaction option argv coercion", () => { expect(parsed.expiration).toBe(60_000); }); }); + +// `txModeFields` is shared, so adding a flag to it silently widens 14 commands at once while their +// individual reference pages stay behind — which is exactly how --build-only / --permission-id / +// --expiration ended up documented on only 3 of the 14. A page that documents the transaction modes +// at all must document all of them. +describe("reference pages keep up with the shared transaction options", () => { + const DOCS = new URL("../../../../../docs/commands/", import.meta.url).pathname; + const REQUIRED = ["--build-only", "--permission-id", "--expiration"]; + + function pages(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() + ? pages(join(directory, entry.name)) + : entry.name.endsWith(".md") ? [join(directory, entry.name)] : [] + ); + } + + it("every page offering --sign-only also documents the rest of the shared modes", () => { + const behind = pages(DOCS) + .map((path) => ({ path, text: readFileSync(path, "utf8") })) + // An Options-table ROW for --sign-only means the command itself carries the shared object; + // prose mentions (tx/index, tx/sign) merely refer to other commands and must not be flagged. + .filter(({ text }) => /^\| `--sign-only`/m.test(text)) + .filter(({ text }) => REQUIRED.some((flag) => !text.includes(flag))) + .map(({ path }) => relative(DOCS, path)); + + expect(behind).toEqual([]); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index 0d06116cc..dbcef9b90 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -5,7 +5,7 @@ import type { TronTransactionService } from "../../../../application/use-cases/t import type { TronSigService } from "../../../../application/use-cases/tron/sig-service.js"; import type { TronMultisigService } from "../../../../application/use-cases/tron/multisig-service.js"; import type { TronMultisigCollaborationService } from "../../../../application/use-cases/tron/multisig-collaboration-service.js"; -import type { TransactionArtifactWriter } from "../../../outbound/persistence/transaction-artifact-writer.js"; +import type { TransactionArtifactWriter } from "../../../../application/ports/transaction-artifact-writer.js"; import { Schemas } from "../schemas/index.js"; import { amountSelector, diff --git a/ts/src/adapters/inbound/cli/output/output.test.ts b/ts/src/adapters/inbound/cli/output/output.test.ts index 6b11fc801..76e09f7c0 100644 --- a/ts/src/adapters/inbound/cli/output/output.test.ts +++ b/ts/src/adapters/inbound/cli/output/output.test.ts @@ -179,3 +179,58 @@ describe("createOutputFormatter (text)", () => { expect(text).not.toContain("000000"); }); }); + +// Chain-controlled strings (permission names, token metadata, TronLink signer labels) reach the +// security summaries a user reads before approving. Bidi controls are not control BYTES, so the +// C0/C1 strip lets them through — and U+202E reorders everything printed after it. +describe("invisible formatting in untrusted display fields", () => { + const RLO = String.fromCharCode(0x202E); + const LRI = String.fromCharCode(0x2066); + const ZWSP = String.fromCharCode(0x200B); + + it("marks bidi controls in text mode so reordering cannot hide", () => { + const { sm } = capture("text"); + const f = createOutputFormatter("text", sm, 0); + const text = f.success(commandId(cmd), net, { label: `Treasury${RLO} ecnanetniam` }); + expect(text).not.toContain(RLO); + expect(text).toContain(""); + }); + + it("marks isolates and zero-width characters too", () => { + const { sm } = capture("text"); + const f = createOutputFormatter("text", sm, 0); + const text = f.success(commandId(cmd), net, { label: `a${LRI}b${ZWSP}c` }); + expect(text).toContain(""); + expect(text).toContain(""); + expect(text).not.toContain(LRI); + expect(text).not.toContain(ZWSP); + }); + + it("leaves ordinary right-to-left text alone", () => { + const { sm } = capture("text"); + const f = createOutputFormatter("text", sm, 0); + // implicit bidi comes from the characters' own properties — nothing to neutralize + const text = f.success(commandId(cmd), net, { label: "Acme \u0645\u0631\u062d\u0628\u0627" }); + expect(text).toContain("Acme \u0645\u0631\u062d\u0628\u0627"); + expect(text).not.toContain(" { + const { sm } = capture("json"); + const f = createOutputFormatter("json", sm, 0); + const env = JSON.parse(f.success(commandId(cmd), net, { label: `Treasury${RLO}x` })); + expect(env.data.label).toBe(`Treasury${RLO}x`); + }); + + it("also covers error lines and progress events", () => { + const { sm, err } = capture("text"); + createOutputFormatter("text", sm, 0) + .error(new UsageError("invalid_value", `bad ${RLO} value`)); + expect(err[0]).toContain(""); + + const { sm: sm2 } = capture("text"); + const frame = createOutputFormatter("text", sm2, 0) + .event({ type: "pre-verify-address", address: `T1${RLO}abc` }); + expect(frame).toContain(""); + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/scalars.ts b/ts/src/adapters/inbound/cli/render/scalars.ts index 8bab44849..229e1c2d3 100644 --- a/ts/src/adapters/inbound/cli/render/scalars.ts +++ b/ts/src/adapters/inbound/cli/render/scalars.ts @@ -95,7 +95,24 @@ export function methodName(sig: string): string { // Built via RegExp so the source file itself carries no raw control bytes. const CONTROL_BYTES = new RegExp("[\\u0000-\\u0009\\u000B-\\u001F\\u007F-\\u009F]", "g"); +// Bidi and other invisible formatting characters are NOT control bytes — they pass straight through +// the strip above — and for a security display they are the more dangerous half. U+202E reverses +// the visible order of everything after it, so a chain-controlled permission name can make the +// address or weight printed beside it read as something else; the zero-width characters can make +// two different names look identical. These are MARKED rather than dropped: dropping them would +// silently mangle legitimate right-to-left text, whereas an escape keeps the string honest and +// makes tampering obvious. Built via RegExp so this file carries no raw formatting characters. +const INVISIBLE_FORMATTING = new RegExp( + "[\\u061C\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF]", + "g", +); + /** strip terminal control bytes from a text-mode output frame (never applied in JSON mode). */ export function sanitizeText(s: string): string { - return s.replace(CONTROL_BYTES, ""); + return s + .replace(CONTROL_BYTES, "") + .replace( + INVISIBLE_FORMATTING, + (c) => ``, + ); } diff --git a/ts/src/adapters/outbound/chain/tron/transaction-codec.deploy-address.test.ts b/ts/src/adapters/outbound/chain/tron/transaction-codec.deploy-address.test.ts new file mode 100644 index 000000000..2495ba438 --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/transaction-codec.deploy-address.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { refreshTransactionIdentity } from "./transaction-codec.js"; + +const OWNER = "411111111111111111111111111111111111111111"; +const STALE = "41dead00000000000000000000000000000000beef"; + +function deployment(overrides: Record = {}) { + return { + visible: false, + txID: "", + raw_data_hex: "", + contract_address: STALE, + raw_data: { + contract: [{ + type: "CreateSmartContract", + parameter: { + type_url: "type.googleapis.com/protocol.CreateSmartContract", + value: { + owner_address: OWNER, + new_contract: { + origin_address: OWNER, + abi: { entrys: [] }, + bytecode: "6080604052", + consume_user_resource_percent: 100, + origin_energy_limit: 10_000_000, + }, + }, + }, + }], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: 1_900_000_000_000, + expiration: 1_900_000_060_000, + fee_limit: 1_000_000_000, + ...overrides, + }, + }; +} + +describe("refreshTransactionIdentity — deployment address", () => { + it("re-derives contract_address from the refreshed txID", () => { + const refreshed = refreshTransactionIdentity(deployment()); + // 41 ‖ keccak256(txID ‖ owner_address)[12..] — the rule java-tron applies on execution. + expect(refreshed.contract_address).toMatch(/^41[0-9a-f]{40}$/); + expect(refreshed.contract_address).not.toBe(STALE); + }); + + it("moves the address whenever the mutation moves the txID", () => { + const base = refreshTransactionIdentity(deployment()); + const rebound = refreshTransactionIdentity({ + ...deployment(), + raw_data: { + ...deployment().raw_data, + contract: [{ ...deployment().raw_data.contract[0]!, Permission_id: 2 }], + expiration: 1_900_086_400_000, + }, + }); + expect(rebound.txID).not.toBe(base.txID); + expect(rebound.contract_address).not.toBe(base.contract_address); + }); + + it("leaves transactions that carry no deployment address untouched", () => { + const { contract_address: _omitted, ...withoutAddress } = deployment(); + expect(refreshTransactionIdentity(withoutAddress).contract_address).toBeUndefined(); + }); + + it("refuses to derive an address when the owner is absent", () => { + const broken = deployment(); + delete (broken.raw_data.contract[0]!.parameter.value as { owner_address?: string }).owner_address; + expect(() => refreshTransactionIdentity(broken)).toThrowError(/owner_address/); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts b/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts index f2757efef..6acabab18 100644 --- a/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts +++ b/ts/src/adapters/outbound/chain/tron/transaction-codec.test.ts @@ -94,4 +94,32 @@ describe("TRON complete transaction hex codec", () => { expect(() => encodeTransactionHex({ ...transaction, raw_data_hex: "00" })).toThrowError(/raw_data_hex does not match/); expect(() => encodeTransactionHex({ ...transaction, signature: ["aa"] })).toThrowError(/65 bytes/); }); + + // TronWeb's deserializer rejects these four by name, so an artifact carrying one can never satisfy + // the raw_data round-trip. They are refused either way — but a named type produces an actionable + // message, so type 51 belongs in the table even though it will never decode. + it("names the undecodable contract types instead of reporting an unknown id", () => { + const transaction = fixture("TransferContract", { owner_address: OWNER, to_address: OTHER, amount: 1 }); + const hex = encodeTransactionHex({ ...transaction, signature: undefined }); + const proto = (globalThis as unknown as { + TronWebProto: { Transaction: { deserializeBinary(b: Uint8Array): { + getRawData(): { getContractList(): Array<{ setType(id: number): void }> }; + serializeBinary(): Uint8Array; + } } }; + }).TronWebProto.Transaction; + + const retyped = (id: number): string => { + const pb = proto.deserializeBinary(Uint8Array.from(Buffer.from(hex, "hex"))); + pb.getRawData().getContractList()[0]!.setType(id); + return Buffer.from(pb.serializeBinary()).toString("hex"); + }; + + expect(() => decodeTransactionHex(retyped(51))) + .toThrowError(/ShieldedTransferContract cannot be decoded losslessly/); + expect(() => decodeTransactionHex(retyped(52))) + .toThrowError(/MarketSellAssetContract cannot be decoded losslessly/); + // an id outside the catalogue still reports as unknown + expect(() => decodeTransactionHex(retyped(200))) + .toThrowError(/unsupported TRON contract type id 200/); + }); }); diff --git a/ts/src/adapters/outbound/chain/tron/transaction-codec.ts b/ts/src/adapters/outbound/chain/tron/transaction-codec.ts index 161c526db..ddea4d452 100644 --- a/ts/src/adapters/outbound/chain/tron/transaction-codec.ts +++ b/ts/src/adapters/outbound/chain/tron/transaction-codec.ts @@ -49,6 +49,10 @@ const CONTRACT_TYPE_BY_ID: Readonly> = Object.freeze({ 46: "AccountPermissionUpdateContract", 48: "ClearABIContract", 49: "UpdateBrokerageContract", + // 14/51/52/53 are named but NOT decodable by the bundled TronWeb (its deserializer throws + // "not supported"). They stay in the table so an artifact carrying one is refused as + // " cannot be decoded losslessly" rather than the opaque "unsupported contract type id". + 51: "ShieldedTransferContract", 52: "MarketSellAssetContract", 53: "MarketCancelOrderContract", 54: "FreezeBalanceV2Contract", @@ -198,7 +202,25 @@ export function decodeTransactionHex(input: string): TronTransactionArtifact { return transaction; } -/** Recompute txID and raw_data_hex after a deliberate unsigned raw_data mutation. */ +/** + * TRON derives a deployed contract's address from the transaction that creates it: + * `41 ‖ keccak256(txID ‖ owner_address)[12..]`. It is therefore part of the transaction identity, + * not an independent field — any raw_data mutation moves the contract, so a carried-over + * `contract_address` would name a contract that never gets deployed. + */ +function deriveContractAddress(txId: string, contract: { parameter?: { value?: Record } }): string { + const owner = contract.parameter?.value?.owner_address; + if (typeof owner !== "string" || !/^41[0-9a-fA-F]{40}$/.test(owner)) { + return invalidTransaction("CreateSmartContract owner_address must be a 21-byte hex address"); + } + const preimage = Uint8Array.from( + (txId + owner.toLowerCase()).match(/../g)!.map((byte) => parseInt(byte, 16)), + ); + return `41${tronUtils.ethersUtils.keccak256(preimage).slice(2).slice(24)}`; +} + +/** Recompute txID, raw_data_hex, and any derived contract address after a deliberate unsigned + * raw_data mutation. */ export function refreshTransactionIdentity(transaction: unknown): TronTransactionArtifact { if (!transaction || typeof transaction !== "object") { return invalidTransaction("transaction must be an object"); @@ -219,6 +241,10 @@ export function refreshTransactionIdentity(transaction: unknown): TronTransactio txID: tronUtils.transaction.txPbToTxID(pb).replace(/^0x/i, "").toLowerCase(), raw_data_hex: tronUtils.transaction.txPbToRawDataHex(pb).toLowerCase(), }; + const deployment = refreshed.raw_data.contract[0]; + if (typeof refreshed.contract_address === "string" && deployment?.type === "CreateSmartContract") { + refreshed.contract_address = deriveContractAddress(refreshed.txID, deployment); + } decodeTransactionHex(encodeTransactionHex(refreshed)); return refreshed; } diff --git a/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts b/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts index cce4223dd..d7723a0f5 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts @@ -14,18 +14,55 @@ const dynamicString = (text: string) => { const bytes32 = (text: string) => Buffer.from(text, "utf8").toString("hex").padEnd(64, "0"); +/** + * Node response shapes, as measured against a live Nile node rather than assumed: + * + * method implemented → { result: { result: true } }, constant_result: [""] + * method NOT there → { result: { result: true, message: "REVERT opcode executed" } }, + * constant_result: [""] ← still result:true + * address is no contract → { result: { code: "CONTRACT_VALIDATE_ERROR", … } }, no constant_result + * node unreachable → the request itself rejects + * + * The second row is the one that matters: a missing view method is not an error at this layer, so + * it needs no rescue — it decodes to undefined on its own. + */ const stub = (responses: Record) => { const client = new TronRpcClient("http://localhost:1", 200); client.tronweb.transactionBuilder.triggerConstantContract = (( _contract: string, fn: string, ) => { const hex = responses[fn]; - if (hex === undefined) return Promise.resolve({ result: { result: false, message: "" } }); + if (hex === undefined) { + return Promise.resolve({ + result: { result: true, message: "REVERT opcode executed" }, + constant_result: [""], + }); + } return Promise.resolve({ result: { result: true }, constant_result: [hex] }); }) as never; return client; }; +/** + * No contract at that address. The node reports `CONTRACT_VALIDATE_ERROR` in a 200 body, but tronweb + * turns that into a rejection rather than handing it back — verified against a live Nile node, where + * an EOA address surfaces as `rpc_error`, not `execution_error`. + */ +const notAContract = () => { + const client = new TronRpcClient("http://localhost:1", 200); + client.tronweb.transactionBuilder.triggerConstantContract = (() => + Promise.reject(new Error("Smart contract is not exist."))) as never; + return client; +}; + +/** the request never reached a node. */ +const unreachable = () => { + const client = new TronRpcClient("http://localhost:1", 200); + client.tronweb.transactionBuilder.triggerConstantContract = (() => + Promise.reject(new Error("connect ECONNREFUSED 127.0.0.1:1"))) as never; + return client; +}; + describe("TronRpcClient.getTokenInfo", () => { it("decodes metadata from view calls without relying on a published ABI", async () => { const client = stub({ @@ -60,7 +97,7 @@ describe("TronRpcClient.getTokenInfo", () => { }); }); - it("leaves fields undefined when their view call reverts", async () => { + it("leaves fields undefined when the contract does not implement the view", async () => { const client = stub({ "symbol()": dynamicString("USDT") }); await expect(client.getTokenInfo(CONTRACT)).resolves.toEqual({ @@ -71,4 +108,16 @@ describe("TronRpcClient.getTokenInfo", () => { totalSupply: undefined, }); }); + + // A node outage used to surface as "this token has no metadata", which GasFree then escalated to + // gasfree_integrity ("the provider is lying") and tx send reported as a usage error at exit 2. + it("propagates a transport failure instead of reporting absent metadata", async () => { + await expect(unreachable().getTokenInfo(CONTRACT)) + .rejects.toMatchObject({ code: "rpc_error", kind: "execution" }); + }); + + it("propagates 'no contract at this address' rather than blanking the fields", async () => { + await expect(notAContract().getTokenInfo(CONTRACT)) + .rejects.toMatchObject({ code: "rpc_error" }); + }); }); diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index f16ecb718..19632640b 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -438,8 +438,14 @@ export class TronRpcClient implements TronGateway, Broadcaster { return this.#wrap("trc20 tokenInfo", async () => { // Read the view methods by selector rather than via contract().at(): tokens deployed without a // published ABI (e.g. USDD on Nile) resolve to a contract object with no methods at all. + // + // No catch here on purpose. A method the contract does not implement is NOT an error at this + // layer — the node answers `result: true` with an empty `constant_result`, which decodes to + // undefined on its own. The only failures that reach this point are a transport fault and + // "no contract at this address", and swallowing either would report a node outage as missing + // token metadata — which callers then escalate into far more specific (and wrong) claims. const read = async (fn: string): Promise => - this.#constant(contract, fn, []).then(([hex]) => hex).catch(() => undefined); + this.#constant(contract, fn, []).then(([hex]) => hex); const [name, symbol, decimals, totalSupply] = await Promise.all([ read("name()"), read("symbol()"), read("decimals()"), read("totalSupply()"), ]); diff --git a/ts/src/adapters/outbound/config/config.test.ts b/ts/src/adapters/outbound/config/config.test.ts index 51c55193d..606994e8a 100644 --- a/ts/src/adapters/outbound/config/config.test.ts +++ b/ts/src/adapters/outbound/config/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { ConfigLoader, NetworkRegistry } from "./index.js"; @@ -104,3 +104,38 @@ describe("ConfigLoader GasFree credentials", () => { }, ); }); + +// A broken config.yaml is the user's typo, not an internal fault — but the underlying errors quote +// file content (YAML parse) or OS detail, and a credential can sit on the very line that failed. +describe("ConfigLoader unreadable/malformed config", () => { + it("classifies malformed YAML without echoing the file content", () => { + const env = envWithConfig([ + 'gasfreeApiSecret: "SUPERSECRET123"', + 'defaultNetwork: "unterminated', + " bad: [1,2", + "", + ].join("\n"), 0o600); + + let thrown: unknown; + try { + ConfigLoader.load(env); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ code: "invalid_config", kind: "usage" }); + expect((thrown as Error).message).toMatch(/not valid YAML/); + expect((thrown as Error).message).not.toContain("SUPERSECRET123"); + expect((thrown as Error).message).not.toContain("bad: [1,2"); + }); + + it("classifies a config path that cannot be read as a file", () => { + // a directory named config.yaml — deterministic across platforms and irrespective of uid, + // unlike chmod 000, which root would sail straight through. + const root = mkdtempSync(join(tmpdir(), "wcli-config-")); + mkdirSync(join(root, "config.yaml")); + + expect(() => ConfigLoader.load({ ...process.env, WALLET_CLI_HOME: root })) + .toThrow(/cannot be read/); + }); +}); diff --git a/ts/src/adapters/outbound/config/index.ts b/ts/src/adapters/outbound/config/index.ts index 5e5584e66..fa34ad422 100644 --- a/ts/src/adapters/outbound/config/index.ts +++ b/ts/src/adapters/outbound/config/index.ts @@ -43,7 +43,7 @@ export class ConfigLoader { const path = ConfigLoader.configPath(env); if (existsSync(path)) { - const raw = parseYaml(readFileSync(path, "utf8")) ?? {}; + const raw = readConfigDocument(path); if ( (typeof raw.tronlinkSecretKey === "string" && raw.tronlinkSecretKey !== "") || (typeof raw.gasfreeApiSecret === "string" && raw.gasfreeApiSecret !== "") @@ -98,6 +98,28 @@ function validCredential(value: unknown): value is string { && !/[\u0000-\u001f\u007f]/.test(value); } +/** + * Read config.yaml, reporting the condition rather than the underlying error. + * + * Both failures carry material we must not surface: a YAML parse error quotes the offending line, + * which may sit right beside `gasfreeApiSecret`, and a read error carries whatever the OS put in + * its message. Classifying here also keeps the user out of the generic `internal_error` they would + * otherwise get from the bootstrap boundary for what is simply a broken file. + */ +function readConfigDocument(path: string) { + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch { + throw new UsageError("invalid_config", `config.yaml cannot be read: ${path}`); + } + try { + return parseYaml(text) ?? {}; + } catch { + throw new UsageError("invalid_config", `config.yaml is not valid YAML: ${path}`); + } +} + function assertSecretConfigPermissions(path: string): void { if (process.platform === "win32") return; if (lstatSync(path).isSymbolicLink()) { diff --git a/ts/src/adapters/outbound/persistence/keypair-writer.test.ts b/ts/src/adapters/outbound/persistence/keypair-writer.test.ts index 5045cea46..def1a1685 100644 --- a/ts/src/adapters/outbound/persistence/keypair-writer.test.ts +++ b/ts/src/adapters/outbound/persistence/keypair-writer.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { + existsSync, lstatSync, mkdtempSync, readFileSync, @@ -10,6 +11,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { SecureKeypairWriter } from "./keypair-writer.js"; +import type { UsageError } from "../../../domain/errors/index.js"; const roots: string[] = []; @@ -50,6 +52,48 @@ describe("SecureKeypairWriter", () => { expect(readFileSync(path, "utf8")).toBe("original"); }); + // O_EXCL creates the file before the content is written, so a failure in between used to leave an + // empty artifact at the final path — and then O_EXCL rejected every retry of the same command. + it("removes its own unfinished file so the same path can be retried", () => { + const directory = root(); + const path = join(directory, "key.json"); + const writer = new SecureKeypairWriter(); + + // fails inside the try, after the file exists: JSON.stringify cannot serialize a BigInt + expect(() => writer.write(path, { privateKey: 1n })) + .toThrow(/could not write keypair file/); + expect(existsSync(path)).toBe(false); + + expect(writer.write(path, { privateKey: "secret" })).toBe(path); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ privateKey: "secret" }); + }); + + it("cleans up only files it created, never a pre-existing target", () => { + const directory = root(); + const path = join(directory, "key.json"); + writeFileSync(path, "original", { mode: 0o600 }); + + expect(() => new SecureKeypairWriter().write(path, { privateKey: 1n })) + .toThrow(/refusing to overwrite/); + expect(readFileSync(path, "utf8")).toBe("original"); + }); + + // The identical O_EXCL conflict in SecureBackupWriter reports output_exists / UsageError / exit 2. + // A deterministic conflict must not look retryable in one command and fatal in another. + it("reports an existing target with the shared output-conflict contract", () => { + const directory = root(); + const path = join(directory, "key.json"); + writeFileSync(path, "original", { mode: 0o600 }); + + try { + new SecureKeypairWriter().write(path, { privateKey: "replacement" }); + expect.unreachable("expected the write to be refused"); + } catch (error) { + expect(error).toMatchObject({ code: "output_exists", kind: "usage" }); + expect((error as UsageError).exitCode()).toBe(2); + } + }); + it("never follows an existing final-path symlink", () => { const directory = root(); const external = join(directory, "external.json"); diff --git a/ts/src/adapters/outbound/persistence/keypair-writer.ts b/ts/src/adapters/outbound/persistence/keypair-writer.ts index 2bfa38c69..432246cbf 100644 --- a/ts/src/adapters/outbound/persistence/keypair-writer.ts +++ b/ts/src/adapters/outbound/persistence/keypair-writer.ts @@ -5,10 +5,11 @@ import { fsyncSync, mkdirSync, openSync, + unlinkSync, writeFileSync, } from "node:fs"; import { dirname, resolve } from "node:path"; -import { ExecutionError } from "../../../domain/errors/index.js"; +import { ExecutionError, UsageError } from "../../../domain/errors/index.js"; import type { KeypairWriter } from "../../../application/ports/keypair-writer.js"; /** Exclusive, no-follow writer for plaintext private-key artifacts. */ @@ -17,6 +18,7 @@ export class SecureKeypairWriter implements KeypairWriter { const target = resolve(path); mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); let descriptor: number | undefined; + let created = false; try { descriptor = openSync( target, @@ -26,6 +28,9 @@ export class SecureKeypairWriter implements KeypairWriter { | (constants.O_NOFOLLOW ?? 0), 0o600, ); + // O_EXCL guarantees this open is what brought the file into existence, so from here on a + // failure leaves OUR unfinished file behind — and it is ours to remove. + created = true; fchmodSync(descriptor, 0o600); writeFileSync( descriptor, @@ -47,12 +52,23 @@ export class SecureKeypairWriter implements KeypairWriter { } catch (error) { if (descriptor !== undefined) closeSync(descriptor); const code = (error as NodeJS.ErrnoException).code; + // Refusing an existing target is deterministic input, not a runtime fault — same code, class, + // and exit status the backup writer uses for the identical O_EXCL conflict. if (code === "EEXIST" || code === "ELOOP") { - throw new ExecutionError( - "file_exists", - `refusing to overwrite keypair file: ${target}`, + throw new UsageError( + "output_exists", + `refusing to overwrite existing file: ${target}`, ); } + // Without this, a half-written file stays at the final path and O_EXCL rejects every retry + // with output_exists — a transient disk error would become a permanent, manual-fix deadlock. + if (created) { + try { + unlinkSync(target); + } catch { + // best effort: the write error below is the one worth reporting + } + } throw new ExecutionError( "io_error", `could not write keypair file: ${target}`, diff --git a/ts/src/adapters/outbound/persistence/transaction-artifact-writer.test.ts b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.test.ts index 412022f33..09a533cfd 100644 --- a/ts/src/adapters/outbound/persistence/transaction-artifact-writer.test.ts +++ b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.test.ts @@ -2,19 +2,19 @@ import { afterEach, describe, expect, it } from "vitest"; import { lstatSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { TransactionArtifactWriter } from "./transaction-artifact-writer.js"; +import { SecureTransactionArtifactWriter } from "./transaction-artifact-writer.js"; const dirs: string[] = []; afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); -describe("TransactionArtifactWriter", () => { +describe("SecureTransactionArtifactWriter", () => { it("atomically writes a newline-terminated 0644 artifact", () => { const dir = mkdtempSync(join(tmpdir(), "wallet-cli-tx-")); dirs.push(dir); const path = join(dir, "signed.hex"); - new TransactionArtifactWriter().write(path, "abcd"); + new SecureTransactionArtifactWriter().write(path, "abcd"); expect(readFileSync(path, "utf8")).toBe("abcd\n"); if (process.platform !== "win32") expect(lstatSync(path).mode & 0o777).toBe(0o644); }); @@ -25,6 +25,6 @@ describe("TransactionArtifactWriter", () => { const target = join(dir, "target"); const link = join(dir, "signed.hex"); symlinkSync(target, link); - expect(() => new TransactionArtifactWriter().write(link, "abcd")).toThrowError(/symbolic-link/); + expect(() => new SecureTransactionArtifactWriter().write(link, "abcd")).toThrowError(/symbolic-link/); }); }); diff --git a/ts/src/adapters/outbound/persistence/transaction-artifact-writer.ts b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.ts index e484253dc..77097f81f 100644 --- a/ts/src/adapters/outbound/persistence/transaction-artifact-writer.ts +++ b/ts/src/adapters/outbound/persistence/transaction-artifact-writer.ts @@ -13,9 +13,10 @@ import { import { randomBytes } from "node:crypto"; import { dirname } from "node:path"; import { ExecutionError } from "../../../domain/errors/index.js"; +import type { TransactionArtifactWriter } from "../../../application/ports/transaction-artifact-writer.js"; /** Atomic, non-secret transaction artifact writer. Published files are mode 0644. */ -export class TransactionArtifactWriter { +export class SecureTransactionArtifactWriter implements TransactionArtifactWriter { write(path: string, hex: string): void { if (!path) throw new ExecutionError("io_error", "transaction artifact path is empty"); try { diff --git a/ts/src/application/ports/transaction-artifact-writer.ts b/ts/src/application/ports/transaction-artifact-writer.ts new file mode 100644 index 000000000..7077f8358 --- /dev/null +++ b/ts/src/application/ports/transaction-artifact-writer.ts @@ -0,0 +1,4 @@ +/** Outbound boundary for publishing a non-secret transaction artifact (complete protobuf hex). */ +export interface TransactionArtifactWriter { + write(path: string, hex: string): void; +} diff --git a/ts/src/application/services/post-check.ts b/ts/src/application/services/post-check.ts new file mode 100644 index 000000000..677c89beb --- /dev/null +++ b/ts/src/application/services/post-check.ts @@ -0,0 +1,33 @@ +import { redactErrorMessage } from "../../domain/errors/redact.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; + +/** + * Run a verification read AFTER the transaction is already confirmed on chain. + * + * By this point the operation is irreversible and has usually cost a fee. A timeout, a 429, or a + * node that has not caught up yet must never turn that confirmed receipt into a command failure: + * callers would retry an operation that already succeeded. Both an unreadable check and a genuine + * mismatch degrade to a warning so the txid always survives — the same rule TxPipeline applies to + * its `confirm` hook, which these post-checks sit outside of. + * + * `check` resolves to a mismatch description, or undefined when the state verified cleanly. + */ +export async function warnOnPostCheck( + scope: TransactionScope, + code: string, + check: () => Promise, +): Promise { + let mismatch: string | undefined; + try { + mismatch = await check(); + } catch (error) { + scope.warn({ + code: `${code}_unavailable`, + message: `the transaction is confirmed on chain, but the follow-up check could not be read: ${ + redactErrorMessage(error instanceof Error ? error.message : String(error)) + }`, + }); + return; + } + if (mismatch) scope.warn({ code: `${code}_mismatch`, message: mismatch }); +} diff --git a/ts/src/application/use-cases/tron/account-service.test.ts b/ts/src/application/use-cases/tron/account-service.test.ts index c00824be8..35872313a 100644 --- a/ts/src/application/use-cases/tron/account-service.test.ts +++ b/ts/src/application/use-cases/tron/account-service.test.ts @@ -314,6 +314,90 @@ describe("TronAccountService account lifecycle writes", () => { })).resolves.toMatchObject({ field: "id", value: "账".repeat(10) }); }); + // The post-check runs AFTER the fee is paid and the transaction is confirmed. A lagging node or a + // transient RPC failure there must never replace the confirmed txid with a command failure — + // callers would retry an operation that already succeeded. + describe("post-check never overwrites a confirmed receipt", () => { + const CONFIRMED = { stage: "confirmed", txId: "tx-confirmed", failed: false }; + + /** first call is the pre-check, second is the post-check. */ + function twoReads( + first: () => Promise>, + second: () => Promise>, + ) { + let calls = 0; + return async () => (++calls === 1 ? first() : second()); + } + + it("activate: an unreadable post-check degrades to a warning", async () => { + const { service } = writeService({ + outcome: CONFIRMED, + getAccount: twoReads( + async () => ({}), + async () => { throw new Error("connect ETIMEDOUT https://nile.trongrid.io/wallet/getaccount"); }, + ), + }); + const ctx = transactionScope(); + + const result = await service.activate(ctx, net, { address: TARGET }); + + expect(result).toMatchObject({ kind: "account-activate", stage: "confirmed", txId: "tx-confirmed" }); + expect(ctx.warn).toHaveBeenCalledWith(expect.objectContaining({ + code: "account_activate_postcheck_unavailable", + })); + }); + + it("activate: a node that cannot see the account yet warns instead of throwing", async () => { + const { service } = writeService({ + outcome: CONFIRMED, + getAccount: twoReads(async () => ({}), async () => ({})), + }); + const ctx = transactionScope(); + + const result = await service.activate(ctx, net, { address: TARGET }); + + expect(result).toMatchObject({ stage: "confirmed", txId: "tx-confirmed" }); + expect(ctx.warn).toHaveBeenCalledWith(expect.objectContaining({ + code: "account_activate_postcheck_mismatch", + })); + }); + + it("set: an unreadable post-check degrades to a warning", async () => { + const { service } = writeService({ + outcome: CONFIRMED, + getAccount: twoReads( + async () => ({ address: OWNER }), + async () => { throw new Error("HTTP 429 Too Many Requests"); }, + ), + }); + const ctx = transactionScope(); + + const result = await service.setOnChain(ctx, net, { name: "Acme Treasury" }); + + expect(result).toMatchObject({ kind: "account-set", stage: "confirmed", txId: "tx-confirmed" }); + expect(ctx.warn).toHaveBeenCalledWith(expect.objectContaining({ + code: "account_set_postcheck_unavailable", + })); + }); + + it("the warning explains the state without leaking endpoint credentials", async () => { + const { service } = writeService({ + outcome: CONFIRMED, + getAccount: twoReads( + async () => ({}), + async () => { throw new Error("request to https://user:pw@node.example/wallet failed"); }, + ), + }); + const ctx = transactionScope(); + + await service.activate(ctx, net, { address: TARGET }); + + const [warning] = vi.mocked(ctx.warn).mock.calls[0]! as [{ message: string }]; + expect(warning.message).toContain("confirmed on chain"); + expect(warning.message).not.toContain("user:pw"); + }); + }); + it("fails a signing mode before RPC when the active account cannot sign", async () => { const { service, gateway, pipeline } = writeService(); vi.mocked(pipeline.assertCanSign).mockImplementation(() => { diff --git a/ts/src/application/use-cases/tron/account-service.ts b/ts/src/application/use-cases/tron/account-service.ts index 0cb781b97..8bca35ede 100644 --- a/ts/src/application/use-cases/tron/account-service.ts +++ b/ts/src/application/use-cases/tron/account-service.ts @@ -17,6 +17,7 @@ import { type TransactionModeInput, } from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { warnOnPostCheck } from "../../services/post-check.js"; import { tronTransactionHooks } from "./multisig-authorization.js"; const round6 = (value: number): number => Math.round(value * 1e6) / 1e6; @@ -124,13 +125,12 @@ export class TronAccountService { estimate: async () => ({ ...fee, balanceSun }), }); if (outcome.stage === "confirmed" && !outcome.failed) { - const activated = await gateway.getAccount(input.address); - if (!accountExists(activated, input.address)) { - throw new ChainError( - "provider_error", - "confirmed account activation is not visible from the selected node", - ); - } + await warnOnPostCheck(scope, "account_activate_postcheck", async () => { + const activated = await gateway.getAccount(input.address); + return accountExists(activated, input.address) + ? undefined + : "confirmed account activation is not visible from the selected node"; + }); } return { kind: "account-activate" as const, @@ -200,15 +200,14 @@ export class TronAccountService { }), }); if (outcome.stage === "confirmed" && !outcome.failed) { - const updated = await gateway.getAccount(address); - const raw = - field === "name" ? updated.account_name : updated.account_id; - if (decodeAccountText(raw, field) !== value) { - throw new ChainError( - "provider_error", - `confirmed account ${field} does not match the submitted value`, - ); - } + await warnOnPostCheck(scope, "account_set_postcheck", async () => { + const updated = await gateway.getAccount(address); + const raw = + field === "name" ? updated.account_name : updated.account_id; + return decodeAccountText(raw, field) === value + ? undefined + : `confirmed account ${field} does not match the submitted value`; + }); } return { kind: "account-set" as const, 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 51f21fd8d..8b94773bd 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 @@ -11,20 +11,35 @@ const SCOPE = {} as unknown as TransactionScope; const DEPLOY_INPUT = { abi: [], bytecode: "0x00", feeLimit: "1000000000", parameters: [] }; const CONTRACT_HEX = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"; const CONTRACT_B58 = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const PREPARED_HEX = "41b20b47cd2008731fd794274b736db24958f88edd"; +const PREPARED_B58 = "TSCcnz8iNBFDbccr4mfByskq8TDhCHLW3n"; -// Fake pipeline: invoke build() (so deploy's closure captures the address), then return a canned -// outcome. The stage decides which outcomeData branch runs. -function service(opts: { deployTx: Record; outcome: Record }) { +// Fake pipeline: run build() then prepare() the way TxPipeline does — deploy captures the address +// from the prepared transaction — then return a canned outcome. The stage decides which +// outcomeData branch runs. `prepareTransaction` stands in for the identity refresh. +function service(opts: { + deployTx: Record; + outcome: Record; + preparedAddress?: string; +}) { const gateway = { async deployContract() { return opts.deployTx; }, + prepareTransaction(tx: Record) { + return "preparedAddress" in opts + ? { ...tx, contract_address: opts.preparedAddress } + : tx; + }, } as unknown as TronGateway; const gateways = { get: () => gateway } as unknown as ChainGatewayProvider; const pipeline = { assertCanSign() {}, - async run(p: { build: (from: string) => Promise }) { - await p.build("Towner"); + async run(p: { + build: (from: string) => Promise; + prepare: (tx: unknown, o: { permissionId: number }) => unknown; + }) { + p.prepare(await p.build("Towner"), { permissionId: 0 }); return opts.outcome; }, } as unknown as TxPipeline; @@ -41,7 +56,7 @@ describe("TronContractService.deploy — contractAddress", () => { expect(view.contractAddress).toBe(CONTRACT_B58); }); - it("dry-run: address captured at build time even without broadcast", async () => { + it("dry-run: address reported even without broadcast", async () => { const view = await service({ deployTx: { contract_address: CONTRACT_HEX }, outcome: { stage: "plan", tx: { txID: "d" }, fee: {} }, @@ -57,4 +72,33 @@ describe("TronContractService.deploy — contractAddress", () => { }).deploy(SCOPE, NET, DEPLOY_INPUT); expect(view.contractAddress).toBeUndefined(); }); + + // --permission-id / --expiration rewrite raw_data, so preparation recomputes the txID and with it + // the deployed address. Reporting the builder's address would name a contract nobody deploys. + it("broadcast with --permission-id: reports the prepared address, not the builder's", async () => { + const view = await service({ + deployTx: { contract_address: CONTRACT_HEX }, + preparedAddress: PREPARED_HEX, + outcome: { stage: "submitted", txId: "tx123" }, + }).deploy(SCOPE, NET, { ...DEPLOY_INPUT, permissionId: 2 }); + expect(view.contractAddress).toBe(PREPARED_B58); + }); + + it("build-only with --expiration: reports the prepared address, not the builder's", async () => { + const view = await service({ + deployTx: { contract_address: CONTRACT_HEX }, + preparedAddress: PREPARED_HEX, + outcome: { stage: "built", tx: { txID: "d" }, hex: "0a", fee: {} }, + }).deploy(SCOPE, NET, { ...DEPLOY_INPUT, buildOnly: true, expiration: 86_400_000 }); + expect(view.contractAddress).toBe(PREPARED_B58); + }); + + it("drops the address when preparation leaves none", async () => { + const view = await service({ + deployTx: { contract_address: CONTRACT_HEX }, + preparedAddress: undefined, + outcome: { stage: "submitted", txId: "tx123" }, + }).deploy(SCOPE, NET, DEPLOY_INPUT); + expect(view.contractAddress).toBeUndefined(); + }); }); diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index 87be1a895..1370ad6a2 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -91,6 +91,7 @@ export class TronContractService { this.pipeline.assertCanSign(scope.activeAccount, "tron", { requireSoftware: true }); } const gateway = this.gateways.get(network, "tron"); + const hooks = tronTransactionHooks(gateway); let contractAddress: string | undefined; const outcome = await this.pipeline.run({ ctx: scope, @@ -98,15 +99,19 @@ export class TronContractService { account: scope.activeAccount, broadcaster: gateway, ...transactionMode(input), - ...tronTransactionHooks(gateway), + ...hooks, + // TRON derives the deployed address from the final txID, and `prepare` recomputes that txID + // when it binds --permission-id / --expiration. Read the address from the prepared + // transaction, never from the builder output, or we report a contract nobody deploys. + prepare: (transaction, options) => { + const prepared = hooks.prepare(transaction, options); + const hex = (prepared as { contract_address?: string }).contract_address; + contractAddress = hex ? tronHexToBase58(hex) : undefined; + return prepared; + }, signerOptions: { requireSoftware: true }, confirm: tronConfirmation(gateway, scope), - build: async (from) => { - const tx = await gateway.deployContract(from, input); - const hex = (tx as { contract_address?: string }).contract_address; - if (hex) contractAddress = tronHexToBase58(hex); - return tx; - }, + build: (from) => gateway.deployContract(from, input), estimate: async () => ({ feeModel: "tron-resource", note: "deploy energy depends on bytecode size", diff --git a/ts/src/application/use-cases/tron/gasfree-service.test.ts b/ts/src/application/use-cases/tron/gasfree-service.test.ts index 6cee55a5d..b0ed170e7 100644 --- a/ts/src/application/use-cases/tron/gasfree-service.test.ts +++ b/ts/src/application/use-cases/tron/gasfree-service.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { ChainError, TransportError } from "../../../domain/errors/index.js"; import type { GasFreeProvider } from "../../ports/gasfree-provider.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { SignerResolver } from "../../services/signer/index.js"; @@ -32,13 +33,13 @@ const NETWORK = { }, } satisfies NetworkDescriptor; -function scope(wait = false): TransactionScope { +function scope(wait = false, waitTimeoutMs = 1000): TransactionScope { return { activeAccount: "wlt_test", resolveAddress: () => OWNER, timeoutMs: 1000, wait, - waitTimeoutMs: 1000, + waitTimeoutMs, emit: vi.fn(), warn: vi.fn(), }; @@ -51,8 +52,11 @@ function fixture(options: { badDigest?: boolean; expiredAtSeconds?: boolean; terminal?: "success" | "fee-exceeded" | "mutated-recipient"; + /** replaces the polling response entirely — for failure-path tests. */ + trace?: (call: number) => Promise; } = {}) { let submittedAuthorization: SignedGasFreeAuthorization | undefined; + let traceCalls = 0; const submitTransfer = vi.fn( async ( _network: NetworkDescriptor, @@ -104,6 +108,7 @@ function fixture(options: { })), submitTransfer, trace: vi.fn(async () => { + if (options.trace) return options.trace(++traceCalls); const authorization = submittedAuthorization!; const transferFee = options.terminal === "fee-exceeded" ? "1600000" : "400000"; @@ -333,6 +338,81 @@ describe("GasFreeService", () => { token: "USDT", dryRun: false, }), - ).rejects.toMatchObject({ code: "gasfree_integrity" }); + // an integrity violation must still fail loudly — but it stays reconcilable + ).rejects.toMatchObject({ code: "gasfree_integrity", details: { traceId: "trace-1" } }); + }); + + // Everything below runs after submitTransfer has already succeeded and the tokens are committed. + // A polling failure says nothing about the outcome and must never take the trace id with it. + describe("--wait polling failures never destroy the receipt", () => { + const send = (fixtureValue: ReturnType, ctx: TransactionScope) => + fixtureValue.service.transfer(ctx, NETWORK, { + to: RECEIVER, + amount: "25", + token: "USDT", + dryRun: false, + }); + + function terminalRecord(state: "SUCCEED") { + return { + id: "trace-1", + state, + tokenAddress: TOKEN, + providerAddress: PROVIDER, + accountAddress: OWNER, + gasFreeAddress: GASFREE, + targetAddress: RECEIVER, + amount: "25000000", + maxFee: "1500000", + nonce: "8", + txnHash: "ab".repeat(32), + }; + } + + it("retries transport flakiness inside the deadline and still settles", async () => { + const fixtureValue = fixture({ + active: true, + trace: async (call) => { + if (call === 1) throw new TransportError("provider_error", "GasFree service returned HTTP 503"); + if (call === 2) throw new ChainError("timeout", "GasFree request timed out after 1000ms"); + return terminalRecord("SUCCEED"); + }, + }); + const ctx = scope(true, 5000); + + const result = await send(fixtureValue, ctx); + + expect(fixtureValue.provider.trace).toHaveBeenCalledTimes(3); + expect(result).toMatchObject({ stage: "confirmed", state: "SUCCEED", traceId: "trace-1" }); + }); + + it("returns the submitted receipt when flakiness outlasts the deadline", async () => { + const fixtureValue = fixture({ + active: true, + trace: async () => { throw new TransportError("provider_error", "GasFree service rate limit exceeded"); }, + }); + const ctx = scope(true, 3000); + + const result = await send(fixtureValue, ctx); + + expect(result).toMatchObject({ stage: "submitted", state: "WAITING", traceId: "trace-1" }); + expect(ctx.warn).toHaveBeenCalledWith(expect.stringContaining("did not finish within")); + }); + + it("gives up on a non-retryable provider error but keeps the trace id", async () => { + const fixtureValue = fixture({ + active: true, + trace: async () => { throw new ChainError("gasfree_rejected", "GasFree service returned HTTP 404"); }, + }); + const ctx = scope(true, 5000); + + const result = await send(fixtureValue, ctx); + + expect(result).toMatchObject({ stage: "submitted", traceId: "trace-1" }); + expect(fixtureValue.provider.trace).toHaveBeenCalledTimes(1); // not retried + expect(ctx.warn).toHaveBeenCalledWith( + expect.stringContaining("gasfree trace trace-1"), + ); + }); }); }); diff --git a/ts/src/application/use-cases/tron/gasfree-service.ts b/ts/src/application/use-cases/tron/gasfree-service.ts index d8fec88d7..24589461d 100644 --- a/ts/src/application/use-cases/tron/gasfree-service.ts +++ b/ts/src/application/use-cases/tron/gasfree-service.ts @@ -14,7 +14,7 @@ import type { GasFreeTransferView, NetworkDescriptor, } from "../../../domain/types/index.js"; -import { ChainError, UsageError } from "../../../domain/errors/index.js"; +import { ChainError, CliError, TransportError, UsageError } from "../../../domain/errors/index.js"; import { gasFreeDigest, gasFreeTypedData, @@ -267,18 +267,14 @@ export class GasFreeService { }; if (!scope.wait) return accepted; const terminal = await this.#wait( + scope, network, submitted, authorization, addressInfo.gasFreeAddress, - scope.waitTimeoutMs, ); - if (!terminal) { - scope.warn( - `--wait: GasFree transfer ${submitted.id} did not finish within ${scope.waitTimeoutMs}ms; returning submitted`, - ); - return accepted; - } + // #wait has already warned with the precise reason it stopped. + if (!terminal) return accepted; const settledAmount = terminal.txnAmount ?? accepted.amount; const settledServiceFee = terminal.txnTransferFee ?? accepted.serviceFee; @@ -381,32 +377,64 @@ export class GasFreeService { ); } + /** + * Poll the trace until a terminal state, or give up. + * + * Everything here runs AFTER `submitTransfer` succeeded — the transfer is accepted and the tokens + * are committed. A polling failure therefore says nothing about the outcome, and must never + * destroy the receipt that carries the trace id: without it the caller cannot reconcile and is + * likely to resubmit. Transport flakiness is retried inside the deadline; anything else that is + * not an integrity violation gives up with a warning and lets the caller return `submitted`. + * + * Returns undefined when no terminal state was reached; it has already warned why. + */ async #wait( + scope: TransactionScope, network: NetworkDescriptor, initial: GasFreeTransferRecord, authorization: GasFreeAuthorization, gasFreeAddress: string, - timeoutMs: number, ): Promise { - const deadline = this.clock() + Math.max(0, timeoutMs); + const deadline = this.clock() + Math.max(0, scope.waitTimeoutMs); + const traceContext = { traceId: initial.id }; let previous = initial.state; if (previous === "SUCCEED" || previous === "FAILED") return initial; for (;;) { const remaining = deadline - this.clock(); - if (remaining <= 0) return undefined; + if (remaining <= 0) { + scope.warn( + `--wait: GasFree transfer ${initial.id} did not finish within ${scope.waitTimeoutMs}ms; returning submitted`, + ); + return undefined; + } await this.sleep(Math.min(POLL_MS, remaining)); - const current = await this.provider.trace(network, initial.id); + let current: GasFreeTransferRecord; + try { + current = await this.provider.trace(network, initial.id); + } catch (error) { + // 429 / 5xx / connection failure / request timeout — the deadline decides when to stop. + if (isRetryablePoll(error)) continue; + if (error instanceof CliError && error.code === "gasfree_integrity") throw error; + scope.warn( + `--wait: GasFree trace could not be polled (${ + error instanceof CliError ? error.code : "provider_error" + }); returning submitted — reconcile with \`gasfree trace ${initial.id}\``, + ); + return undefined; + } if (current.id !== initial.id) { throw new ChainError( "gasfree_integrity", "GasFree trace id changed while polling", + traceContext, ); } - assertAccepted(current, authorization, gasFreeAddress); + assertAccepted(current, authorization, gasFreeAddress, traceContext); if (stateRank(current.state) < stateRank(previous)) { throw new ChainError( "gasfree_integrity", "GasFree state regressed while polling", + traceContext, ); } previous = current.state; @@ -417,6 +445,12 @@ export class GasFreeService { } } +/** Transport-level flakiness the provider will plausibly recover from inside the wait deadline. */ +function isRetryablePoll(error: unknown): boolean { + return error instanceof TransportError + || (error instanceof CliError && error.code === "timeout"); +} + function selectToken( tokens: ResolvedGasFreeToken[], symbol: string, @@ -510,6 +544,8 @@ function assertAccepted( record: GasFreeTransferRecord, authorization: GasFreeAuthorization, gasFreeAddress: string, + /** carried into error.details once a trace id exists, so a rejected receipt stays reconcilable. */ + context?: object, ): void { const deadlineMatches = record.expiredAt === undefined @@ -532,6 +568,7 @@ function assertAccepted( throw new ChainError( "gasfree_integrity", "GasFree submit receipt does not match the signed authorization", + context, ); } assertFeeIntegrity(record, authorization.maxFee); diff --git a/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts b/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts index cd4f4bead..01e4a7f1d 100644 --- a/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts +++ b/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts @@ -220,3 +220,39 @@ describe("TronLink multi-sign collaboration workflow", () => { expect(result).toMatchObject({ action: "watch", notifications: 1 }); }); }); + +// #verifyOnChain used to bind only the SIGNED address set and the aggregate weight, leaving unsigned +// entries and every per-signer weight provider-controlled. A stale record (permission updated while +// the transaction sat pending) or a hostile one could therefore name a signer the permission does +// not contain, or misstate weights — and it all rendered as chain-validated. +describe("TronLink signer roster is bound to the on-chain permission", () => { + const C = "TB6dL8QunEyPUqX95PESxyZ2SHGeAQELW2"; // not a key in the permission (keys are A and B) + const unsigned = (address: string, weight: number) => + ({ address, weight, is_sign: 0 as const, sign_time: 0 }); + + function withProgress(progress: ReturnType[]) { + return setup(remote(unsignedHex(), { signature_progress: progress })); + } + + it("refuses a fabricated signer entry", async () => { + const { service } = withProgress([unsigned(A, 1), unsigned(B, 1), unsigned(C, 1)]); + await expect(service.list(NETWORK, A)).rejects.toMatchObject({ code: "provider_error" }); + }); + + it("refuses to act for an account the permission does not contain", async () => { + const { service } = withProgress([unsigned(A, 1), unsigned(B, 1), unsigned(C, 1)]); + // C passes every provider-side consistency check because the provider invented its own entry + await expect(service.list(NETWORK, C)).rejects.toMatchObject({ code: "not_authorized" }); + }); + + it("renders on-chain weights, not the ones the provider reported", async () => { + const { service } = withProgress([unsigned(A, 1), unsigned(B, 99)]); + const view = await service.list(NETWORK, A); + expect(view.transactions[0]!.signatureProgress.map((entry) => entry.weight)).toEqual([1, 1]); + }); + + it("accepts a roster that omits a key — every number shown is chain-derived anyway", async () => { + const { service } = withProgress([unsigned(A, 1)]); + await expect(service.list(NETWORK, A)).resolves.toBeDefined(); + }); +}); diff --git a/ts/src/application/use-cases/tron/multisig-collaboration-service.ts b/ts/src/application/use-cases/tron/multisig-collaboration-service.ts index 3cc22f7ba..efce33a40 100644 --- a/ts/src/application/use-cases/tron/multisig-collaboration-service.ts +++ b/ts/src/application/use-cases/tron/multisig-collaboration-service.ts @@ -49,7 +49,7 @@ export class TronMultisigCollaborationService { }); const gateway = this.gateways.get(network, "tron"); const validated = page.records.map((record) => this.#validate(gateway, address, record)); - await mapWithConcurrency(validated, 4, (transaction) => this.#verifyOnChain(network, transaction)); + await mapWithConcurrency(validated, 4, (transaction) => this.#verifyOnChain(network, address, transaction)); return { address, total: page.total, @@ -162,7 +162,7 @@ export class TronMultisigCollaborationService { for (const record of page.records) { const validated = this.#validate(gateway, address, record); if (validated.view.txId === txId) { - await this.#verifyOnChain(network, validated); + await this.#verifyOnChain(network, address, validated); return validated; } } @@ -274,8 +274,16 @@ export class TronMultisigCollaborationService { }; } - async #verifyOnChain(network: NetworkDescriptor, remote: ValidatedRemoteRecord): Promise { - const approval = await this.multisig.approvals(network, remote.hex); + async #verifyOnChain( + network: NetworkDescriptor, + currentAddress: string, + remote: ValidatedRemoteRecord, + ): Promise { + const gateway = this.gateways.get(network, "tron"); + const [approval, signWeight] = await Promise.all([ + this.multisig.approvals(network, remote.hex), + gateway.getSignWeight(gateway.decodeTransactionHex(remote.hex)), + ]); const signedProgress = remote.view.signatureProgress .filter((entry) => entry.signed) .map((entry) => entry.address); @@ -296,6 +304,46 @@ export class TronMultisigCollaborationService { "TronLink transaction metadata or signatures disagree with the selected network", ); } + + // The checks above bind only the SIGNED set and the aggregate weight. Unsigned entries and every + // per-signer weight are still whatever the provider said — so a stale record (the permission was + // updated while this transaction sat pending) or a hostile one can name a signer the permission + // does not contain, or misstate how much weight each key carries. Both read as chain-validated + // in the summary a co-signer uses to decide. Bind the whole roster, and render chain weights. + const onChainWeight = new Map( + (signWeight.permission?.keys ?? []).map((key) => [key.address, key.weight] as const), + ); + if (!onChainWeight.has(currentAddress)) { + throw new ChainError( + "not_authorized", + "selected account is not a key in the transaction permission", + ); + } + // NB: deliberately not requiring every on-chain key to appear. Whether the service always + // returns the full roster is not something this client can know, and every number the user acts + // on (threshold, currentWeight, missingWeight) is chain-derived regardless — so rejecting a + // short list would risk breaking real usage to prevent nothing. + for (const entry of remote.view.signatureProgress) { + const weight = onChainWeight.get(entry.address); + if (weight === undefined) { + throw new ChainError( + "provider_error", + "TronLink lists a signer that is not a key in the on-chain permission", + ); + } + entry.weight = weight; + } + // Re-derive from chain weights: catches weight redistributed among the signed keys, which the + // provider-side sum in #validate cannot see. + const signedWeight = remote.view.signatureProgress + .filter((entry) => entry.signed) + .reduce((sum, entry) => sum + entry.weight, 0); + if (signedWeight !== approval.currentWeight) { + throw new ChainError( + "provider_error", + "TronLink per-signer weights disagree with the on-chain permission", + ); + } remote.view.permission.name = approval.permission.name; } } diff --git a/ts/src/application/use-cases/tron/permission-service.test.ts b/ts/src/application/use-cases/tron/permission-service.test.ts index 4acb2a41a..605a9bd1b 100644 --- a/ts/src/application/use-cases/tron/permission-service.test.ts +++ b/ts/src/application/use-cases/tron/permission-service.test.ts @@ -59,9 +59,12 @@ function accounts(): AccountStore { } as unknown as AccountStore; } -function setup(balance = "200000000") { +function setup(balance = "200000000", options: { + outcome?: Record; + showPermissions?: () => Promise; +} = {}) { const gateway = { - getAccountPermissions: vi.fn(async () => permissions()), + getAccountPermissions: vi.fn(options.showPermissions ?? (async () => permissions())), getUpdateAccountPermissionFee: vi.fn(async () => 100_000_000), getNativeBalance: vi.fn(async () => balance), buildAccountPermissionUpdate: vi.fn(async () => ({ txID: "tx" })), @@ -74,6 +77,7 @@ function setup(balance = "200000000") { run: vi.fn(async (params: TxPipelineParams) => { captured = params; const tx = await params.build(A); + if (options.outcome) return options.outcome; return { stage: "plan" as const, tx, fee: await params.estimate(tx) }; }), } as unknown as TxPipeline; @@ -140,4 +144,39 @@ describe("TRON permission service", () => { expect(gateway.buildAccountPermissionUpdate).not.toHaveBeenCalled(); expect(pipeline.run).not.toHaveBeenCalled(); }); + + // Permissions are already rewritten on chain by this point; an unreadable re-read must not present + // that as a failed command, or the caller re-submits a permission structure that is already live. + it("keeps the confirmed receipt when the permission re-read cannot be performed", async () => { + const { service } = setup("200000000", { + outcome: { stage: "confirmed", txId: "tx-confirmed" }, + showPermissions: async () => { throw new Error("connect ETIMEDOUT"); }, + }); + const ctx = scope(); + + const result = await service.update(ctx, NETWORK, {}, permissions()); + + expect(result).toMatchObject({ kind: "permission-update", stage: "confirmed", txId: "tx-confirmed" }); + expect(result).not.toHaveProperty("permissions"); + expect(ctx.warn).toHaveBeenCalledWith(expect.objectContaining({ + code: "permission_postcheck_unavailable", + })); + }); + + it("still reports a genuine mismatch under the unchanged warning code", async () => { + const divergent = permissions(); + divergent.owner.threshold = 1; + const { service } = setup("200000000", { + outcome: { stage: "confirmed", txId: "tx-confirmed" }, + showPermissions: async () => divergent, + }); + const ctx = scope(); + + const result = await service.update(ctx, NETWORK, {}, permissions()); + + expect(result).toMatchObject({ stage: "confirmed", txId: "tx-confirmed" }); + expect(ctx.warn).toHaveBeenCalledWith(expect.objectContaining({ + code: "permission_postcheck_mismatch", + })); + }); }); diff --git a/ts/src/application/use-cases/tron/permission-service.ts b/ts/src/application/use-cases/tron/permission-service.ts index 7ceeff08b..175b2a061 100644 --- a/ts/src/application/use-cases/tron/permission-service.ts +++ b/ts/src/application/use-cases/tron/permission-service.ts @@ -17,6 +17,7 @@ import { type TransactionModeInput, } from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; +import { warnOnPostCheck } from "../../services/post-check.js"; import { assertTronSignerAuthorized } from "./multisig-authorization.js"; export class TronPermissionService { @@ -75,13 +76,12 @@ export class TronPermissionService { let resultPermissions: AccountPermissionsView | undefined; if (outcome.stage === "confirmed") { - resultPermissions = await this.show(network, address); - if (canonicalPermissions(resultPermissions) !== canonicalPermissions(permissions)) { - scope.warn({ - code: "permission_postcheck_mismatch", - message: "confirmed transaction permissions differ from the requested canonical structure", - }); - } + await warnOnPostCheck(scope, "permission_postcheck", async () => { + resultPermissions = await this.show(network, address); + return canonicalPermissions(resultPermissions) === canonicalPermissions(permissions) + ? undefined + : "confirmed transaction permissions differ from the requested canonical structure"; + }); } else if (outcome.stage === "plan" || outcome.stage === "built" || outcome.stage === "signed") { resultPermissions = permissions; } diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index ebdc16a0d..d85216289 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -101,7 +101,7 @@ 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 { AccountStore } from "../../application/ports/account-store.js"; -import { TransactionArtifactWriter } from "../../adapters/outbound/persistence/transaction-artifact-writer.js"; +import { SecureTransactionArtifactWriter } from "../../adapters/outbound/persistence/transaction-artifact-writer.js"; import type { FamilyPlugin } from "./types.js"; import type { TronLinkCollaborationPort } from "../../application/ports/tronlink-collaboration.js"; import type { GasFreeProvider } from "../../application/ports/gasfree-provider.js"; @@ -191,7 +191,7 @@ export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainC transaction, signing, multisig, - new TransactionArtifactWriter(), + new SecureTransactionArtifactWriter(), )); reg.addChain(txApprovalsSpec, "tron", txApprovalsTronBinding(multisig)); reg.addChain( diff --git a/ts/src/bootstrap/runner.test.ts b/ts/src/bootstrap/runner.test.ts index 28321aa74..126e8c380 100644 --- a/ts/src/bootstrap/runner.test.ts +++ b/ts/src/bootstrap/runner.test.ts @@ -1,4 +1,8 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { main } from "./runner.js"; import { parseGlobals, hasCommand } from "./argv.js"; import { FAMILY_REGISTRY } from "./family-registry.js"; @@ -65,3 +69,67 @@ describe("parseGlobals", () => { expect(parseGlobals(["--network"]).globals.network).toBeUndefined(); }); }); + +// composeCliRuntime runs before the formatter exists, so a broken config.yaml used to escape main() +// entirely and land on the last-resort `fatal:` guard in index.ts — no envelope, exit 1 for what is +// a UsageError. A malformed document is the trigger here because, unlike the 0600 check, it is not +// skipped on Windows. +describe("bootstrap error boundary", () => { + async function runWithConfig(yaml: string, tokens: string[]) { + const root = mkdtempSync(join(tmpdir(), "wcli-boot-")); + writeFileSync(join(root, "config.yaml"), yaml); + const previous = process.env.WALLET_CLI_HOME; + process.env.WALLET_CLI_HOME = root; + const stdout: string[] = []; + const stderr: string[] = []; + const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + try { + const code = await main(["node", "wallet-cli", ...tokens]); + return { code, stdout: stdout.join(""), stderr: stderr.join("") }; + } finally { + outSpy.mockRestore(); + errSpy.mockRestore(); + if (previous === undefined) delete process.env.WALLET_CLI_HOME; + else process.env.WALLET_CLI_HOME = previous; + } + } + + const BROKEN = [ + 'gasfreeApiSecret: "SUPERSECRET123"', + 'defaultNetwork: "unterminated', + " bad: [1,2", + "", + ].join("\n"); + + it("emits a v1 error envelope and the UsageError exit code, even for --help", async () => { + const { code, stdout } = await runWithConfig(BROKEN, ["-o", "json", "--help"]); + + expect(code).toBe(2); + expect(JSON.parse(stdout)).toMatchObject({ + schema: "wallet-cli.result.v1", + success: false, + error: { code: "invalid_config" }, + }); + }); + + it("never leaks the offending file content into either stream", async () => { + const { stdout, stderr } = await runWithConfig(BROKEN, ["-o", "json", "account", "balance"]); + + expect(stdout + stderr).not.toContain("SUPERSECRET123"); + expect(stdout + stderr).not.toContain("bad: [1,2"); + }); + + it("falls back to text when no explicit -o was given (the config default is unreadable)", async () => { + const { code, stderr } = await runWithConfig(BROKEN, ["account", "balance"]); + + expect(code).toBe(2); + expect(stderr).toMatch(/invalid_config/); + }); +}); diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index 479971fae..c9563b222 100644 --- a/ts/src/bootstrap/runner.ts +++ b/ts/src/bootstrap/runner.ts @@ -1,19 +1,57 @@ import { hideBin } from "yargs/helpers" -import type { ExitCode } from "../domain/types/index.js" +import type { ExitCode, OutputMode } from "../domain/types/index.js" import { normalizeError, UsageError } from "../domain/errors/index.js" +import { redactErrorMessage } from "../domain/errors/redact.js" import { HelpService, hasMeta } from "../adapters/inbound/cli/help/index.js" import { buildCli } from "../adapters/inbound/cli/shell/index.js" +import { createOutputFormatter } from "../adapters/inbound/cli/output/index.js" +import { StreamManager } from "../adapters/inbound/cli/stream/index.js" import { hasCommand, parseGlobals } from "./argv.js" import { composeCliRuntime } from "./composition.js" export const VERSION = "4.11.0" +/** + * Report a failure raised while the composition root was still being built — an unreadable, + * insecure, or malformed `config.yaml`. These happen before the runtime exists, so they cannot use + * the normal error path, and without this they escape `main()` entirely and land on the last-resort + * guard in `index.ts` as a bare `fatal:` line at exit 1 — no envelope, and the wrong exit code for + * what is a UsageError. + * + * Everything here depends on argv alone: the config that would have supplied the default output + * mode is precisely what failed, so only an explicit `-o` is honoured and text is the fallback. + * `normalizeError` still does the redacting — a YAML parse error quotes the offending line, which + * may sit next to a service credential, so it is classified to a generic `internal_error` rather + * than surfaced. + */ +function reportBootstrapFailure( + error: unknown, + globals: { output?: OutputMode; verbose?: boolean }, + startedAt: number, +): ExitCode { + const output = globals.output ?? "text" + const normalized = normalizeError(error) + const streams = new StreamManager(output, globals.verbose ?? false) + if (normalized.code === "internal_error") { + // same --verbose-gated escape hatch the main path offers, minus paths/URLs + streams.diagnostic("debug", `bootstrap error: ${redactErrorMessage(String(error))}`) + } + createOutputFormatter(output, streams, startedAt).error(normalized) + return normalized.exitCode() +} + /** Execute one CLI invocation. Dependency construction is delegated to the composition root. */ export async function main(argv: string[]): Promise { const startedAt = Date.now() const tokens = hideBin(argv) const { globals, secretPaths, invalid } = parseGlobals(tokens) - const runtime = composeCliRuntime({ globals, secretPaths, startedAt }) + + let runtime: ReturnType + try { + runtime = composeCliRuntime({ globals, secretPaths, startedAt }) + } catch (error) { + return reportBootstrapFailure(error, globals, startedAt) + } try { if (hasMeta(tokens) || !hasCommand(tokens)) { From 9ec365992c5443e357d8f2aced862db8a20b610e Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 4 Aug 2026 00:40:28 +0800 Subject: [PATCH 18/24] refactor(ts): break the type-only import cycles, enforce boundaries at build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit depcruise only saw the graph that survives compilation, so a boundary violation carrying nothing but a type was invisible to it — which is how an inbound command came to name an outbound implementation directly. Turning on tsPreCompilationDeps fixes that, but it also closes four pre-existing type-only cycles, so those had to go first. Every cycle ran through one pivot: ChainFamily was defined in the family registry, and the registry depends on the address codec at runtime. Any type that named a family therefore had to reach through the registry, closing types -> family -> address -> types. The identity now lives in its own dependency-free module, re-exported from the registry, so both public import paths are unchanged. The price cycle was simpler: coingecko imported its own port type back through the barrel that re-exports it, rather than from the port. With the cycles gone, tsPreCompilationDeps is on and all four boundary rules cover type-only edges. That subsumes boundary.test.ts, added as a stopgap for the inbound->outbound case, so it is removed: the guarantee moves from one hand-written string check to the build, and from one edge to four. Verified the replacement is real by pointing the inbound command back at the outbound class: depcruise reports inbound-does-not-know-outbound. --- ts/.dependency-cruiser.cjs | 5 +++ ts/src/adapters/inbound/boundary.test.ts | 37 --------------------- ts/src/adapters/outbound/price/coingecko.ts | 2 +- ts/src/domain/address/index.ts | 3 +- ts/src/domain/family/chain-family.ts | 13 ++++++++ ts/src/domain/family/index.ts | 12 +++---- ts/src/domain/types/contact.ts | 2 +- ts/src/domain/types/index.ts | 2 +- ts/src/domain/types/wallet.ts | 2 +- 9 files changed, 28 insertions(+), 50 deletions(-) delete mode 100644 ts/src/adapters/inbound/boundary.test.ts create mode 100644 ts/src/domain/family/chain-family.ts diff --git a/ts/.dependency-cruiser.cjs b/ts/.dependency-cruiser.cjs index 1a1bcea08..3af44982d 100644 --- a/ts/.dependency-cruiser.cjs +++ b/ts/.dependency-cruiser.cjs @@ -45,5 +45,10 @@ module.exports = { doNotFollow: { path: "node_modules" }, tsConfig: { fileName: "tsconfig.json" }, enhancedResolveOptions: { extensions: [".ts", ".js"] }, + // `import type` is erased at build time, so without this the rules above only see the graph + // that survives compilation — and a boundary violation carrying only a type is still one: it + // names a concrete implementation where a port belongs, and it is what a later refactor turns + // into a runtime edge. + tsPreCompilationDeps: true, }, }; diff --git a/ts/src/adapters/inbound/boundary.test.ts b/ts/src/adapters/inbound/boundary.test.ts deleted file mode 100644 index 7a260036f..000000000 --- a/ts/src/adapters/inbound/boundary.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { readFileSync, readdirSync } from "node:fs"; -import { join, relative } from "node:path"; - -/** - * depcruise already forbids inbound → outbound, but it only sees the graph that survives - * compilation: `import type` is erased, so a type-only edge across the boundary is invisible to it - * (switching on `tsPreCompilationDeps` would catch it, but it also closes several type-only cycles - * through the domain barrels and would fail `no-circular` for reasons unrelated to any boundary). - * - * The edge still matters. It names a concrete implementation where a port belongs, and it is the - * thing a later refactor quietly turns into a runtime dependency — which is exactly how - * `commands/tx.ts` came to reference the outbound `TransactionArtifactWriter` class directly. - */ -describe("inbound adapters do not name outbound implementations", () => { - const INBOUND = new URL("./", import.meta.url).pathname; - - function sources(directory: string): string[] { - return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => - entry.isDirectory() - ? sources(join(directory, entry.name)) - : entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") - ? [join(directory, entry.name)] - : [] - ); - } - - it("has no import of adapters/outbound, type-only included", () => { - const offenders = sources(INBOUND) - .map((path) => ({ path, text: readFileSync(path, "utf8") })) - .filter(({ text }) => /from\s+"[^"]*adapters\/outbound\//.test(text) - || /from\s+"(?:\.\.\/)+outbound\//.test(text)) - .map(({ path }) => relative(INBOUND, path)); - - expect(offenders).toEqual([]); - }); -}); diff --git a/ts/src/adapters/outbound/price/coingecko.ts b/ts/src/adapters/outbound/price/coingecko.ts index b3f26f728..f8d99d5c6 100644 --- a/ts/src/adapters/outbound/price/coingecko.ts +++ b/ts/src/adapters/outbound/price/coingecko.ts @@ -3,7 +3,7 @@ * native via `ids=`, TRC20 via `token_price/tron`. TRC10 → null. Best-effort: * any network/parse failure resolves to null (see {@link PriceProvider}). */ -import type { PriceProvider } from "./index.js"; +import type { PriceProvider } from "../../../application/ports/price-provider.js"; export class CoinGeckoPriceProvider implements PriceProvider { readonly source = "coingecko"; diff --git a/ts/src/domain/address/index.ts b/ts/src/domain/address/index.ts index 644c08df5..d6252afaa 100644 --- a/ts/src/domain/address/index.ts +++ b/ts/src/domain/address/index.ts @@ -7,7 +7,8 @@ import { sha256 } from "@noble/hashes/sha2.js" import { createBase58check } from "@scure/base" import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js" import { secp256k1 } from "@noble/curves/secp256k1.js" -import type { Bytes, ChainFamily } from "../types/index.js" +import type { Bytes } from "../types/tx.js" +import type { ChainFamily } from "../family/chain-family.js" export interface AddressCodec { family: ChainFamily diff --git a/ts/src/domain/family/chain-family.ts b/ts/src/domain/family/chain-family.ts new file mode 100644 index 000000000..715ed576d --- /dev/null +++ b/ts/src/domain/family/chain-family.ts @@ -0,0 +1,13 @@ +/** + * The family identity: the named, value-level enum of chain families. Prefer `ChainFamily.tron` + * over the bare `"tron"` string in comparisons/switches. Kept as a const-object (not a TS `enum`) + * so the derived type stays a plain `"tron"` string union — bare strings (config data, + * CLI/RPC boundaries) remain assignable, and references can migrate incrementally. + * + * It lives in its own module, apart from the family registry that re-exports it, because the + * registry depends on the address codec at runtime while this identity depends on nothing. Sharing + * a module made every type that names a family reach through the registry, which is what closed the + * `types → family → address → types` cycle. + */ +export const ChainFamily = { tron: "tron" } as const; +export type ChainFamily = (typeof ChainFamily)[keyof typeof ChainFamily]; diff --git a/ts/src/domain/family/index.ts b/ts/src/domain/family/index.ts index dc932d792..8641a2d13 100644 --- a/ts/src/domain/family/index.ts +++ b/ts/src/domain/family/index.ts @@ -10,14 +10,10 @@ */ import { type AddressCodec, TronAddress } from "../address/index.js"; -/** - * The family identity: the named, value-level enum of chain families. Prefer `ChainFamily.tron` - * over the bare `"tron"` string in comparisons/switches. Kept as a const-object (not a TS `enum`) - * so the derived type stays a plain `"tron"` string union — bare strings (config data, - * CLI/RPC boundaries) remain assignable, and references can migrate incrementally. - */ -export const ChainFamily = { tron: "tron" } as const; -export type ChainFamily = (typeof ChainFamily)[keyof typeof ChainFamily]; +/** The family identity itself lives in a dependency-free module; re-exported here so the registry + * stays the one place callers import family facts from. */ +export { ChainFamily } from "./chain-family.js"; +import type { ChainFamily } from "./chain-family.js"; export interface FamilyMeta { family: ChainFamily; diff --git a/ts/src/domain/types/contact.ts b/ts/src/domain/types/contact.ts index ae6c81604..f98e377ba 100644 --- a/ts/src/domain/types/contact.ts +++ b/ts/src/domain/types/contact.ts @@ -1,4 +1,4 @@ -import type { ChainFamily } from "../family/index.js"; +import type { ChainFamily } from "../family/chain-family.js"; /** Canonical persisted recipient. nameKey is an integrity-checked lookup key. */ export interface ContactEntry { diff --git a/ts/src/domain/types/index.ts b/ts/src/domain/types/index.ts index 4d5fed4e8..183b2e7a3 100644 --- a/ts/src/domain/types/index.ts +++ b/ts/src/domain/types/index.ts @@ -19,7 +19,7 @@ * * ChainFamily (type + value enum) lives with the family registry and is re-exported here. */ -export type { ChainFamily } from "../family/index.js"; +export type { ChainFamily } from "../family/chain-family.js"; export type { TypedDataField, TypedDataPayload } from "../typed-data/index.js"; export * from "./network.js"; diff --git a/ts/src/domain/types/wallet.ts b/ts/src/domain/types/wallet.ts index c1e1c0038..eab247af3 100644 --- a/ts/src/domain/types/wallet.ts +++ b/ts/src/domain/types/wallet.ts @@ -1,7 +1,7 @@ /** * SharedTypes — wallet data shapes (persisted in wallets.json). */ -import type { ChainFamily } from "../family/index.js"; +import type { ChainFamily } from "../family/chain-family.js"; import type { AccountRef } from "./network.js"; /** one secret derives every family → all slots always present (non-optional). */ From b0ef8095b55435dc172cc91d9c7aeb3d76490936 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 4 Aug 2026 01:23:46 +0800 Subject: [PATCH 19/24] fix(ts): make tx multisig --create match the real TronLink protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --create was ported from MultiSignService.createTransaction(), which has no call site anywhere in the Java tree — dead code whose request shape had never been validated by the service. Two things were wrong as a result. The POST signature covered permission_name and tx_id, but the service signs only sign_version/channel/secret_id/ts/uuid/address, so every --create was rejected with 4000 Authentication failed. And the shape itself does not exist: the service has no empty collection, and derives the starting weight from the signature the transaction arrives with. Uploading unsigned raw_data returns 20004 Error param. --create now signs the transaction locally and submits it, which opens the collection at 1 of N. The CLI surface is unchanged — it still takes the unsigned hex a --build-only run produces — but it needs the master password now, and the originator no longer signs a second time. signChecked() already covers expiry, permission membership, and repeat signatures, so the manual getSignWeight/approvals preamble is gone, along with the port's create() and TronLinkCreateRequest. Two related fixes: - Service rejections carried only a numeric code, which cannot separate a bad signature (4000) from a bad parameter (20004) or a stale one (20305). The service's own wording now reaches error.details.providerMessage, stripped of control characters and bounded at 200 chars. - One record that no longer reconciles with the chain used to fail the whole listing, which is the normal state of any account whose permission changed while an old transaction sat queued. Listing now marks such a record unverified, says why, and shows the rest of the page. It is never presented as chain-validated, its state column reads unverified rather than what the service claimed, and awaitingMySignature is forced false. Acting on one still refuses. "Could not check" is kept distinct from "checked and disagreed": a node outage propagates instead of reading as a clean page. Also: once the threshold is met the service broadcasts the transaction itself, so the receipt now says to confirm with tx info before broadcasting by hand. Verified end to end on Nile: --create -> --sign -> on chain (a480c8f6…5797, block #69,754,801). New tests are mutation-checked; the ten mutants covering the routing, receipts, degradation boundary, and message handling all fail the suite. Bundles an unrelated in-flight refactor: KeypairWriter now takes a {out}|{name} target so filesystem layout stays out of AddressService. --- ts/docs/commands/tx/multisig.md | 88 +++++++++----- .../inbound/cli/commands/tx.multisig.test.ts | 51 +++++++++ ts/src/adapters/inbound/cli/commands/tx.ts | 13 ++- .../inbound/cli/render/multisig.test.ts | 108 ++++++++++++++++++ .../adapters/inbound/cli/render/multisig.ts | 32 ++++-- .../persistence/keypair-writer.test.ts | 29 +++-- .../outbound/persistence/keypair-writer.ts | 16 ++- .../adapters/outbound/tronlink/client.test.ts | 76 +++++++++--- ts/src/adapters/outbound/tronlink/client.ts | 36 +++--- ts/src/application/ports/keypair-writer.ts | 11 +- .../ports/tronlink-collaboration.ts | 13 +-- .../use-cases/address-service.test.ts | 7 +- .../application/use-cases/address-service.ts | 7 +- .../multisig-collaboration-service.test.ts | 80 +++++++++---- .../tron/multisig-collaboration-service.ts | 59 ++++++---- ts/src/bootstrap/composition.ts | 2 +- ts/src/domain/types/multisig.ts | 5 + 17 files changed, 477 insertions(+), 156 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts create mode 100644 ts/src/adapters/inbound/cli/render/multisig.test.ts diff --git a/ts/docs/commands/tx/multisig.md b/ts/docs/commands/tx/multisig.md index 842b93bf7..6c06534e4 100644 --- a/ts/docs/commands/tx/multisig.md +++ b/ts/docs/commands/tx/multisig.md @@ -18,41 +18,54 @@ reach each co-signer, and someone has to know when enough weight has accumulated ([`tx sign --file`](sign.md) → hand the file on → [`tx approvals`](approvals.md)) solves it by passing an artifact around by hand. -This command uses the official TronLink multi-sign service as a shared inbox instead: one signer -uploads the unsigned transaction, the others fetch, sign, and submit it, and the service holds the -accumulated signatures in between. TronLink wallet users see the same queue. +This command uses the official TronLink multi-sign service as a shared inbox instead: the +originator signs the transaction and submits it, which opens the collection; the others fetch, sign, +and submit it in turn, and the service holds the accumulated signatures in between. TronLink wallet +users see the same queue. The mode flags `--create`, `--sign`, and `--watch` are mutually exclusive; with none of them the command lists. **The service is a transport, not an authority.** Everything it returns is treated as untrusted: -the CLI re-derives the transaction hash from the raw data, checks the reported contract type, +the CLI re-derives the transaction hash from the raw data, and checks the reported contract type, owner, weights, and signature progress against the transaction itself **and against the on-chain -permission**, and fails with `provider_error` on any disagreement — rather than showing you what the -service claims. Signing still happens locally with your key. +permission** — rather than showing you what the service claims. Signing still happens locally with +your key. + +Byte-level lies are fatal: a record whose hash, contract type, or owner does not match its own +`raw_data` fails the command with `provider_error`, because no passage of time can produce one. + +Disagreement with the *chain* is different, because permissions change while old transactions sit +in the queue. Listing marks such a record **unverified**, states why, and shows the rest of the +page — it is never presented as chain-validated, its state column reads `unverified` rather than +what the service claimed, and `awaitingMySignature` is forced to `false` so it cannot be mistaken +for work waiting on you. Acting on one still refuses: [`--sign`](#modes) re-runs the same check and +fails with `provider_error` or `not_authorized`. Concretely, for the signer roster: every address the service reports must be a key in the transaction's on-chain permission, and each `weight` shown is read from the chain, never from the -service — so a reported weight that has gone stale (the permission was updated while this -transaction sat pending) is corrected rather than displayed. If the selected account is not itself a -key in that permission, the command fails with `not_authorized` instead of reporting the transaction -as awaiting your signature. A roster that omits a key is accepted: every figure you act on -(`threshold`, `currentWeight`, `missingWeight`) is chain-derived regardless. +service — so a reported weight that has gone stale is corrected rather than displayed. A roster that +omits a key is accepted: every figure you act on (`threshold`, `currentWeight`, `missingWeight`) is +chain-derived regardless. ### Modes **list** (no flag) — service-managed transactions for the selected account, with each one's state, accumulated weight against the threshold, and whether it is waiting on *you*. -**`--create`** — upload one **unsigned** transaction and open a collection. Passing an -already-signed transaction is refused (`invalid_value`), as is an expired one (`tx_expired`) or one -whose permission does not include the selected account (`not_authorized`). Requires exactly one of -`--hex` / `--file`, which are valid only with `--create`. +**`--create`** — take one **unsigned** transaction, sign it with your key, and submit it. There is +no empty collection: the service derives the starting weight from the signature the transaction +arrives with, so opening a collection and casting its first signature are the same act — the +originator does not sign again afterwards. Passing an already-signed transaction is refused +(`invalid_value`), as is an expired one (`tx_expired`) or one whose permission does not include the +selected account (`not_authorized`). Requires exactly one of `--hex` / `--file`, which are valid +only with `--create`, and the master password. **`--sign `** — fetch the transaction with the signatures gathered so far, verify it locally, sign it with your key, and submit the result back. Fails with `already_signed` if this account has already contributed, `tx_expired` if it has lapsed, or `not_authorized` if the account is not one -of its signers. Once the threshold is reached, the output tells you the broadcast command. +of its signers. Once the threshold is reached the service broadcasts the transaction itself, so the +output tells you how to confirm that before broadcasting it yourself. **`--watch`** — hold a WebSocket open and report when transactions are awaiting your signature. By design it receives **only a count**, never transaction content, so watching leaks nothing about @@ -76,7 +89,7 @@ descriptor — `api.walletadapter.org` for mainnet, and the Nile and Shasta equi | Option | Description | |---|---| -| `--create` | Upload one unsigned transaction and open a signature collection | +| `--create` | Sign one unsigned transaction and open a signature collection with it | | `--hex ` | Unsigned `protocol.Transaction` hex — only with `--create` | | `--file ` | File containing the unsigned transaction hex — only with `--create` | | `--sign ` | Fetch and co-sign one pending transaction by 32-byte hex txId | @@ -87,15 +100,17 @@ Plus the [global options](../index.md#global-options-every-command). ## Examples -Open a collection from an unsigned transaction: +Open a collection from an unsigned transaction — built by any broadcasting command with +`--build-only`: ```bash -wallet-cli tx multisig --create --file tx.unsigned.hex --network tron:nile +echo "$PW" | wallet-cli tx multisig --create --file tx.unsigned.hex --network tron:nile --password-stdin ``` ```console ✅ Created on TronLink multi-sig service - TxID 9c1f… + Signer TMSg…ToHJ (weight 1) + Hex 0a02…9f31 Transaction TxID 9c1f… @@ -103,9 +118,11 @@ Transaction Permission active "operations" (id 2) threshold 2 Expires 2026-07-29 12:34:56 (in 58 minutes) -Progress 0 / 2 — 2 more weight needed -No approved signers. -! Each signer signs it with: wallet-cli tx multisig --sign 9c1f… +Progress 1 / 2 — 1 more weight needed +| Approved signer | Weight | +| ---------------------------------- | ------ | +| TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ | 1 | +! Each co-signer signs it with: wallet-cli tx multisig --sign 9c1f… ``` See what is waiting on you: @@ -115,25 +132,35 @@ wallet-cli tx multisig --network tron:nile ``` ```console -Multi-sig transactions — TronLink service (1 total) +Multi-sig transactions — TronLink service (2 total) | TxID | Type | Amount | State | Progress | Expires | | ----- | ----------------- | ------ | ------------ | -------- | ----------------------------- | | 9c1f… | TransferContract | 1 TRX | awaiting you | 1 / 2 | 2026-07-29 12:34:56 (in 58m) | +| 1a9b… | TransferContract | 1 TRX | unverified | 1 / 1 | 2026-04-29 12:33 (~97d ago) | +! 1a9b… unverified — TronLink transaction metadata or signatures disagree with the selected network ! Co-sign one with: wallet-cli tx multisig --sign ``` +The second row is an old transaction whose permission has since been changed, so its numbers can no +longer be reconciled with the chain. It stays visible and labelled; the page does not fail. + Co-sign it: ```bash echo "$PW" | wallet-cli tx multisig --sign 9c1f… --network tron:nile --password-stdin ``` -When your signature completes the threshold, the receipt ends with the broadcast command: +When your signature completes the threshold, the service broadcasts the transaction itself, so the +receipt points you at the confirmation first: ```console -! Broadcast it: wallet-cli tx broadcast --hex 0a02… +! Threshold reached — the service broadcasts it. Confirm: wallet-cli tx info --txid 9c1f… + Not on chain: wallet-cli tx broadcast --hex 0a02… ``` +Broadcasting one that is already on chain fails with `transaction_rejected` +(`Transaction already exists.`) — harmless, but confirm rather than guess. + Watch for work, count only: ```bash @@ -155,6 +182,8 @@ Watching TronLink multi-sig service for tron:nile … (Ctrl-C to stop) |---|---|---| | `address` | string | Account the queue was read for | | `total` | number | Number of transactions | +| `transactions[].verified` | boolean | Whether the record reconciled with the chain | +| `transactions[].unverifiedReason` | string? | Present only when `verified` is `false` | | `transactions[].txId` | string | Transaction id | | `transactions[].state` | string | `pending` \| `signed` \| `success` \| `failed` | | `transactions[].contractType` | string | TRON contract type | @@ -166,8 +195,8 @@ Watching TronLink multi-sig service for tron:nile … (Ctrl-C to stop) | `transactions[].signatureProgress[]` | array | Per signer: `address`, `weight`, `signed`, `signedAt` | | `transactions[].createdAt` / `expiration` / `expired` | — | Timing | -**`--create`** — `action: "create"`, `accepted`, `hex`, and `transaction` (the same approval object -[`tx approvals`](approvals.md) returns). +**`--create`** — `action: "create"`, `accepted`, `signer`, `signerWeight`, `hex` (the signed +transaction), and `transaction` (the same approval object [`tx approvals`](approvals.md) returns). **`--sign`** — `action: "sign"`, `accepted`, `signer`, `signerWeight`, `hex`, and `transaction`. @@ -176,7 +205,8 @@ Watching TronLink multi-sig service for tron:nile … (Ctrl-C to stop) ## Exit status `0` · `1` execution failure — `not_authorized`, `already_signed`, `tx_expired`, `not_found`, -`provider_error` (any inconsistency in what the service returned), `auth_failed` · `2` usage error — +`provider_error` (a record inconsistent with its own bytes, or a rejection from the service — whose +own wording is carried through as `error.details.providerMessage`), `auth_failed` · `2` usage error — conflicting mode flags, `--hex`/`--file` without `--create`, `--create` without exactly one of them, a malformed txId. diff --git a/ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts b/ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts new file mode 100644 index 000000000..5f3acf601 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TronMultisigCollaborationService } from "../../../../application/use-cases/tron/multisig-collaboration-service.js"; +import { txTronLinkMultisigBinding } from "./tx.js"; + +const A = "TLZz5XKerAAebbRdScB3jmSPr5DHSpGJJP"; +const NETWORK = { family: "tron", id: "tron:nile" } as never; +const TX_ID = "ab".repeat(32); + +function harness() { + const service = { + create: vi.fn(async () => ({ action: "create" })), + sign: vi.fn(async () => ({ action: "sign" })), + list: vi.fn(async () => ({ action: "list" })), + watch: vi.fn(async () => ({ action: "watch" })), + } as unknown as TronMultisigCollaborationService; + const ctx = { + resolveAddress: () => A, + streams: { event: vi.fn() }, + } as never; + return { service, ctx, run: txTronLinkMultisigBinding(service).run }; +} + +describe("tx multisig binding", () => { + // --create signs, so it must receive the execution scope. Handing it the bare address instead + // would still typecheck at the call site and fail only at runtime, in the signing step. + it("routes --create through the signing scope, not the resolved address", async () => { + const { service, ctx, run } = harness(); + await run(ctx, NETWORK, { create: true, hex: "aabb" } as never); + + expect(service.create).toHaveBeenCalledWith(ctx, NETWORK, "aabb"); + expect(service.sign).not.toHaveBeenCalled(); + expect(service.list).not.toHaveBeenCalled(); + }); + + it("routes --sign through the same scope and leaves create alone", async () => { + const { service, ctx, run } = harness(); + await run(ctx, NETWORK, { sign: TX_ID } as never); + + expect(service.sign).toHaveBeenCalledWith(ctx, NETWORK, TX_ID); + expect(service.create).not.toHaveBeenCalled(); + }); + + it("lists for the resolved address when no mode flag is given", async () => { + const { service, ctx, run } = harness(); + await run(ctx, NETWORK, {} as never); + + expect(service.list).toHaveBeenCalledWith(NETWORK, A); + expect(service.create).not.toHaveBeenCalled(); + expect(service.sign).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index dbcef9b90..840479b60 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -215,7 +215,7 @@ export const txSignTronBinding = ( const tronLinkMultisigFields = z.object({ create: z.boolean().default(false) - .describe("upload one unsigned transaction and open a TronLink signature collection"), + .describe("sign one unsigned transaction and open a TronLink signature collection with it"), hex: z.string().min(2).optional().describe("unsigned protocol.Transaction hex used with --create"), file: z.string().min(1).optional().describe("file containing unsigned transaction hex used with --create"), sign: z.string().regex(/^(?:0x)?[0-9a-fA-F]{64}$/).optional() @@ -230,9 +230,10 @@ export const txTronLinkMultisigSpec: ChainSpec = { capability: "tx.multisig.tronlink", summary: "Coordinate multi-signature collection through the TronLink service", description: - "With no mode flag, list service-managed transactions for the selected account. --create\n" + - "uploads an UNSIGNED transaction; --sign fetches the accumulated transaction, signs locally,\n" + - "and submits it; --watch opens the official WebSocket count-only notification channel.", + "With no mode flag, list service-managed transactions for the selected account. --create signs\n" + + "an UNSIGNED transaction locally and submits it, which opens the collection at the first\n" + + "signature; --sign fetches the accumulated transaction, signs locally, and submits it;\n" + + "--watch opens the official WebSocket count-only notification channel.", requires: [ "TronLink service credentials — config tronlinkSecretId / tronlinkSecretKey / tronlinkChannel", ], @@ -240,7 +241,7 @@ export const txTronLinkMultisigSpec: ChainSpec = { baseRefine: tronLinkMultisigRefine, examples: [ { cmd: "wallet-cli tx multisig" }, - { cmd: "wallet-cli tx multisig --create --file tx.unsigned.hex" }, + { cmd: "wallet-cli tx multisig --create --file tx.unsigned.hex --password-stdin" }, { cmd: "wallet-cli tx multisig --sign 9c1... --password-stdin" }, { cmd: "wallet-cli tx multisig --watch" }, ], @@ -250,7 +251,7 @@ export const txTronLinkMultisigSpec: ChainSpec = { export const txTronLinkMultisigBinding = (service: TronMultisigCollaborationService): FamilyBinding => ({ run: async (ctx, network, input) => { const address = ctx.resolveAddress("tron"); - if (input.create) return service.create(network, address, hexInput(input)); + if (input.create) return service.create(ctx, network, hexInput(input)); if (input.sign) return service.sign(ctx, network, input.sign); if (!input.watch) return service.list(network, address); diff --git a/ts/src/adapters/inbound/cli/render/multisig.test.ts b/ts/src/adapters/inbound/cli/render/multisig.test.ts new file mode 100644 index 000000000..ec412a599 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/multisig.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import type { TronLinkMultisigListView } from "../../../../domain/types/index.js"; +import { MultisigFormatters } from "./multisig.js"; + +const A = "TLZz5XKerAAebbRdScB3jmSPr5DHSpGJJP"; + +function transaction(overrides: Record = {}) { + return { + txId: "ab".repeat(32), + state: "pending", + contractType: "TransferContract", + originator: A, + owner: A, + permission: { id: 2, name: "finance", threshold: 2 }, + currentWeight: 1, + missingWeight: 1, + thresholdReached: false, + awaitingMySignature: true, + signedByCurrentAccount: false, + createdAt: 1_900_000_000_000, + expiration: 1_900_000_600_000, + expired: false, + signatures: 1, + signatureProgress: [], + from: A, + rawAmount: "1000000", + verified: true, + ...overrides, + }; +} + +function render(...transactions: unknown[]): string { + return MultisigFormatters.txTronLinkMultisig({ + address: A, + total: transactions.length, + transactions, + } as unknown as TronLinkMultisigListView); +} + +describe("TronLink multi-sig list rendering", () => { + // An unverified row's service-claimed state is exactly what must not be acted on, so the row + // stays visible but says so, and it never counts toward the co-sign prompt. + it("marks a chain-unverified row and keeps it out of the co-sign hint", () => { + const output = render(transaction({ + verified: false, + unverifiedReason: "TronLink transaction metadata or signatures disagree with the selected network", + })); + expect(output).toContain("unverified"); + expect(output).not.toContain("awaiting you"); + expect(output).not.toContain("--sign "); + expect(output).toContain("disagree"); + }); + + it("still prompts to co-sign when a verified row awaits this account", () => { + const output = render(transaction(), transaction({ verified: false, txId: "cd".repeat(32) })); + expect(output).toContain("awaiting you"); + expect(output).toContain("--sign "); + }); +}); + +function receipt(action: "create" | "sign", thresholdReached: boolean): string { + return MultisigFormatters.txTronLinkMultisig({ + action, + accepted: true, + signer: A, + signerWeight: 1, + hex: "0a02cafe", + transaction: { + txId: "ab".repeat(32), + contractType: "TransferContract", + operation: "Transfer TRX", + from: A, + rawAmount: "1000000", + permission: { id: 2, name: "finance", threshold: 2 }, + currentWeight: thresholdReached ? 2 : 1, + missingWeight: thresholdReached ? 0 : 1, + thresholdReached, + approved: [{ address: A, weight: 1 }], + expiration: 1_900_000_600_000, + expired: false, + signatures: thresholdReached ? 2 : 1, + }, + } as never); +} + +describe("TronLink multi-sig receipts", () => { + // --create is the originator's signature, so the receipt has to name the signer and its weight + // the way --sign does; the collection opens at 1 of N, never at 0. + it("reports the originator's own signature on create and points at the co-signers", () => { + const output = receipt("create", false); + expect(output).toContain("Created on TronLink multi-sig service"); + expect(output).toContain(`Signer ${A} (weight 1)`); + expect(output).toContain("0a02cafe"); + expect(output).toContain("! Each co-signer signs it with: wallet-cli tx multisig --sign"); + expect(output).not.toContain("tx broadcast"); + }); + + // The service broadcasts once the threshold is met, so an unconditional "broadcast it" would + // send the user at a call that fails with "Transaction already exists." + it("tells both create and sign to confirm on chain before broadcasting at threshold", () => { + for (const action of ["create", "sign"] as const) { + const output = receipt(action, true); + expect(output).toContain("Threshold reached — the service broadcasts it. Confirm:"); + expect(output).toContain("wallet-cli tx info --txid ab"); + expect(output).toContain("Not on chain: wallet-cli tx broadcast --hex 0a02cafe"); + } + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/multisig.ts b/ts/src/adapters/inbound/cli/render/multisig.ts index 19744e94c..b71c52eaf 100644 --- a/ts/src/adapters/inbound/cli/render/multisig.ts +++ b/ts/src/adapters/inbound/cli/render/multisig.ts @@ -20,7 +20,10 @@ function renderTronLink(value: TronLinkMultisigView): string { ? `${formatSun(transaction.rawAmount)} TRX` : `${transaction.rawAmount} base units` : ""; - const state = transaction.awaitingMySignature ? "awaiting you" : transaction.state; + // An unverified row's service-claimed state is precisely what must not be acted on. + const state = !transaction.verified + ? "unverified" + : transaction.awaitingMySignature ? "awaiting you" : transaction.state; return [ transaction.txId, transaction.contractType, @@ -30,13 +33,17 @@ function renderTronLink(value: TronLinkMultisigView): string { formatAtWithRelative(transaction.expiration), ]; }); - const hint = value.transactions.some((transaction) => transaction.awaitingMySignature) + const hint = value.transactions.some((t) => t.verified && t.awaitingMySignature) ? "\n! Co-sign one with: wallet-cli tx multisig --sign " : ""; + const unverified = value.transactions + .filter((transaction) => !transaction.verified) + .map((transaction) => `\n! ${transaction.txId} unverified — ${transaction.unverifiedReason ?? "chain check failed"}`) + .join(""); return `${heading}\n${table( ["TxID", "Type", "Amount", "State", "Progress", "Expires"], rows, - )}${hint}`; + )}${unverified}${hint}`; } if (value.action === "watch") { return receipt(ok(), "Stopped watching TronLink multi-sig service", [ @@ -45,20 +52,31 @@ function renderTronLink(value: TronLinkMultisigView): string { ]); } if (value.action === "create") { - return `${receipt(ok(), "Created on TronLink multi-sig service", [ - ["TxID", value.transaction.txId], - ])}\n\n${renderApproval(value.transaction)}\n! Each signer signs it with: wallet-cli tx multisig --sign ${value.transaction.txId}`; + const created = receipt(ok(), "Created on TronLink multi-sig service", [ + ["Signer", `${value.signer} (weight ${formatInt(value.signerWeight)})`], + ["Hex", value.hex], + ]); + const next = value.transaction.thresholdReached + ? thresholdHint(value.transaction.txId, value.hex) + : `\n! Each co-signer signs it with: wallet-cli tx multisig --sign ${value.transaction.txId}`; + return `${created}\n\n${renderApproval(value.transaction)}${next}`; } const signed = receipt(ok(), "Signed & submitted", [ ["Signer", `${value.signer} (weight ${formatInt(value.signerWeight)})`], ["Hex", value.hex], ]); const broadcast = value.transaction.thresholdReached - ? `\n! Broadcast it: wallet-cli tx broadcast --hex ${value.hex}` + ? thresholdHint(value.transaction.txId, value.hex) : ""; return `${signed}\n\n${renderApproval(value.transaction)}${broadcast}`; } +/** The service broadcasts on its own once the threshold is met, so confirm before broadcasting. */ +function thresholdHint(txId: string, hex: string): string { + return `\n! Threshold reached — the service broadcasts it. Confirm: wallet-cli tx info --txid ${txId}` + + `\n Not on chain: wallet-cli tx broadcast --hex ${hex}`; +} + function renderSign(value: TxSignView): string { const artifact = value.out ? `written to ${value.out}` : value.hex; const signer = value.checked diff --git a/ts/src/adapters/outbound/persistence/keypair-writer.test.ts b/ts/src/adapters/outbound/persistence/keypair-writer.test.ts index def1a1685..67f9e81f7 100644 --- a/ts/src/adapters/outbound/persistence/keypair-writer.test.ts +++ b/ts/src/adapters/outbound/persistence/keypair-writer.test.ts @@ -31,7 +31,7 @@ describe("SecureKeypairWriter", () => { it("creates a new durable 0600 JSON artifact", () => { const directory = root(); const path = join(directory, "nested", "key.json"); - expect(new SecureKeypairWriter().write(path, { privateKey: "secret" })) + expect(new SecureKeypairWriter(directory).write({ out: path }, { privateKey: "secret" })) .toBe(path); expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ privateKey: "secret", @@ -41,13 +41,26 @@ describe("SecureKeypairWriter", () => { } }); + // The default location moved here from AddressService: a use case should not know that generated + // keypairs live under /generated, nor how the filename is built. + it("derives the default location from the wallet root and the name", () => { + const directory = root(); + const address = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; + + const written = new SecureKeypairWriter(directory) + .write({ name: address }, { privateKey: "secret" }); + + expect(written).toBe(join(directory, "generated", `keypair-${address}`)); + expect(JSON.parse(readFileSync(written, "utf8"))).toEqual({ privateKey: "secret" }); + }); + it("never overwrites an existing file", () => { const directory = root(); const path = join(directory, "key.json"); writeFileSync(path, "original", { mode: 0o600 }); expect(() => - new SecureKeypairWriter().write(path, { privateKey: "replacement" }) + new SecureKeypairWriter(directory).write({ out: path }, { privateKey: "replacement" }) ).toThrow(/refusing to overwrite/); expect(readFileSync(path, "utf8")).toBe("original"); }); @@ -57,14 +70,14 @@ describe("SecureKeypairWriter", () => { it("removes its own unfinished file so the same path can be retried", () => { const directory = root(); const path = join(directory, "key.json"); - const writer = new SecureKeypairWriter(); + const writer = new SecureKeypairWriter(directory); // fails inside the try, after the file exists: JSON.stringify cannot serialize a BigInt - expect(() => writer.write(path, { privateKey: 1n })) + expect(() => writer.write({ out: path }, { privateKey: 1n })) .toThrow(/could not write keypair file/); expect(existsSync(path)).toBe(false); - expect(writer.write(path, { privateKey: "secret" })).toBe(path); + expect(writer.write({ out: path }, { privateKey: "secret" })).toBe(path); expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ privateKey: "secret" }); }); @@ -73,7 +86,7 @@ describe("SecureKeypairWriter", () => { const path = join(directory, "key.json"); writeFileSync(path, "original", { mode: 0o600 }); - expect(() => new SecureKeypairWriter().write(path, { privateKey: 1n })) + expect(() => new SecureKeypairWriter(directory).write({ out: path }, { privateKey: 1n })) .toThrow(/refusing to overwrite/); expect(readFileSync(path, "utf8")).toBe("original"); }); @@ -86,7 +99,7 @@ describe("SecureKeypairWriter", () => { writeFileSync(path, "original", { mode: 0o600 }); try { - new SecureKeypairWriter().write(path, { privateKey: "replacement" }); + new SecureKeypairWriter(directory).write({ out: path }, { privateKey: "replacement" }); expect.unreachable("expected the write to be refused"); } catch (error) { expect(error).toMatchObject({ code: "output_exists", kind: "usage" }); @@ -102,7 +115,7 @@ describe("SecureKeypairWriter", () => { symlinkSync(external, path); expect(() => - new SecureKeypairWriter().write(path, { privateKey: "replacement" }) + new SecureKeypairWriter(directory).write({ out: path }, { privateKey: "replacement" }) ).toThrow(/refusing to overwrite/); expect(readFileSync(external, "utf8")).toBe("original"); }); diff --git a/ts/src/adapters/outbound/persistence/keypair-writer.ts b/ts/src/adapters/outbound/persistence/keypair-writer.ts index 432246cbf..d9429e46a 100644 --- a/ts/src/adapters/outbound/persistence/keypair-writer.ts +++ b/ts/src/adapters/outbound/persistence/keypair-writer.ts @@ -8,13 +8,23 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { ExecutionError, UsageError } from "../../../domain/errors/index.js"; -import type { KeypairWriter } from "../../../application/ports/keypair-writer.js"; +import type { KeypairTarget, KeypairWriter } from "../../../application/ports/keypair-writer.js"; /** Exclusive, no-follow writer for plaintext private-key artifacts. */ export class SecureKeypairWriter implements KeypairWriter { - write(path: string, value: unknown): string { + constructor(private readonly root: string) {} + + write(target: KeypairTarget, value: unknown): string { + // Default location is this adapter's business — the use case only supplies the name. + return this.#writeTo( + "out" in target ? target.out : join(this.root, "generated", `keypair-${target.name}`), + value, + ); + } + + #writeTo(path: string, value: unknown): string { const target = resolve(path); mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); let descriptor: number | undefined; diff --git a/ts/src/adapters/outbound/tronlink/client.test.ts b/ts/src/adapters/outbound/tronlink/client.test.ts index 2231d0081..aad0f573f 100644 --- a/ts/src/adapters/outbound/tronlink/client.test.ts +++ b/ts/src/adapters/outbound/tronlink/client.test.ts @@ -67,25 +67,29 @@ describe("TronLink REST/WebSocket collaboration adapter", () => { ); }); - it("uploads unsigned raw_data with the Java createTransaction query shape", async () => { + // The service authenticates POSTs over exactly this key set; one extra signed field is rejected + // with code 4000, so pin the signed set rather than only the body. + it("signs POSTs over the fixed auth set plus address, and nothing else", async () => { const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => response({ code: 0, data: {} })); const client = new TronLinkClient(CONFIG, 1_000, fetchMock as typeof fetch, () => NOW, () => UUID); - await client.create(NETWORK, ADDRESS, { - permissionName: "finance", - txId: "ab".repeat(32), - rawDataJson: '{"contract":[]}', - contractType: "TransferContract", - }); + await client.submit(NETWORK, ADDRESS, { visible: true } as TronTransactionArtifact); - const [rawUrl, init] = fetchMock.mock.calls[0]!; - const calledUrl = new URL(String(rawUrl)); - expect(calledUrl.searchParams.get("permission_name")).toBe("finance"); - expect(calledUrl.searchParams.get("tx_id")).toBe("ab".repeat(32)); - expect(JSON.parse(String(init?.body))).toEqual({ - raw_data: '{"contract":[]}', - extra: { type: "TransferContract" }, - }); + const calledUrl = new URL(String(fetchMock.mock.calls[0]![0])); + expect(calledUrl.pathname).toBe("/multi/transaction"); + expect([...calledUrl.searchParams.keys()].sort()).toEqual( + ["address", "channel", "secret_id", "sign", "sign_version", "ts", "uuid"], + ); + expect(calledUrl.searchParams.get("sign")).toBe( + signTronLinkRequest("POST", "/multi/transaction", { + sign_version: "v1", + channel: "wallet-cli", + secret_id: "sid", + ts: String(NOW), + uuid: UUID, + address: ADDRESS, + }, "super-secret").signature, + ); }); it("submits the complete accumulated transaction for a signing step", async () => { @@ -132,6 +136,48 @@ describe("TronLink REST/WebSocket collaboration adapter", () => { expect(messages).toEqual([[{ state: 0, is_sign: 0 }]]); }); + // The business code alone cannot tell a rejected signature (4000) from a bad parameter (20004) + // or a stale signature (20305). Carry the service's own wording through to the caller. + it("carries the service's rejection message into the error details", async () => { + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => + response({ code: 4000, message: "Authentication failed." })); + const client = new TronLinkClient(CONFIG, 1_000, fetchMock as typeof fetch, () => NOW, () => UUID); + await expect(client.list(NETWORK, ADDRESS, { state: 255, start: 0, limit: 20 })) + .rejects.toMatchObject({ + code: "provider_error", + details: { code: 4000, providerMessage: "Authentication failed." }, + }); + }); + + it("bounds the service message and drops non-string or control-character noise", async () => { + const noisy = `x\u0000y\u001b[31m${"z".repeat(400)}`; + const client = (body: unknown) => new TronLinkClient( + CONFIG, + 1_000, + (vi.fn(async () => response(body)) as unknown) as typeof fetch, + () => NOW, + () => UUID, + ); + let rejection: { details?: { providerMessage?: string } } = {}; + try { + await client({ code: 20004, message: noisy }) + .list(NETWORK, ADDRESS, { state: 255, start: 0, limit: 20 }); + } catch (error) { + rejection = error as { details?: { providerMessage?: string } }; + } + const message = rejection.details?.providerMessage ?? ""; + expect(message.length).toBeLessThanOrEqual(200); + expect(message).not.toMatch(/[\u0000-\u001f]/); + expect(message.startsWith("xy[31mzzz")).toBe(true); + + await expect(client({ code: 20004, message: { nested: true } }) + .list(NETWORK, ADDRESS, { state: 255, start: 0, limit: 20 })) + .rejects.toMatchObject({ code: "provider_error", details: { code: 20004 } }); + await expect(client({ code: 20004, message: { nested: true } }) + .list(NETWORK, ADDRESS, { state: 255, start: 0, limit: 20 })) + .rejects.not.toHaveProperty("details.providerMessage"); + }); + it("requires complete credentials and rejects insecure endpoints before a request", async () => { const fetchMock = vi.fn(); await expect(new TronLinkClient({} as Config, 1_000, fetchMock as typeof fetch) diff --git a/ts/src/adapters/outbound/tronlink/client.ts b/ts/src/adapters/outbound/tronlink/client.ts index 33f4e3e00..6b1f78793 100644 --- a/ts/src/adapters/outbound/tronlink/client.ts +++ b/ts/src/adapters/outbound/tronlink/client.ts @@ -3,7 +3,6 @@ import WebSocket, { type ClientOptions, type RawData } from "ws"; import { isLosslessNumber, parse as parseLosslessJson } from "lossless-json"; import type { TronLinkCollaborationPort, - TronLinkCreateRequest, TronLinkListFilter, TronLinkRemotePage, TronLinkRemoteRecord, @@ -75,27 +74,6 @@ export class TronLinkClient implements TronLinkCollaborationPort { return parsePage(await this.#request(network, path, parameters), filter.limit); } - async create( - network: NetworkDescriptor, - address: string, - request: TronLinkCreateRequest, - ): Promise { - const credentials = this.#credentials(); - const path = "/multi/transaction"; - const auth = tronLinkAuthParameters(credentials, this.clock, this.uuid); - const signed = { - ...auth, - address, - permission_name: request.permissionName, - tx_id: request.txId, - }; - const sign = signTronLinkRequest("POST", path, signed, credentials.secretKey).signature; - await this.#request(network, path, { ...signed, sign }, { - raw_data: request.rawDataJson, - extra: { type: request.contractType }, - }); - } - async submit( network: NetworkDescriptor, address: string, @@ -300,11 +278,23 @@ function unwrapBusinessResponse(value: unknown): unknown { const root = record(value, "response"); const code = safeInteger(root.code, "code", 0); if (code !== 0) { - throw new ChainError("provider_error", "TronLink collaboration service rejected the request", { code }); + // The code alone does not separate a rejected signature from a bad parameter or a stale one. + const providerMessage = boundedProviderMessage(root.message); + throw new ChainError("provider_error", "TronLink collaboration service rejected the request", { + code, + ...(providerMessage === undefined ? {} : { providerMessage }), + }); } return root.data; } +/** Provider-controlled text reaching our output: strip control characters and bound the length. */ +function boundedProviderMessage(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const cleaned = value.replaceAll(/[\p{Cc}\p{Cf}]/gu, "").trim(); + return cleaned === "" ? undefined : cleaned.slice(0, 200); +} + function parsePage(value: unknown, requestedLimit: number): TronLinkRemotePage { const data = record(value, "data"); const total = safeInteger(data.range_total, "data.range_total", 0); diff --git a/ts/src/application/ports/keypair-writer.ts b/ts/src/application/ports/keypair-writer.ts index 1fcb9c884..3bf740cb2 100644 --- a/ts/src/application/ports/keypair-writer.ts +++ b/ts/src/application/ports/keypair-writer.ts @@ -1,3 +1,12 @@ +/** + * Outbound boundary for persisting a generated keypair. + * + * The caller states intent and the adapter decides where that lands: either an explicit path the + * user named, or the name the artifact should be filed under. Filesystem layout is not something a + * use case should know, and the two forms are exclusive — a supplied path leaves nothing to derive. + */ +export type KeypairTarget = { out: string } | { name: string }; + export interface KeypairWriter { - write(path: string, value: unknown): string; + write(target: KeypairTarget, value: unknown): string; } diff --git a/ts/src/application/ports/tronlink-collaboration.ts b/ts/src/application/ports/tronlink-collaboration.ts index 8371a1c26..897bc6385 100644 --- a/ts/src/application/ports/tronlink-collaboration.ts +++ b/ts/src/application/ports/tronlink-collaboration.ts @@ -26,13 +26,6 @@ export interface TronLinkRemotePage { records: TronLinkRemoteRecord[]; } -export interface TronLinkCreateRequest { - permissionName: string; - txId: string; - rawDataJson: string; - contractType: string; -} - /** Outbound boundary for the official walletadapter REST/WebSocket collaboration service. */ export interface TronLinkCollaborationPort { list( @@ -40,11 +33,7 @@ export interface TronLinkCollaborationPort { address: string, filter: TronLinkListFilter, ): Promise; - create( - network: NetworkDescriptor, - address: string, - request: TronLinkCreateRequest, - ): Promise; + /** Opens a collection when the transaction is new, and accumulates onto it thereafter. */ submit( network: NetworkDescriptor, address: string, diff --git a/ts/src/application/use-cases/address-service.test.ts b/ts/src/application/use-cases/address-service.test.ts index 67e66b59a..1348f0140 100644 --- a/ts/src/application/use-cases/address-service.test.ts +++ b/ts/src/application/use-cases/address-service.test.ts @@ -8,7 +8,6 @@ describe("AddressService", () => { const writer = { write: vi.fn(() => "/safe/key.json") }; const scalar = Uint8Array.from([...new Uint8Array(31), 1]); const result = new AddressService( - "/safe", writer, () => scalar, ).generate({ printSecret: false }); @@ -19,8 +18,9 @@ describe("AddressService", () => { secretFile: "/safe/key.json", }); expect(JSON.stringify(result)).not.toContain(SECRET); + // the use case states the name; where it lands is the adapter's decision expect(writer.write).toHaveBeenCalledWith( - "/safe/generated/keypair-TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + { name: "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC" }, expect.objectContaining({ privateKey: SECRET }), ); expect(scalar.every((byte) => byte === 0)).toBe(true); @@ -29,7 +29,6 @@ describe("AddressService", () => { it("returns the private key only when printSecret is explicit", () => { const writer = { write: vi.fn() }; const result = new AddressService( - "/safe", writer, () => Uint8Array.from([...new Uint8Array(31), 1]), ).generate({ printSecret: true }); @@ -41,7 +40,7 @@ describe("AddressService", () => { it("bounds rejection sampling when an entropy source is broken", () => { const random = vi.fn(() => new Uint8Array(32)); expect(() => - new AddressService("/safe", { write: vi.fn() }, random) + new AddressService({ write: vi.fn() }, random) .generate({ printSecret: false }) ).toThrow(/valid secp256k1/); expect(random).toHaveBeenCalledTimes(128); diff --git a/ts/src/application/use-cases/address-service.ts b/ts/src/application/use-cases/address-service.ts index d4da528b7..db8fb6ebf 100644 --- a/ts/src/application/use-cases/address-service.ts +++ b/ts/src/application/use-cases/address-service.ts @@ -1,5 +1,4 @@ import { randomBytes } from "node:crypto"; -import { join } from "node:path"; import { bytesToHex } from "@noble/hashes/utils.js"; import { secp256k1 } from "@noble/curves/secp256k1.js"; import type { KeypairWriter } from "../ports/keypair-writer.js"; @@ -13,7 +12,6 @@ const MAX_SCALAR_ATTEMPTS = 128; export class AddressService { constructor( - private readonly root: string, private readonly writer: KeypairWriter, private readonly random: (size: number) => Uint8Array = randomBytes, ) {} @@ -50,10 +48,7 @@ export class AddressService { if (input.printSecret) { return { tron, evm, privateKey: privateKeyHex }; } - const path = - input.out - ?? join(this.root, "generated", `keypair-${tron}`); - const secretFile = this.writer.write(path, { + const secretFile = this.writer.write(input.out ? { out: input.out } : { name: tron }, { version: 1, privateKey: privateKeyHex, publicKey: bytesToHex(publicKey), diff --git a/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts b/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts index 01e4a7f1d..34a1d7298 100644 --- a/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts +++ b/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts @@ -10,6 +10,7 @@ import { decodeTransactionHex, encodeTransactionHex, } from "../../../adapters/outbound/chain/tron/transaction-codec.js"; +import { ChainError } from "../../../domain/errors/index.js"; import type { TronMultisigService } from "./multisig-service.js"; import { TronMultisigCollaborationService } from "./multisig-collaboration-service.js"; @@ -110,10 +111,10 @@ function approval(hex: string) { }; } -function setup(record = remote()) { +function setup(...given: TronLinkRemoteRecord[]) { + const records = given.length ? given : [remote()]; const collaboration = { - list: vi.fn(async () => ({ total: 1, records: [record] })), - create: vi.fn(async () => {}), + list: vi.fn(async () => ({ total: records.length, records })), submit: vi.fn(async () => {}), watch: vi.fn(async (_network, _address, _signal, onMessage) => { onMessage([{ state: 0, is_sign: 0 }, { state: 0, is_sign: 1 }]); @@ -164,6 +165,33 @@ describe("TronLink multi-sign collaboration workflow", () => { }); }); + // A permission updated while an old transaction sat pending makes that record disagree with the + // chain forever. Listing must survive it: one stale row cannot cost the user the whole queue. + it("lists a chain-disagreeing record as unverified instead of failing the whole page", async () => { + const stale = remote(unsignedHex(), { threshold: 1 }); + const signedHex = encodeTransactionHex({ ...decodeTransactionHex(unsignedHex()), signature: [SIG] }); + const result = await setup(stale, remote(signedHex)).service.list(NETWORK, A); + + expect(result.transactions).toHaveLength(2); + expect(result.transactions[0]).toMatchObject({ verified: false }); + expect(result.transactions[0]!.unverifiedReason).toMatch(/disagree/i); + expect(result.transactions[1]).toMatchObject({ verified: true }); + expect(result.transactions[1]!.unverifiedReason).toBeUndefined(); + }); + + // "Could not check" is not "checked and disagreed" — a node outage must not read as a clean page. + it("propagates a node failure rather than reporting every record as unverified", async () => { + const { service, local } = setup(); + vi.mocked(local.approvals).mockRejectedValue(new ChainError("timeout", "node timed out")); + await expect(service.list(NETWORK, A)).rejects.toMatchObject({ code: "timeout" }); + }); + + it("still refuses to co-sign a record that disagrees with the chain", async () => { + const { service } = setup(remote(unsignedHex(), { threshold: 1 })); + await expect(service.sign(scope(), NETWORK, decodeTransactionHex(unsignedHex()).txID)) + .rejects.toMatchObject({ code: "provider_error" }); + }); + it("rejects hash, owner, and progress metadata tampering", async () => { await expect(setup(remote(unsignedHex(), { hash: "00".repeat(32) })).service.list(NETWORK, A)) .rejects.toMatchObject({ code: "provider_error" }); @@ -173,28 +201,33 @@ describe("TronLink multi-sign collaboration workflow", () => { .rejects.toMatchObject({ code: "provider_error" }); }); - it("creates by uploading unsigned raw_data without invoking a signer", async () => { - const { service, collaboration, local } = setup(); - const result = await service.create(NETWORK, A, unsignedHex()); - expect(result).toMatchObject({ action: "create", accepted: true, transaction: { currentWeight: 0 } }); - expect(local.signChecked).not.toHaveBeenCalled(); - const request = vi.mocked(collaboration.create).mock.calls[0]![2]; - expect(request).toMatchObject({ - permissionName: "finance", - txId: decodeTransactionHex(unsignedHex()).txID, - contractType: "TransferContract", + it("opens the collection with the originator's own signature", async () => { + const { service, collaboration, local, signedHex } = setup(); + const context = scope(); + const result = await service.create(context, NETWORK, unsignedHex()); + expect(result).toMatchObject({ + action: "create", + accepted: true, + signer: A, + signerWeight: 1, + hex: signedHex, + transaction: { currentWeight: 1 }, }); - expect(JSON.parse(request.rawDataJson)).toMatchObject({ - contract: [{ parameter: { value: { owner_address: A, to_address: B } } }], + expect(local.signChecked).toHaveBeenCalledWith(context, NETWORK, unsignedHex()); + const submitted = vi.mocked(collaboration.submit).mock.calls[0]!; + expect(submitted[1]).toBe(A); + expect(submitted[2]).toMatchObject({ + visible: true, + raw_data: { contract: [{ parameter: { value: { owner_address: A, to_address: B } } }] }, }); }); it("refuses create artifacts that already contain a signature", async () => { const { service, collaboration, local, signedHex } = setup(); - await expect(service.create(NETWORK, A, signedHex)) + await expect(service.create(scope(), NETWORK, signedHex)) .rejects.toMatchObject({ code: "invalid_value" }); expect(local.signChecked).not.toHaveBeenCalled(); - expect(collaboration.create).not.toHaveBeenCalled(); + expect(collaboration.submit).not.toHaveBeenCalled(); }); it("fetches the accumulated transaction, adds one signature, and submits it", async () => { @@ -234,15 +267,22 @@ describe("TronLink signer roster is bound to the on-chain permission", () => { return setup(remote(unsignedHex(), { signature_progress: progress })); } - it("refuses a fabricated signer entry", async () => { + // Listing shows a fabricated roster as unverified rather than refusing the page, but it is never + // presented as chain-validated and never as work waiting on this account. + it("never presents a fabricated signer entry as verified", async () => { const { service } = withProgress([unsigned(A, 1), unsigned(B, 1), unsigned(C, 1)]); - await expect(service.list(NETWORK, A)).rejects.toMatchObject({ code: "provider_error" }); + const view = await service.list(NETWORK, A); + expect(view.transactions[0]).toMatchObject({ verified: false, awaitingMySignature: false }); + expect(view.transactions[0]!.unverifiedReason).toMatch(/not a key in the on-chain permission/); }); it("refuses to act for an account the permission does not contain", async () => { const { service } = withProgress([unsigned(A, 1), unsigned(B, 1), unsigned(C, 1)]); // C passes every provider-side consistency check because the provider invented its own entry - await expect(service.list(NETWORK, C)).rejects.toMatchObject({ code: "not_authorized" }); + const view = await service.list(NETWORK, C); + expect(view.transactions[0]).toMatchObject({ verified: false, awaitingMySignature: false }); + await expect(service.sign(scope(), NETWORK, decodeTransactionHex(unsignedHex()).txID)) + .rejects.toMatchObject({ code: "provider_error" }); }); it("renders on-chain weights, not the ones the provider reported", async () => { diff --git a/ts/src/application/use-cases/tron/multisig-collaboration-service.ts b/ts/src/application/use-cases/tron/multisig-collaboration-service.ts index efce33a40..79cda2ed6 100644 --- a/ts/src/application/use-cases/tron/multisig-collaboration-service.ts +++ b/ts/src/application/use-cases/tron/multisig-collaboration-service.ts @@ -49,7 +49,19 @@ export class TronMultisigCollaborationService { }); const gateway = this.gateways.get(network, "tron"); const validated = page.records.map((record) => this.#validate(gateway, address, record)); - await mapWithConcurrency(validated, 4, (transaction) => this.#verifyOnChain(network, address, transaction)); + // A permission changed while an old transaction sat pending makes that record disagree with + // the chain for good. Listing is informational, so one such row is reported as unverified + // rather than costing the whole page — acting on it (--sign) still refuses. + await mapWithConcurrency(validated, 4, async (transaction) => { + try { + await this.#verifyOnChain(network, address, transaction); + } catch (error) { + if (!isChainDisagreement(error)) throw error; + transaction.view.unverifiedReason = error.message; + // Nothing here is substantiated, least of all a claim on this account's signature. + transaction.view.awaitingMySignature = false; + } + }); return { address, total: page.total, @@ -57,9 +69,13 @@ export class TronMultisigCollaborationService { }; } + /** + * Opening a collection and casting its first signature are one act: the service has no empty + * collection, and derives the initial weight from the signature the transaction arrives with. + */ async create( + scope: TransactionScope, network: NetworkDescriptor, - address: string, unsignedHex: string, ) { const gateway = this.gateways.get(network, "tron"); @@ -67,29 +83,18 @@ export class TronMultisigCollaborationService { if ((transaction.signature?.length ?? 0) !== 0) { throw new UsageError("invalid_value", "TronLink --create requires an unsigned transaction"); } - const approval = await this.multisig.approvals(network, unsignedHex); - if (approval.expired) throw new ChainError("tx_expired", "transaction has expired"); - const weight = await gateway.getSignWeight(transaction); - const permission = weight.permission; - if (!permission - || permission.id !== approval.permission.id - || !permission.keys.some((key) => key.address === address)) { - throw new ChainError("not_authorized", "selected account is not a key in the transaction permission"); - } - - const visible = visibleTransaction(gateway, unsignedHex); - await this.collaboration.create(network, address, { - permissionName: permission.name, - txId: approval.txId, - rawDataJson: JSON.stringify(visible.raw_data), - contractType: approval.contractType, - }); + // signChecked rejects an expired transaction, a signer outside the permission group, and a + // repeat signature — the whole precondition set this collection needs. + const signed = await this.multisig.signChecked(scope, network, unsignedHex); + await this.collaboration.submit(network, signed.signer, visibleTransaction(gateway, signed.hex)); return { action: "create" as const, accepted: true as const, - hex: unsignedHex.trim().replace(/^0x/i, "").toLowerCase(), - transaction: approval, + signer: signed.signer, + signerWeight: signed.signerWeight, + hex: signed.hex, + transaction: signed.approval, }; } @@ -247,6 +252,8 @@ export class TronMultisigCollaborationService { return { hex, view: { + // Byte-level checks only; #verifyOnChain is what earns this. + verified: false, txId: hash, state, contractType, @@ -345,9 +352,19 @@ export class TronMultisigCollaborationService { ); } remote.view.permission.name = approval.permission.name; + remote.view.verified = true; } } +/** + * A verdict of "checked and disagreed", as opposed to "could not check". Only the former may be + * downgraded to an unverified row: a node outage must never read as a clean page. + */ +function isChainDisagreement(error: unknown): error is ChainError { + return error instanceof ChainError + && (error.code === "provider_error" || error.code === "not_authorized"); +} + /** Convert address fields to visible=true JSON, then prove that protobuf bytes are unchanged. */ function visibleTransaction(gateway: TronGateway, hex: string): TronTransactionArtifact { const transaction = structuredClone(gateway.decodeTransactionHex(hex)); diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index ae6b829b4..01b1f93c2 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -100,7 +100,7 @@ export function composeCliRuntime(options: BootstrapOptions) { registerEncodingCommands(registry, new EncodingService()); registerAddressCommands( registry, - new AddressService(root, new SecureKeypairWriter()), + new AddressService(new SecureKeypairWriter(root)), ); registerTronChainCommands(registry, { gateways: gatewayProvider, diff --git a/ts/src/domain/types/multisig.ts b/ts/src/domain/types/multisig.ts index 3af7efc7e..14aa6d416 100644 --- a/ts/src/domain/types/multisig.ts +++ b/ts/src/domain/types/multisig.ts @@ -70,6 +70,9 @@ export interface TronLinkSignatureProgressView { /** Validated projection of an untrusted TronLink collaboration record. */ export interface TronLinkMultisigTransactionView { + /** False when the record could not be reconciled with the chain; act on it at your own risk. */ + verified: boolean; + unverifiedReason?: string; txId: string; state: TronLinkMultisigState; contractType: string; @@ -104,6 +107,8 @@ export interface TronLinkMultisigListView { export interface TronLinkMultisigCreateView { action: "create"; accepted: true; + signer: string; + signerWeight: number; hex: string; transaction: TxApprovalView; } From 15c2ac843f8f5077b1f29b35fe9096eb458d6666 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 4 Aug 2026 03:21:19 +0800 Subject: [PATCH 20/24] fix(ts): make help state the constraints the runtime enforces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.1.2 help audit found that a command's --help routinely omitted what the command actually requires, so a reader could only learn the rule by failing a run. Fix that at the source: declare the constraints, render them, and cover each with a test. Mutually exclusive options (A-1) Options that are jointly required each rendered "[optional]" — individually true, but read together it says the whole set may be omitted, which is exactly what Zod rejects. Add a declarative ExclusiveGroup on the spec, rendered as a labelled block and emitted in the --json-schema catalog so agents see it too. Two flavours: "exactly-one" drops the misleading tag, "at-most-one" keeps it (tx send's asset selector really is optional — omit all three and you send native TRX). Eight groups, three of which the audit did not list: tx send x2 and message sign, whose --message-stdin never appeared under Options at all. Values and limits (A-2, A-3, A-4, A-5) --permission-id now names its groups (0=owner, 1=witness, 2-9=active). --expiration states the 24h cap it enforces and the ~60s node default when omitted. --build-only names both multi-sig routes it feeds, not just the TronLink one. address generate --out names the default path a private key lands on, which mattered before the run, not only in the receipt. Irreversible and costly operations (A-6, A-8, A-9, A-11) account set says each field can be set once and never changed, and is not `rename`. permission update says where the input shape comes from and that the lockout warning does not block the submission. The permission group page explains the model and names the update fee; the gasfree group page says fees come out of the token being sent. Wording that was false (B, root) --wait claimed "after broadcast ... returns the submitted txid", shown verbatim on gasfree transfer, which broadcasts nothing and returns a trace id. Reword globally rather than add a per-command override — a second copy is how this drift started. --timeout enumerated only RPC and device, leaving out the service APIs it equally bounds. chain was the one family-scoped group missing its (tron) tag. Also: permission update carried its own copies of three shared txModeFields descriptions; they now reuse the shared source, and a test keeps them there. Fourteen reference pages get the same --permission-id / --expiration wording, with the sync test tightened from "flag is present" to "semantics match", reading the expected text out of the schema instead of restating it. Ledger reach (separate thread, folded in) backup checks exportability before demanding a master password: a Ledger-only keystore may have no password sentinel, so the old order trapped the user on a prompt no answer satisfies. account activate and account set --id now require a software signer up front — the TRON app has no parser for AccountCreateContract or SetAccountIdContract and answers 0x6a80, which is now classified with wording covering both an unsupported contract type and a malformed payload. Tests 792 -> 833. Two defects the tests caught that review would not: an exclusive group whose member was named in camelCase resolved to nothing and vanished silently (the renderer now throws), and each shared-description edit left a stale duplicate behind in permission update. --- ts/docs/commands/account/activate.md | 4 +- ts/docs/commands/account/set.md | 4 +- ts/docs/commands/contract/deploy.md | 4 +- ts/docs/commands/contract/send.md | 4 +- ts/docs/commands/permission/update.md | 4 +- ts/docs/commands/reward/withdraw.md | 4 +- ts/docs/commands/stake/cancel-unfreeze.md | 4 +- ts/docs/commands/stake/delegate.md | 4 +- ts/docs/commands/stake/freeze.md | 4 +- ts/docs/commands/stake/undelegate.md | 4 +- ts/docs/commands/stake/unfreeze.md | 4 +- ts/docs/commands/stake/withdraw.md | 4 +- ts/docs/commands/tx/send.md | 4 +- ts/docs/commands/vote/cast.md | 4 +- .../adapters/inbound/cli/commands/account.ts | 9 +- .../inbound/cli/commands/address.test.ts | 56 ++++++ .../adapters/inbound/cli/commands/address.ts | 5 +- .../cli/commands/irreversible-help.test.ts | 38 ++++ .../inbound/cli/commands/message.sign.test.ts | 36 ++++ .../inbound/cli/commands/permission.ts | 16 +- .../adapters/inbound/cli/commands/shared.ts | 17 +- .../cli/commands/transaction-options.test.ts | 73 +++++++ .../inbound/cli/commands/tx.sign.test.ts | 42 ++++ ts/src/adapters/inbound/cli/commands/tx.ts | 18 +- .../cli/commands/wallet.backup.test.ts | 101 ++++++++++ .../adapters/inbound/cli/commands/wallet.ts | 14 +- .../adapters/inbound/cli/contracts/command.ts | 17 ++ .../inbound/cli/globals/globals.test.ts | 35 ++++ ts/src/adapters/inbound/cli/globals/index.ts | 6 +- ts/src/adapters/inbound/cli/help/catalog.ts | 2 + ts/src/adapters/inbound/cli/help/help.test.ts | 190 ++++++++++++++++++ ts/src/adapters/inbound/cli/help/index.ts | 55 ++++- ts/src/adapters/outbound/ledger/index.test.ts | 31 +++ ts/src/adapters/outbound/ledger/index.ts | 11 + .../use-cases/tron/account-service.test.ts | 48 +++++ .../use-cases/tron/account-service.ts | 11 +- .../application/use-cases/wallet-service.ts | 20 +- 37 files changed, 842 insertions(+), 65 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/address.test.ts create mode 100644 ts/src/adapters/inbound/cli/commands/irreversible-help.test.ts create mode 100644 ts/src/adapters/inbound/cli/commands/message.sign.test.ts create mode 100644 ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts create mode 100644 ts/src/adapters/inbound/cli/globals/globals.test.ts diff --git a/ts/docs/commands/account/activate.md b/ts/docs/commands/account/activate.md index 9e6a53976..81821cef8 100644 --- a/ts/docs/commands/account/activate.md +++ b/ts/docs/commands/account/activate.md @@ -38,8 +38,8 @@ invisible to the node is treated as a failure, not a success. | `--dry-run` | Build and estimate only — no signature, no broadcast | | `--sign-only` | Sign and output the complete transaction hex without broadcasting | | `--build-only` | Build and output the unsigned transaction hex without unlocking | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | | `--password-stdin` | Master password from stdin (software accounts) | diff --git a/ts/docs/commands/account/set.md b/ts/docs/commands/account/set.md index a14dc3d5b..a3077d953 100644 --- a/ts/docs/commands/account/set.md +++ b/ts/docs/commands/account/set.md @@ -39,8 +39,8 @@ stored value does not match what was submitted. | `--dry-run` | Build and estimate only — no signature, no broadcast | | `--sign-only` | Sign and output the complete transaction hex without broadcasting | | `--build-only` | Build and output the unsigned transaction hex without unlocking | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | | `--password-stdin` | Master password from stdin (software accounts) | diff --git a/ts/docs/commands/contract/deploy.md b/ts/docs/commands/contract/deploy.md index af72dc8c0..472942a1e 100644 --- a/ts/docs/commands/contract/deploy.md +++ b/ts/docs/commands/contract/deploy.md @@ -30,8 +30,8 @@ Requires an account. The master password (via `--password-stdin`) is needed only | `--dry-run` | Estimate only | | `--sign-only` | Sign without broadcasting | | `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/contract/send.md b/ts/docs/commands/contract/send.md index 4d8f496cd..1d7c45c69 100644 --- a/ts/docs/commands/contract/send.md +++ b/ts/docs/commands/contract/send.md @@ -32,8 +32,8 @@ Requires an account. The master password (via `--password-stdin`) is needed only | `--dry-run` | Estimate energy only, no signature/broadcast | | `--sign-only` | Sign without broadcasting | | `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/permission/update.md b/ts/docs/commands/permission/update.md index a2ebdc8ef..5579c0340 100644 --- a/ts/docs/commands/permission/update.md +++ b/ts/docs/commands/permission/update.md @@ -124,8 +124,8 @@ update fee (`insufficient_balance`) — this fee is substantial on mainnet, so c | `--dry-run` | Validate, build, and estimate without signing or broadcasting | | `--sign-only` | Build and sign, then output the complete transaction hex | | `--build-only` | Build and output unsigned transaction hex without unlocking | -| `--permission-id <0-9>` | Permission group authorizing *this* transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active) — authorizes *this* update, not the structure being written; default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed | | `--password-stdin` | Master password from stdin (software accounts) | diff --git a/ts/docs/commands/reward/withdraw.md b/ts/docs/commands/reward/withdraw.md index 7cef0ed3f..6e95bad02 100644 --- a/ts/docs/commands/reward/withdraw.md +++ b/ts/docs/commands/reward/withdraw.md @@ -23,8 +23,8 @@ Moves your accumulated voting rewards (plus block rewards if you are an SR) into | `--dry-run` | Build and estimate only, no signature/broadcast | | `--sign-only` | Sign without broadcasting | | `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/cancel-unfreeze.md b/ts/docs/commands/stake/cancel-unfreeze.md index 909f26f17..a4024783d 100644 --- a/ts/docs/commands/stake/cancel-unfreeze.md +++ b/ts/docs/commands/stake/cancel-unfreeze.md @@ -21,8 +21,8 @@ Cancels **every** unstake still in its waiting period and rolls those amounts ba | `--dry-run` | Estimate only, no signature/broadcast | | `--sign-only` | Sign without broadcasting | | `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/delegate.md b/ts/docs/commands/stake/delegate.md index 3a1c7a200..61fe135cd 100644 --- a/ts/docs/commands/stake/delegate.md +++ b/ts/docs/commands/stake/delegate.md @@ -32,8 +32,8 @@ Check how much you can still delegate with [`stake delegated`](delegated.md) (`M | `--dry-run` | Estimate only, no signature/broadcast | | `--sign-only` | Sign without broadcasting | | `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/freeze.md b/ts/docs/commands/stake/freeze.md index d1fd917cd..05a3d1b7f 100644 --- a/ts/docs/commands/stake/freeze.md +++ b/ts/docs/commands/stake/freeze.md @@ -26,8 +26,8 @@ Amount is in SUN (1 TRX = 1,000,000 SUN). Staked TRX stays yours; to get it back | `--dry-run` | Estimate only, no signature/broadcast | | `--sign-only` | Sign without broadcasting | | `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/undelegate.md b/ts/docs/commands/stake/undelegate.md index 46782ca9f..303784371 100644 --- a/ts/docs/commands/stake/undelegate.md +++ b/ts/docs/commands/stake/undelegate.md @@ -28,8 +28,8 @@ Reclaiming is immediate (no waiting period — the TRX was staked all along, onl | `--dry-run` | Estimate only, no signature/broadcast | | `--sign-only` | Sign without broadcasting | | `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/unfreeze.md b/ts/docs/commands/stake/unfreeze.md index d7a26bde2..59fe44d3b 100644 --- a/ts/docs/commands/stake/unfreeze.md +++ b/ts/docs/commands/stake/unfreeze.md @@ -26,8 +26,8 @@ Stake 2.0 allows at most **32 pending unstakes** per account at a time; check re | `--dry-run` | Estimate only, no signature/broadcast | | `--sign-only` | Sign without broadcasting | | `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/withdraw.md b/ts/docs/commands/stake/withdraw.md index 9e95c1248..7f337719b 100644 --- a/ts/docs/commands/stake/withdraw.md +++ b/ts/docs/commands/stake/withdraw.md @@ -23,8 +23,8 @@ Withdrawing also frees up unstake slots (max 32 pending unstakes per account). | `--dry-run` | Estimate only, no signature/broadcast | | `--sign-only` | Sign without broadcasting | | `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/tx/send.md b/ts/docs/commands/tx/send.md index ee4ac8c98..caf61a638 100644 --- a/ts/docs/commands/tx/send.md +++ b/ts/docs/commands/tx/send.md @@ -45,8 +45,8 @@ Requires an account. The master password (via `--password-stdin`) is needed only | `--dry-run` | Build and estimate only | | `--sign-only` | Sign without broadcasting | | `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](index.md) | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default 60000; on cap returns the submitted receipt) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/vote/cast.md b/ts/docs/commands/vote/cast.md index 834b9d71e..9f645f1d7 100644 --- a/ts/docs/commands/vote/cast.md +++ b/ts/docs/commands/vote/cast.md @@ -30,8 +30,8 @@ Votes take effect at the next maintenance cycle (~6 h). Each vote uses 1 TP (it | `--dry-run` | Build and estimate only, no signature/broadcast | | `--sign-only` | Sign without broadcasting (feed to [`tx broadcast`](../tx/broadcast.md)) | | `--build-only` | Build and output the unsigned transaction hex **without unlocking** — the entry point for [multi-party or offline signing](../tx/index.md) | -| `--permission-id <0-9>` | TRON permission group id used to authorize this transaction (default `0`) | -| `--expiration ` | Expiration duration in ms (1–86400000); only with `--sign-only` / `--build-only` | +| `--permission-id <0-9>` | TRON permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to 86400000 (24h); only with `--sign-only` / `--build-only`; omitted = node default (~60s) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin (fd 0) | diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts index 9e53e2bc7..69fb3374a 100644 --- a/ts/src/adapters/inbound/cli/commands/account.ts +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -39,7 +39,9 @@ export const accountActivateSpec: ChainSpec = { capability: "account.activate", summary: "Activate a new TRON account", description: - "Create an AccountCreateContract funded by the active account. The target must not already be active; use --dry-run to inspect current creation fees.", + "Create an AccountCreateContract funded by the active account. The target must not already be\n" + + "active; use --dry-run to inspect current creation fees. Note: a plain transfer also activates\n" + + "the recipient, so use this command only when the address just needs to exist.", baseFields: z.object({ address: z.string().min(1).describe("unactivated TRON base58 address"), ...txModeFields, @@ -67,12 +69,15 @@ export const accountSetSpec: ChainSpec = { capability: "account.set", summary: "Set the one-time on-chain account name or ID", description: - "Set exactly one immutable account field. Names are 1-32 UTF-8 bytes; IDs are unique and 8-32 UTF-8 bytes.", + "Set exactly one immutable account field. Names are 1-32 UTF-8 bytes; IDs are unique and 8-32\n" + + "UTF-8 bytes. Each can be set only once and can never be changed afterwards — rehearse with\n" + + "--dry-run to check the value first. This is not `wallet-cli rename`, which changes the local label.", baseFields: z.object({ name: z.string().min(1).optional().describe("one-time on-chain account name (1-32 UTF-8 bytes)"), id: z.string().min(1).optional().describe("one-time unique account ID (8-32 UTF-8 bytes)"), ...txModeFields, }), + exclusive: [{ label: "what to set", flags: ["name", "id"] }], baseRefine: (input, context) => { if ((input.name === undefined) === (input.id === undefined)) { context.addIssue({ diff --git a/ts/src/adapters/inbound/cli/commands/address.test.ts b/ts/src/adapters/inbound/cli/commands/address.test.ts new file mode 100644 index 000000000..6602ebd4f --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/address.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { registerAddressCommands } from "./address.js"; +import { CommandRegistry } from "../registry/index.js"; +import { HelpService } from "../help/index.js"; +import type { StreamManager } from "../contracts/index.js"; + +function helpFor(path: string[]): string { + const registry = new CommandRegistry(); + registerAddressCommands(registry, { generate: async () => ({}) } as never); + let rendered = ""; + const streams = { + result(text: string) { rendered = text; }, + diagnostic() {}, errorLine() {}, event() {}, readStdinOnce: () => "", warnings: () => [], + } as unknown as StreamManager; + new HelpService(registry, streams, "0.0.0").handleMeta([...path, "--help"]); + return rendered; +} + +function schemaFor(path: string[]): any { + const registry = new CommandRegistry(); + registerAddressCommands(registry, { generate: async () => ({}) } as never); + let rendered = ""; + const streams = { + result(text: string) { rendered = text; }, + diagnostic() {}, errorLine() {}, event() {}, readStdinOnce: () => "", warnings: () => [], + } as unknown as StreamManager; + new HelpService(registry, streams, "0.0.0").handleMeta([...path, "--json-schema"]); + return JSON.parse(rendered); +} + +// Omitting --out writes a plaintext private key under the wallet root. The command reports the +// path afterwards, but help is what a reader consults *before* deciding to run this on a shared +// machine or a CI runner — so the location has to be visible up front, not only in the receipt. +describe("address generate --out documents its default location", () => { + it("names the default path in the flag description", () => { + const out = helpFor(["address", "generate"]) + .split("\n").find((line) => line.trimStart().startsWith("--out")) ?? ""; + // shape asserted against the writer in keypair-writer.test.ts ("derives the default location…") + expect(out).toContain("generated/keypair-
"); + }); + + it("keeps the machine schema honest: --out has no schema-level default", () => { + const properties = schemaFor(["address", "generate"]).properties; + expect(properties.out).toBeDefined(); + expect(properties.out).not.toHaveProperty("default"); + }); + + // The rendered "[optional, default: X]" tag is derived from zod, so faking one here would put a + // default in help that --json-schema does not have. + it("does not fake a default tag on the rendered flag line", () => { + const out = helpFor(["address", "generate"]) + .split("\n").find((line) => line.trimStart().startsWith("--out")) ?? ""; + expect(out).toContain("[optional]"); + expect(out).not.toMatch(/\[optional, default:/); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/address.ts b/ts/src/adapters/inbound/cli/commands/address.ts index f7e6fa435..05fb08a2d 100644 --- a/ts/src/adapters/inbound/cli/commands/address.ts +++ b/ts/src/adapters/inbound/cli/commands/address.ts @@ -9,8 +9,11 @@ export function registerAddressCommands( service: AddressService, ): void { const fields = z.object({ + // The default lives in SecureKeypairWriter, not in this schema, so it is stated in prose: + // a "[optional, default: …]" tag is derived from zod and would claim a default --json-schema + // does not have. Readers need the location before running, not only in the receipt. out: z.string().min(1).max(4096).optional() - .describe("exclusive 0600 output path; existing files are never overwritten"), + .describe("exclusive 0600 output path; existing files are never overwritten (default: /generated/keypair-
)"), printSecret: z.boolean().default(false) .describe("print the private key instead of writing it; use only offline"), }); diff --git a/ts/src/adapters/inbound/cli/commands/irreversible-help.test.ts b/ts/src/adapters/inbound/cli/commands/irreversible-help.test.ts new file mode 100644 index 000000000..f75661a8b --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/irreversible-help.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { accountActivateSpec, accountSetSpec } from "./account.js"; +import { permissionUpdateSpec } from "./permission.js"; + +// Both commands are one-way doors, and neither help text said so. `account set` is rejected with +// name_already_set / id_already_set on a second attempt (account-service.ts), and `permission +// update` only warns about a lockout — permissionSafetyWarnings goes to scope.warn and execution +// continues, with no confirmation prompt anywhere. A reader has to learn that before running, not +// from the error afterwards. +describe("help for the irreversible commands says so", () => { + it("account set warns the field can never be changed, and is not a local rename", () => { + const text = accountSetSpec.description ?? ""; + expect(text).toMatch(/only once|never be changed/i); + expect(text).toMatch(/--dry-run/); + // `rename` changes the local label and is the command people actually mean most of the time + expect(text).toMatch(/rename/); + }); + + // Activation is a paid transaction, and a plain transfer to an unactivated address already + // activates it. Someone who is going to send funds anyway does not need this command at all — + // without the hint they pay for two transactions where one would do. + it("account activate points out that a plain transfer already activates the target", () => { + const text = accountActivateSpec.description ?? ""; + expect(text).toMatch(/transfer/i); + expect(text).toMatch(/only when/i); + }); + + it("permission update says where the input shape comes from", () => { + expect(permissionUpdateSpec.description ?? "").toMatch(/permission show/); + }); + + it("permission update says the lockout warning does not stop the submission", () => { + const text = permissionUpdateSpec.description ?? ""; + expect(text).toMatch(/warns/i); + expect(text).toMatch(/does not (block|stop)/i); + expect(text).toMatch(/no confirmation prompt/i); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/message.sign.test.ts b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts new file mode 100644 index 000000000..b266a4b18 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { messageSignSpec, messageSignBinding } from "./shared.js"; + +// SecretResolver.pick is an exactly-one selector: both sources → invalid_option, neither → +// missing_option. That constraint used to live only in the runtime and in prose inside the +// --message description, so `--help` listed a lone "[optional]" flag and never named --message-stdin +// as the alternative. +describe("message sign exclusive group", () => { + it("declares the inline flag and the stdin channel as jointly required", () => { + expect(messageSignSpec.exclusive).toEqual([ + { label: "the message to sign", flags: ["message", "message-stdin"], select: "exactly-one" }, + ]); + }); + + it("states the constraint once — in the group, not also in the field description", () => { + const description = (messageSignSpec.baseFields.shape.message as { description?: string }).description ?? ""; + expect(description).not.toMatch(/exactly one|OR --message-stdin/i); + expect(description).toBeTruthy(); + }); + + it("still routes the resolved message to the service", async () => { + let received: unknown; + const service = { + sign: async (_ctx: unknown, _family: unknown, _account: unknown, message: string) => { + received = message; + return { kind: "message-sign" }; + }, + }; + const ctx = { + activeAccount: "main", + secrets: { pick: (inline: string | undefined) => inline ?? "from-stdin" }, + } as never; + await messageSignBinding(service as never).run(ctx, { family: "tron" } as never, { message: "hello" }); + expect(received).toBe("hello"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/permission.ts b/ts/src/adapters/inbound/cli/commands/permission.ts index b71901829..d8f689f02 100644 --- a/ts/src/adapters/inbound/cli/commands/permission.ts +++ b/ts/src/adapters/inbound/cli/commands/permission.ts @@ -5,6 +5,7 @@ import { UsageError } from "../../../../domain/errors/index.js"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import { TextFormatters } from "../render/index.js"; import { exactlyOne, readBoundedTextFile } from "./artifact.js"; +import { txModeFields } from "./shared.js"; const showFields = z.object({}); @@ -29,9 +30,11 @@ const updateFields = z.object({ json: z.string().min(1).optional().describe("inline complete replacement permission JSON"), dryRun: z.boolean().default(false).describe("validate, build, and estimate without signing or broadcasting"), signOnly: z.boolean().default(false).describe("build and sign, then output complete transaction hex"), - buildOnly: z.boolean().default(false).describe("build and output unsigned complete transaction hex without unlocking"), - permissionId: z.coerce.number().int().min(0).max(9).default(0).describe("TRON permission group id used to authorize this transaction"), - expiration: z.coerce.number().int().min(1).max(86_400_000).optional().describe("expiration duration in milliseconds; only with --sign-only or --build-only"), + buildOnly: txModeFields.buildOnly, + // dry-run/sign-only wording is specific to a permission replacement, but the permission group + // and expiration semantics are the shared ones — reuse them rather than keep a second copy. + permissionId: txModeFields.permissionId, + expiration: txModeFields.expiration, }); export const permissionUpdateSpec: ChainSpec = { @@ -42,8 +45,13 @@ export const permissionUpdateSpec: ChainSpec = { broadcasts: true, capability: "permission.update", summary: "Replace the complete account permission structure", - description: "Replaces owner/witness/active permissions in one AccountPermissionUpdateContract. Misconfiguration can permanently lock the account; use --dry-run first.", + description: + "Replaces owner/witness/active permissions in one AccountPermissionUpdateContract. The input is\n" + + "the complete structure, in the same shape as `permission show -o json` data.\n" + + "There is no confirmation prompt: it warns about a permanent lockout but does not block the\n" + + "submission, so rehearse with --dry-run first.", baseFields: updateFields, + exclusive: [{ label: "the new permission structure", flags: ["file", "json"] }], baseRefine: (input, context) => { if ([input.file, input.json].filter((value) => value !== undefined).length !== 1) { context.addIssue({ code: "custom", path: ["file"], message: "provide exactly one of --file or --json" }); diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts index 2cbacaeda..368d8c19d 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -14,9 +14,16 @@ import type { MessageService } from "../../../../application/use-cases/message-s export const txModeFields = { dryRun: z.boolean().default(false).describe("build and estimate only, with no signature and no broadcast"), signOnly: z.boolean().default(false).describe("sign and output complete transaction hex without broadcasting"), - buildOnly: z.boolean().default(false).describe("build and output unsigned complete transaction hex without unlocking"), - permissionId: z.coerce.number().int().min(0).max(9).default(0).describe("TRON permission group id used to authorize this transaction"), - expiration: z.coerce.number().int().min(1).max(86_400_000).optional().describe("expiration duration in milliseconds; only with --sign-only or --build-only"), + // Both multi-sig routes start from this artifact: the hex relay (`tx sign --file --out`) and the + // TronLink queue (`tx multisig --create`). Naming only one would read as "service path only". + buildOnly: z.boolean().default(false) + .describe("build and output unsigned complete transaction hex without unlocking; the entry point for multi-party signing (relay it with `tx sign`, or open a queue with `tx multisig --create`)"), + permissionId: z.coerce.number().int().min(0).max(9).default(0) + .describe("TRON permission group to sign with (0=owner, 1=witness, 2-9=active)"), + // The 24h bound is the chain's, enforced by max() above; the omitted case is the node's own + // ~60s, which is why extending it is the whole point of this flag when collecting signatures. + expiration: z.coerce.number().int().min(1).max(86_400_000).optional() + .describe("transaction expiration in ms, up to 86400000 (24h); only with --sign-only or --build-only; omitted = node default (~60s)"), }; // ── unified --amount / --raw-amount selector (shared by every chain's `tx send`) ──── // A transfer of 0 is meaningless on any chain — reject it here (exit 2) rather than let the node @@ -41,7 +48,7 @@ export function amountSelector(v: { amount?: string; rawAmount?: string }, ctx: } const messageSignFields = z.object({ - message: z.string().min(1).optional().describe("message text to sign; provide this OR --message-stdin; exactly one is required"), + message: z.string().min(1).optional().describe("message text to sign"), }); export const messageSignSpec: ChainSpec = { @@ -53,6 +60,8 @@ export const messageSignSpec: ChainSpec = { capability: "message.sign", summary: "Sign an arbitrary message (TIP-191/V2 · EIP-191)", baseFields: messageSignFields, + // SecretResolver.pick enforces this: both sources → invalid_option, neither → missing_option. + exclusive: [{ label: "the message to sign", flags: ["message", "message-stdin"], select: "exactly-one" }], examples: [{ cmd: `wallet-cli message sign --message "hello"` }], formatText: TextFormatters.messageSign, }; diff --git a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts index 872ebc8d8..2254aa26d 100644 --- a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts +++ b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts @@ -30,6 +30,52 @@ describe("transaction option argv coercion", () => { }); }); +// These two flags exist only for multi-sign, and their help used to state neither what the values +// mean nor what the limits are. A co-signer reading `--help` could not tell which permission group +// to pass, nor that the collection window they were extending is capped at 24h. +describe("shared --permission-id / --expiration document their semantics", () => { + const describeOf = (name: keyof typeof txModeFields): string => + (txModeFields[name] as { description?: string }).description ?? ""; + + it("spells out what a permission group id means", () => { + const text = describeOf("permissionId"); + expect(text).toContain("0=owner"); + expect(text).toContain("1=witness"); + expect(text).toContain("2-9=active"); + }); + + it("states the expiration cap, and quotes the number the schema actually enforces", () => { + const schema = z.object(txModeFields); + expect(schema.safeParse({ buildOnly: true, expiration: 86_400_000 }).success).toBe(true); + expect(schema.safeParse({ buildOnly: true, expiration: 86_400_001 }).success).toBe(false); + // the description must quote that same bound — a stale number here is worse than none + expect(describeOf("expiration")).toContain("86400000"); + expect(describeOf("expiration")).toContain("24h"); + }); + + it("says what happens when --expiration is omitted", () => { + expect(describeOf("expiration")).toMatch(/node default.*60s/); + }); + + // --build-only produces an artifact nobody can use without knowing what consumes it. Both + // multi-sig routes start here — the hex relay through `tx sign` and the TronLink queue through + // `tx multisig` (docs/commands/tx/index.md) — so naming only one of them would be worse than + // naming neither: it reads as "this flag is for the service path". + it("says what the unsigned hex is for, and does not narrow it to one route", () => { + const text = describeOf("buildOnly"); + expect(text).toMatch(/tx sign/); + expect(text).toMatch(/tx multisig/); + }); + + // permission update carries its own copies of these fields; two copies of a description drift. + it("permission update shows the same text, not a stale copy", () => { + const shape = permissionUpdateSpec.baseFields.shape as Record; + expect(shape.permissionId?.description).toBe(describeOf("permissionId")); + expect(shape.expiration?.description).toBe(describeOf("expiration")); + expect(shape.buildOnly?.description).toBe(describeOf("buildOnly")); + }); +}); + // `txModeFields` is shared, so adding a flag to it silently widens 14 commands at once while their // individual reference pages stay behind — which is exactly how --build-only / --permission-id / // --expiration ended up documented on only 3 of the 14. A page that documents the transaction modes @@ -46,6 +92,33 @@ describe("reference pages keep up with the shared transaction options", () => { ); } + // Presence is not enough. These two rows carried the pre-A-2/A-3 wording — a permission id with + // no key to its values, and a range with no readable bound — so a reader who went to the + // reference page instead of `--help` still could not tell which group to pass. + it("every --permission-id row carries the same value key the flag's help does", () => { + // taken from the schema, not restated here: one wording, two surfaces + const key = /\((0=owner[^)]*)\)/.exec( + (txModeFields.permissionId as { description?: string }).description ?? "", + )?.[1]; + expect(key).toBeTruthy(); + + const behind = pages(DOCS) + .map((path) => ({ path, rows: readFileSync(path, "utf8").split("\n").filter((l) => l.startsWith("| `--permission-id")) })) + .filter(({ rows }) => rows.some((row) => !row.includes(key!))) + .map(({ path }) => relative(DOCS, path)); + + expect(behind).toEqual([]); + }); + + it("every --expiration row gives the readable cap and the omitted-case default", () => { + const behind = pages(DOCS) + .map((path) => ({ path, rows: readFileSync(path, "utf8").split("\n").filter((l) => l.startsWith("| `--expiration")) })) + .filter(({ rows }) => rows.some((row) => !(row.includes("24h") && /node default.*60s/.test(row)))) + .map(({ path }) => relative(DOCS, path)); + + expect(behind).toEqual([]); + }); + it("every page offering --sign-only also documents the rest of the shared modes", () => { const behind = pages(DOCS) .map((path) => ({ path, text: readFileSync(path, "utf8") })) diff --git a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts index 8bb665305..bedd21335 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { txBroadcastSpec, txBroadcastTronBinding, + txSendSpec, txSignSpec, txSignTronBinding, txTronLinkMultisigSpec, @@ -140,6 +141,35 @@ describe("tx sign 4.10.0 JSON compatibility", () => { }); }); +// tx send carries both flavours, and conflating them would be a new lie in the help: +// the amount pair is jointly required, the asset trio is not (omit all three → native TRX). +describe("tx send exclusive groups", () => { + const groups = () => Object.fromEntries((txSendSpec.exclusive ?? []).map((g) => [g.flags[0], g])); + + it("marks the amount pair jointly required", () => { + expect(groups().amount).toEqual({ + label: "the amount to send", + flags: ["amount", "raw-amount"], + select: "exactly-one", + }); + }); + + it("marks the asset selector optional, since omitting it sends native TRX", () => { + expect(groups().token).toEqual({ + label: "which asset to send; omit for native TRX", + flags: ["token", "contract", "asset-id"], + select: "at-most-one", + }); + expect(txSendSpec.baseFields.safeParse({ to: "T...", amount: "1" }).success).toBe(true); + }); + + it("states each exclusivity once — in the group, not also in a field description", () => { + const descriptions = Object.values(txSendSpec.baseFields.shape) + .map((field) => (field as { description?: string }).description ?? ""); + expect(descriptions.filter((d) => d.includes("mutually exclusive"))).toEqual([]); + }); +}); + describe("tx broadcast binding", () => { const broadcastContext = (stdin?: string, wait = false) => ({ wait, @@ -155,6 +185,18 @@ describe("tx broadcast binding", () => { expect(txBroadcastSpec.baseFields.safeParse({ file: "tx.hex" }).success).toBe(true); }); + // The exclusive group is the single source of truth for "pick exactly one of these four", + // on both the human help and the --json-schema catalog. Restating it inside a field + // description is a second copy that drifts the moment an input is added or renamed. + it("declares every input in one exclusive group and nowhere else", () => { + expect(txBroadcastSpec.exclusive).toEqual([ + { label: "the signed transaction to broadcast", flags: ["transaction", "tx-stdin", "hex", "file"] }, + ]); + const descriptions = Object.values(txBroadcastSpec.baseFields.shape) + .map((field) => (field as { description?: string }).description ?? ""); + expect(descriptions.filter((d) => d.includes("mutually exclusive"))).toEqual([]); + }); + it("routes protobuf hex without parsing it as JSON", async () => { const service = { broadcastHex: async (_ctx: unknown, _net: unknown, hex: string, dryRun: boolean) => ({ diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index 840479b60..6a1903683 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -21,7 +21,7 @@ const sendFields = z.object({ to: z.string().trim().min(1).max(128) .describe("recipient TRON base58 address or local contact name"), token: z.string().min(1).optional() - .describe("token symbol from the address book; mutually exclusive with --contract and --asset-id"), + .describe("token symbol from the address book"), contract: Schemas.addressFor("tron").optional() .describe("TRC20 contract address; omit with --asset-id for native TRX"), assetId: z.string().regex(/^\d+$/).optional() @@ -29,8 +29,8 @@ const sendFields = z.object({ 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", + "human amount: TRX for native, token units for TRC20/TRC10", + "raw integer amount in SUN or token base units", ), ...txModeFields, }); @@ -42,6 +42,11 @@ export const txSendSpec: ChainSpec = { capability: "tx.send", summary: "Send native TRX or TRC20/TRC10 tokens with human --amount", baseFields: sendFields, + exclusive: [ + { label: "the amount to send", flags: ["amount", "raw-amount"], select: "exactly-one" }, + // omitting all three is the native-TRX path, so this set is optional as a whole. + { label: "which asset to send; omit for native TRX", flags: ["token", "contract", "asset-id"], select: "at-most-one" }, + ], baseRefine: amountSelector, examples: [ { cmd: "wallet-cli tx send --to T... --amount 1" }, @@ -58,8 +63,7 @@ export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => }); const broadcastFields = z.object({ - transaction: z.string().optional() - .describe("signed TRON transaction JSON; mutually exclusive with --tx-stdin/--hex/--file"), + transaction: z.string().optional().describe("signed TRON transaction JSON"), hex: z.string().min(2).optional().describe("complete signed protocol.Transaction hex"), file: z.string().min(1).optional().describe("file containing complete signed protocol.Transaction hex"), dryRun: z.boolean().default(false) @@ -74,6 +78,7 @@ export const txBroadcastSpec: ChainSpec = { capability: "tx.broadcast", summary: "Validate and broadcast a presigned JSON or protobuf-hex transaction", baseFields: broadcastFields, + exclusive: [{ label: "the signed transaction to broadcast", flags: ["transaction", "tx-stdin", "hex", "file"] }], baseRefine: (input, context) => { if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length > 1) { context.addIssue({ @@ -130,6 +135,7 @@ export const txApprovalsSpec: ChainSpec = { summary: "Show permission, signature approvals, current weight, and expiration", description: "Inspect the transaction, selected permission group, approved signers, accumulated weight, missing weight, and expiration without signing.", baseFields: approvalsFields, + exclusive: [{ label: "the transaction to inspect", flags: ["hex", "file"] }], baseRefine: hexOrFileRefine, examples: [{ cmd: "wallet-cli tx approvals --file partially-signed.hex" }], formatText: TextFormatters.txApprovals, @@ -161,6 +167,8 @@ export const txSignSpec: ChainSpec = { "approval weight. Add --offline to sign without contacting a node, which skips those checks.\n" + "This command never broadcasts.", baseFields: signFields, + // --hex/--file first: --transaction is the compatibility path, not the co-signing one. + exclusive: [{ label: "the transaction to co-sign", flags: ["hex", "file", "transaction"] }], baseRefine: (input, context) => { if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length !== 1) { context.addIssue({ diff --git a/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts new file mode 100644 index 000000000..831b46c42 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts @@ -0,0 +1,101 @@ +/** + * `backup` password gating, exercised through real dispatch — the ordering only exists there. + * A Ledger-only keystore has no master password sentinel, so verifying one can never succeed: + * the account-type check must therefore win over the password gate. + */ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildCli, type ShellOptions } from "../shell/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"; +import { WalletService } from "../../../../application/use-cases/wallet-service.js"; +import { registerWalletCommands } from "./wallet.js"; +import type { SessionRef } from "../contracts/index.js"; + +// Cheap KDF for keystore encryption in this suite — see cheap-scrypt.ts. Production untouched. +vi.mock("@noble/hashes/scrypt.js", async () => + import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), +); + +const VALID_MNEMONIC = "test test test test test test test test test test test junk"; +const VALID_PASSWORD = "Abcdef1!"; +const LEDGER_ADDRESS = "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL"; + +function fixture(opts: { tty: boolean }) { + const root = mkdtempSync(join(tmpdir(), "wallet-backup-test-")); + const store = new AtomicFileStore(); + const streams = new StreamManager("text", false); + const prompter = new Prompter({ + isTTY: () => opts.tty, + async question(_prompt: string, _hidden: boolean) { return VALID_PASSWORD; }, + async readKey() { return { name: "return" }; }, + write() {}, + beginRaw() {}, + endRaw() {}, + }); + const secrets = new SecretResolver(streams, {}, prompter); + const keystore = new Keystore(root, store, () => secrets.masterPassword()); + const spyPrime = vi.spyOn(secrets, "primePassword"); + + const config = ConfigLoader.load(); + const networkRegistry = new NetworkRegistry(config); + const formatter = createOutputFormatter("text", streams, Date.now()); + const registry = new CommandRegistry(); + registerWalletCommands(registry, { + walletService: new WalletService(keystore, {} as any, { + write: () => ({ out: "unused", fileMode: "0600", bytes: 0 }), + }), + ledger: {} as any, + } as any); + + const session: SessionRef = {}; + const shellOpts: ShellOptions = { + registry, + globals: { output: "text", verbose: false }, + deps: { config, networkRegistry, streams, secrets, keystore, prompter, formatter }, + targetResolver: new TargetResolver({ networkRegistry, keystore }), + caps: new CapabilityRegistry(), + streams, + formatter, + session, + }; + return { shellOpts, keystore, secrets, spyPrime }; +} + +describe("backup password gating", () => { + it("fails a Ledger account with not_exportable instead of demanding the master password", async () => { + const { shellOpts, keystore, spyPrime } = fixture({ tty: false }); + const { accountId } = keystore.registerLedger({ + family: "tron", + path: "m/44'/195'/0'/0/0", + address: LEDGER_ADDRESS, + }); + + await expect(buildCli(shellOpts).parseAsync(["backup", accountId])) + .rejects.toMatchObject({ code: "not_exportable" }); + expect(spyPrime).not.toHaveBeenCalled(); + }); + + it("still verifies the master password before exporting a software account", async () => { + const { shellOpts, keystore, secrets, spyPrime } = fixture({ tty: true }); + await secrets.primePassword({ mode: "set" }); + const { accountId } = keystore.import({ secret: VALID_MNEMONIC, type: "seed" }); + secrets.clearPrimed(); + spyPrime.mockClear(); // ignore the prime used to seed the wallet above + + await buildCli(shellOpts).parseAsync(["backup", accountId]); + + expect(spyPrime).toHaveBeenCalledOnce(); + expect(spyPrime.mock.calls[0]![0].mode).toBe("verify"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index c55cd50f7..7c1667e6a 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -334,7 +334,12 @@ export function registerWalletCommands( // ── backup ──────────────────────────────────────────────────────────────── // Writes the secret + metadata to a 0600 FILE (never stdout/envelope): the secret stays off // screen, logs and AI context. stdout returns only metadata + the written path. - // master password via dispatch prime (passwordMode: "verify"); --password-stdin is the non-interactive source. + // The master password is primed by `run` itself, not by dispatch (no passwordMode): the account + // must be known to be exportable BEFORE a password is demanded. A Ledger/watch account holds no + // secret, and a Ledger-only keystore may have no master password at all — verifying against a + // missing sentinel always fails, so a dispatch-level gate would trap the user on a prompt no + // answer satisfies instead of telling them the account simply cannot be exported. + // --password-stdin remains the non-interactive source. const backupFields = z.object({ account: accountRef("account or wallet to export, addressed by accountId, label, or address"), out: z @@ -347,7 +352,6 @@ export function registerWalletCommands( network: "none", wallet: "none", auth: "required", - passwordMode: "verify", interactive: true, positionals: [{ field: "account" }], summary: "Export an account's secret + metadata to a 0600 file", @@ -355,7 +359,11 @@ export function registerWalletCommands( input: backupFields, examples: [{ cmd: "wallet-cli backup main --out ~/main-backup.json --password-stdin" }], formatText: TextFormatters.walletBackup, - run: async (_ctx, _net, input) => wallets.backup(input.account, input.out), + run: async (ctx, _net, input) => { + wallets.assertExportable(input.account) + await ctx.secrets.primePassword({ mode: "verify", verify: (pw) => wallets.verifyPassword(pw) }) + return wallets.backup(input.account, input.out) + }, } satisfies CommandDefinition) // ── change-password ─────────────────────────────────────────────────────── diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index 604a0ebfc..f6078bc29 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -10,6 +10,19 @@ export interface Example { note?: string; } +/** a set of options of which exactly one must be supplied, enforced in the command's refine. + * Help renders it as a labelled block ("Exactly one of these —