From e4577eda4777462340e06cf88af3461d29acafb3 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 12:08:38 +0800 Subject: [PATCH 01/14] 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 abf5cf997..6b5be0ae5 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 d0c19e1f5..fa3a5d37e 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 6200e2dfe6017e9dcc2bb99dc62f427abe915265 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 12:17:33 +0800 Subject: [PATCH 02/14] 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 8d2d4bd798059b22b38c128a8556cd876eac37fc Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 12:31:46 +0800 Subject: [PATCH 03/14] 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 6b5be0ae5..a8e7e979a 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 fa3a5d37e..1c2164e87 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 1d50d60cd11f713fe445e78186458ce5247cb95b Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 12:44:47 +0800 Subject: [PATCH 04/14] 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 1653dd72c6f4b3b27d037ab9bcecbfdb0e2b4b7a Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 12:53:49 +0800 Subject: [PATCH 05/14] 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 6ad21b0c6dfd3ce21dcf43ae8862d5c1a4c4766e Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 13:01:50 +0800 Subject: [PATCH 06/14] 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 ae7b2e1d6ed85a4da13cf2b3eeb5f820e96e4dff Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 13:06:18 +0800 Subject: [PATCH 07/14] 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 dfe4a27b2d4161c73db856c0643d0ce6b840fec5 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 13:10:15 +0800 Subject: [PATCH 08/14] 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 a8e7e979a..028eae2f9 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.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.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 1c2164e87..2f1836a7f 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 ca9bf975d5aa71c7de218c210d9b37c3541b88be Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 13:13:07 +0800 Subject: [PATCH 09/14] 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 c93456a2f..69db9ec8b 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 9e3aa58734c629e79c2aeb2b3364015cf619df5a Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 16:06:16 +0800 Subject: [PATCH 10/14] 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 5292cb2f871d7f86b3105c3ba8064ffcc216a26c Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Thu, 23 Jul 2026 16:09:15 +0800 Subject: [PATCH 11/14] 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 13b8888960fe25f85dfe8aac0052f78f9ce86683 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 29 Jul 2026 00:33:03 +0800 Subject: [PATCH 12/14] 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 69db9ec8b..b161fc36a 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 c2cf2b80f6b3058a8f4375eba5c7bf0cb46fd10e Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 29 Jul 2026 02:26:28 +0800 Subject: [PATCH 13/14] 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 39a4f28c5..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.0"; + 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 028eae2f9..148dffae3 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "0.2.0", + "version": "4.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tron-walletcli/wallet-cli", - "version": "0.2.0", + "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 2f1836a7f..d92364000 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "0.2.0", + "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 aa3fc0797..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 = "0.2.0" +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 b161fc36a..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("0.2.0") + 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 1e068529da4bf2535618acb228fa5597da5c49de Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 29 Jul 2026 02:46:32 +0800 Subject: [PATCH 14/14] 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" },