From c0bddb6bcd47155c73903c1b52423ab9043e161f Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 18:30:01 +0800 Subject: [PATCH 01/11] fix: scope chain json schemas by family --- ts/src/adapters/inbound/cli/help/catalog.ts | 9 ++-- ts/src/adapters/inbound/cli/help/help.test.ts | 42 +++++++++++++++++++ ts/src/adapters/inbound/cli/help/index.ts | 11 +++-- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/ts/src/adapters/inbound/cli/help/catalog.ts b/ts/src/adapters/inbound/cli/help/catalog.ts index 4f111590..691c56a9 100644 --- a/ts/src/adapters/inbound/cli/help/catalog.ts +++ b/ts/src/adapters/inbound/cli/help/catalog.ts @@ -97,7 +97,7 @@ export function buildCatalog( examples: cmd.spec.examples.map((e: { cmd: string }) => e.cmd), ...(cmd.spec.exclusive?.length ? { exclusive: cmd.spec.exclusive } : {}), ...(cmd.spec.stdin ? { inputFlags: inputFlagsFor(cmd.spec) } : {}), - inputSchema: commandInputSchema(mergedInput(cmd)), + inputSchema: commandInputSchema(mergedInput(cmd, familyFilter)), } : { id: commandId(cmd), @@ -125,14 +125,15 @@ export function buildCatalog( }); } -function mergedInput(def: ChainCommandDefinition): z.ZodType { +function mergedInput(def: ChainCommandDefinition, family?: ChainFamily): z.ZodType { let shape = { ...def.spec.baseFields.shape }; - for (const binding of Object.values(def.families)) { + const bindings = family ? [def.families[family]] : Object.values(def.families); + for (const binding of bindings) { if (binding?.fields) shape = { ...shape, ...binding.fields.shape }; } let input: z.ZodType = z.object(shape); if (def.spec.baseRefine) input = input.superRefine(def.spec.baseRefine); - for (const binding of Object.values(def.families)) { + for (const binding of bindings) { if (binding?.refine) input = input.superRefine(binding.refine); } return input; diff --git a/ts/src/adapters/inbound/cli/help/help.test.ts b/ts/src/adapters/inbound/cli/help/help.test.ts index a663fe3f..e776f48d 100644 --- a/ts/src/adapters/inbound/cli/help/help.test.ts +++ b/ts/src/adapters/inbound/cli/help/help.test.ts @@ -78,6 +78,48 @@ describe("HelpService --json-schema", () => { const out = JSON.parse(stream.last!); expect(out).toHaveProperty("commands"); // group head → catalog, not a phantom command schema }); + + it("scopes a concrete chain command schema to the addressed family", () => { + const reg = new CommandRegistry(); + const spec = chainSpec(["tx", "send"], { to: z.string() }); + reg.addChain(spec, "tron", { + run: async () => ({}), + fields: z.object({ feeLimit: z.string() }), + }); + reg.addChain(spec, "evm", { + run: async () => ({}), + fields: z.object({ gasLimit: z.string() }), + }); + const stream = makeStream(); + + new HelpService(reg, stream, "9.9.9").handleMeta(["evm", "tx", "send", "--json-schema"]); + + const out = JSON.parse(stream.last!); + expect(out.properties).toHaveProperty("to"); + expect(out.properties).toHaveProperty("gasLimit"); + expect(out.properties).not.toHaveProperty("feeLimit"); + }); + + it("scopes the family catalog's input schemas to that family", () => { + const reg = new CommandRegistry(); + const spec = chainSpec(["tx", "send"], { to: z.string() }); + reg.addChain(spec, "tron", { + run: async () => ({}), + fields: z.object({ feeLimit: z.string() }), + }); + reg.addChain(spec, "evm", { + run: async () => ({}), + fields: z.object({ gasLimit: z.string() }), + }); + const stream = makeStream(); + + new HelpService(reg, stream, "9.9.9").handleMeta(["evm", "--json-schema"]); + + const command = JSON.parse(stream.last!).commands.find((c: { id: string }) => c.id === "tx.send"); + expect(command.inputSchema.properties).toHaveProperty("to"); + expect(command.inputSchema.properties).toHaveProperty("gasLimit"); + expect(command.inputSchema.properties).not.toHaveProperty("feeLimit"); + }); }); // Asserting the spec object is not enough: the renderer resolves members by kebab flag name, so a diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index b25d4502..cd1b1269 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -45,7 +45,7 @@ export class HelpService { if (tokens.includes("--json-schema")) { if (concrete) { - const input = isChainCommand(concrete) ? mergedFields(concrete) : concrete.input; + const input = isChainCommand(concrete) ? mergedFields(concrete, family) : concrete.input; this.streams.result(JSON.stringify(z.toJSONSchema(input))); return 0; } @@ -536,10 +536,13 @@ export class HelpService { } } -function mergedFields(def: ChainCommandDefinition): ZodObject { +function mergedFields( + def: ChainCommandDefinition, + family?: ChainFamily, +): ZodObject { let shape = { ...def.spec.baseFields.shape }; - for (const b of Object.values(def.families)) - if (b?.fields) shape = { ...shape, ...b.fields.shape }; + const bindings = family ? [def.families[family]] : Object.values(def.families); + for (const b of bindings) if (b?.fields) shape = { ...shape, ...b.fields.shape }; return z.object(shape); } From 9f14026faf126392aeec7d2b12d36a74ed388c40 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 18:31:54 +0800 Subject: [PATCH 02/11] chore: fix lint gate --- ts/eslint.config.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ts/eslint.config.js b/ts/eslint.config.js index c8169693..7845a205 100644 --- a/ts/eslint.config.js +++ b/ts/eslint.config.js @@ -39,6 +39,21 @@ export default tseslint.config( globals: { module: "writable", require: "readonly", __dirname: "readonly" }, }, }, + { + files: ["scripts/**/*.mjs"], + languageOptions: { + globals: { + Bun: "readonly", + URL: "readonly", + console: "readonly", + setTimeout: "readonly", + }, + }, + rules: { + // build and smoke-test scripts are CLI programs; their user-facing output is intentional. + "no-console": "off", + }, + }, // formatting is Prettier's job — must stay last so it can switch stylistic rules off prettier, ); From 902a56231de2b13ffc8581c3eadd519f6fce3496 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 18:32:47 +0800 Subject: [PATCH 03/11] style: apply prettier formatting --- ts/src/adapters/inbound/cli/help/help.test.ts | 4 +++- ts/src/adapters/inbound/cli/help/index.ts | 5 +---- ts/src/adapters/inbound/cli/render/misc.ts | 3 --- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/ts/src/adapters/inbound/cli/help/help.test.ts b/ts/src/adapters/inbound/cli/help/help.test.ts index e776f48d..529fbbd7 100644 --- a/ts/src/adapters/inbound/cli/help/help.test.ts +++ b/ts/src/adapters/inbound/cli/help/help.test.ts @@ -115,7 +115,9 @@ describe("HelpService --json-schema", () => { new HelpService(reg, stream, "9.9.9").handleMeta(["evm", "--json-schema"]); - const command = JSON.parse(stream.last!).commands.find((c: { id: string }) => c.id === "tx.send"); + const command = JSON.parse(stream.last!).commands.find( + (c: { id: string }) => c.id === "tx.send", + ); expect(command.inputSchema.properties).toHaveProperty("to"); expect(command.inputSchema.properties).toHaveProperty("gasLimit"); expect(command.inputSchema.properties).not.toHaveProperty("feeLimit"); diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index cd1b1269..e6c66c8c 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -536,10 +536,7 @@ export class HelpService { } } -function mergedFields( - def: ChainCommandDefinition, - family?: ChainFamily, -): ZodObject { +function mergedFields(def: ChainCommandDefinition, family?: ChainFamily): ZodObject { let shape = { ...def.spec.baseFields.shape }; const bindings = family ? [def.families[family]] : Object.values(def.families); for (const b of bindings) if (b?.fields) shape = { ...shape, ...b.fields.shape }; diff --git a/ts/src/adapters/inbound/cli/render/misc.ts b/ts/src/adapters/inbound/cli/render/misc.ts index 3fd8a11c..0103f267 100644 --- a/ts/src/adapters/inbound/cli/render/misc.ts +++ b/ts/src/adapters/inbound/cli/render/misc.ts @@ -50,9 +50,6 @@ export const MiscFormatters = { // `block` reports the node's RAW object, so the two families arrive in different shapes: TRON // nests its header and counts milliseconds, an EVM node is flat, hex and counts seconds. // Making that readable is this renderer's job — the JSON stays as the node sent it. - // `block` reports the node's RAW object, so the two families arrive in different shapes: TRON - // nests its header and counts milliseconds, an EVM node is flat, hex and counts seconds. - // Making that readable is this renderer's job — the JSON stays as the node sent it. block: ((data, ctx) => { const block = asObj(asObj(data).block); const header = asObj(asObj(block.block_header).raw_data); From 0de53d8a6d38a29712f944321a4e630578d67c4e Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 18:38:20 +0800 Subject: [PATCH 04/11] fix: neutralize shared chain command metadata --- ts/src/adapters/inbound/cli/commands/tx.ts | 8 ++++---- ts/src/adapters/inbound/cli/commands/typed-data.ts | 6 +++--- ts/test/golden.test.ts | 5 +---- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index e50ed07d..4c83c216 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -26,10 +26,10 @@ const sendFields = z.object({ token: z.string().min(1).optional().describe("token symbol from the address book"), contract: Schemas.address() .optional() - .describe("token contract address; omit with --asset-id for a native-coin transfer"), + .describe("token contract address; omit for a native-coin transfer"), ...unifiedAmountFields( - "human amount: TRX for native, token units for TRC20/TRC10", - "raw integer amount in SUN or token base units", + "human amount: native coin for native transfers, token units for token transfers", + "raw integer amount in native base units or token base units", ), ...txModeFields, }); @@ -41,7 +41,7 @@ export const txSendSpec: ChainSpec = { auth: "conditional", broadcasts: true, capability: "tx.send", - summary: "Send the native coin or a token", + summary: "Send native coins or tokens with human --amount", description: "Send the native coin, or a token selected with --token / --contract.\n" + // §10.1: a command whose Options show BOTH families' tags must say what the tags mean — diff --git a/ts/src/adapters/inbound/cli/commands/typed-data.ts b/ts/src/adapters/inbound/cli/commands/typed-data.ts index bc1620a3..efcf8980 100644 --- a/ts/src/adapters/inbound/cli/commands/typed-data.ts +++ b/ts/src/adapters/inbound/cli/commands/typed-data.ts @@ -21,9 +21,9 @@ export const typedDataSignSpec: ChainSpec = { capability: "typedData.sign", summary: "Sign EIP-712 / TIP-712 structured data", description: - "Sign an EIP-712 / TIP-712 typed-data payload with the selected account.\n" + - "`EIP712Domain` in `types` is ignored, `value` is accepted for `message`, and TRON base58\n" + - "addresses work in address fields.", + "Prints the signature, the digest that was signed, and the primary type.\n" + + "`EIP712Domain` in `types` is ignored and `value` is accepted for `message`; address values\n" + + "are interpreted by the selected chain family's signing strategy.", baseFields: typedDataFields, examples: [ { diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index ee65137a..05c7c071 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -338,10 +338,7 @@ describe("golden CLI — command help contracts", () => { it("tx send --help summary leads with 'Send' and human --amount (E2)", () => { const r = run(["tx", "send", "--help"], { password: null }); expect(r.status).toBe(0); - // Leads with the imperative verb (§10.1 rule 1) and stays family-neutral; the human-unit - // --amount flag is what the E2 contract is really about, so assert it directly. - expect(r.stdout).toMatch(/^Send the native coin, or a token/m); - expect(r.stdout).toMatch(/^ +--amount +human amount/m); + expect(r.stdout).toContain("Send native coins or tokens with human --amount"); }); it("block --help documents the height as a positional arg, not a --number flag (H4)", () => { From f67e4bfe8dfc81e8962ad063c7afe279d4e1eeb6 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 18:40:19 +0800 Subject: [PATCH 05/11] refactor: share EVM unsigned tx building --- .../use-cases/evm/contract-service.ts | 57 +++----------- .../use-cases/evm/transaction-service.ts | 55 +++----------- ts/src/application/use-cases/evm/tx-build.ts | 75 +++++++++++++++++++ 3 files changed, 96 insertions(+), 91 deletions(-) create mode 100644 ts/src/application/use-cases/evm/tx-build.ts diff --git a/ts/src/application/use-cases/evm/contract-service.ts b/ts/src/application/use-cases/evm/contract-service.ts index ab291932..f2951a25 100644 --- a/ts/src/application/use-cases/evm/contract-service.ts +++ b/ts/src/application/use-cases/evm/contract-service.ts @@ -1,10 +1,9 @@ -import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index.js"; -import { resolveGasLimit } from "../../services/evm-gas-estimate.js"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; import { FAMILIES } from "../../../domain/family/index.js"; import { fromBaseUnits, toBaseUnits } from "../../../domain/amounts/index.js"; -import { planEvmFee } from "../../../domain/fees/evm-gas.js"; import { evmConfirmation } from "../../services/evm-confirmation.js"; import { approveRows } from "../../services/approve-receipt.js"; +import { buildEvmUnsignedTx } from "./tx-build.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider, @@ -35,15 +34,6 @@ export interface EvmContractWriteInput extends TransactionModeInput { nonce?: number; } -/** the gas overrides, in the shape the fee model takes. */ -function overridesOf(input: EvmContractWriteInput) { - return { - ...(input.gasLimit === undefined ? {} : { gasLimit: input.gasLimit }), - ...(input.maxFee === undefined ? {} : { maxFeeWei: input.maxFee }), - ...(input.priorityFee === undefined ? {} : { priorityFeeWei: input.priorityFee }), - }; -} - /** * Contract reads and writes. * @@ -173,40 +163,17 @@ export class EvmContractService { artifact: (tx) => gateway.encodeTransactionHex(tx), estimate: async () => plan, build: async (from) => { - const [nonce, fee] = await Promise.all([ - input.nonce === undefined - ? gateway.getTransactionCount(from, "pending") - : Promise.resolve(String(input.nonce)), - gateway.feeData(), - ]); - onNonce?.(from, nonce); - const gasEstimate = await resolveGasLimit(gateway, { from, ...call }, input.gasLimit); - const resolved = planEvmFee({ - ...fee, - gasLimit: gasEstimate, - declaredFeeModel: network.feeModel, - overrides: overridesOf(input), + const built = await buildEvmUnsignedTx({ + gateway, + network, + from, + call, + input, + onNonce, }); - for (const warning of resolved.warnings ?? []) scope.warn(warning); - plan = { - feeModel: resolved.mode, - maxCostWei: resolved.maxCostWei, - gasLimit: resolved.gasLimit, - maxPerGasWei: resolved.maxFeeWei ?? resolved.gasPriceWei, - }; - return { - ...call, - chainId: Number(network.chainId), - nonce: Number(nonce), - gasLimit: resolved.gasLimit, - ...(resolved.mode === "eip1559" - ? { - type: 2, - maxFeePerGas: resolved.maxFeeWei, - maxPriorityFeePerGas: resolved.priorityFeeWei, - } - : { type: 0, gasPrice: resolved.gasPriceWei }), - } as UnsignedTx; + for (const warning of built.warnings ?? []) scope.warn(warning); + plan = built.fee; + return built.tx; }, }); } diff --git a/ts/src/application/use-cases/evm/transaction-service.ts b/ts/src/application/use-cases/evm/transaction-service.ts index 787f5ba8..94fb367f 100644 --- a/ts/src/application/use-cases/evm/transaction-service.ts +++ b/ts/src/application/use-cases/evm/transaction-service.ts @@ -12,10 +12,9 @@ import { FAMILIES } from "../../../domain/family/index.js"; import { evmChecksumAddress } from "../../../domain/address/index.js"; import { hexToBytes } from "@noble/hashes/utils.js"; import { fromBaseUnits, toBaseUnits } from "../../../domain/amounts/index.js"; -import { planEvmFee } from "../../../domain/fees/evm-gas.js"; import { evmConfirmation } from "../../services/evm-confirmation.js"; import { confirmationsOf } from "../../services/confirmations.js"; -import { resolveGasLimit } from "../../services/evm-gas-estimate.js"; +import { buildEvmUnsignedTx } from "./tx-build.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { EvmGateway } from "../../ports/chain/gateway-provider.js"; @@ -213,51 +212,15 @@ export class EvmTransactionService { } : { to, value: transfer.rawAmount }; - const [nonce, fee] = await Promise.all([ - // "pending", not "latest": a latest-based nonce refuses to queue behind a transaction of - // our own that has not been mined yet. - input.nonce === undefined - ? gateway.getTransactionCount(from, "pending") - : Promise.resolve(String(input.nonce)), - gateway.feeData(), - ]); - // No fallback: a failed estimate is the node saying something true about this transaction, - // and 21000 — the intrinsic cost of a plain value transfer — would sign an ERC-20 transfer - // that cannot succeed while reporting it as fine. - const gasEstimate = await resolveGasLimit(gateway, { from, ...call }, input.gasLimit); - - const plan = planEvmFee({ - ...fee, - gasLimit: gasEstimate, - declaredFeeModel: network.feeModel, - overrides: { - ...(input.gasLimit === undefined ? {} : { gasLimit: input.gasLimit }), - ...(input.maxFee === undefined ? {} : { maxFeeWei: input.maxFee }), - ...(input.priorityFee === undefined ? {} : { priorityFeeWei: input.priorityFee }), - }, + const built = await buildEvmUnsignedTx({ + gateway, + network, + from, + call, + input, }); - for (const warning of plan.warnings ?? []) scope.warn(warning); - - return { - tx: { - ...call, - chainId: Number(network.chainId), - nonce: Number(nonce), - gasLimit: plan.gasLimit, - ...(plan.mode === "eip1559" - ? { type: 2, maxFeePerGas: plan.maxFeeWei, maxPriorityFeePerGas: plan.priorityFeeWei } - : { type: 0, gasPrice: plan.gasPriceWei }), - }, - // maxPerGasWei rides along so the estimate can state what the ceiling is made OF — the same - // " ( gas × )" shape a confirmed receipt uses. Without it the dry run - // gives a number the reader cannot check against the gas price they just looked up. - fee: { - feeModel: plan.mode, - maxCostWei: plan.maxCostWei, - gasLimit: plan.gasLimit, - maxPerGasWei: plan.maxFeeWei ?? plan.gasPriceWei, - }, - }; + for (const warning of built.warnings ?? []) scope.warn(warning); + return built; } /** diff --git a/ts/src/application/use-cases/evm/tx-build.ts b/ts/src/application/use-cases/evm/tx-build.ts new file mode 100644 index 00000000..ad9ace13 --- /dev/null +++ b/ts/src/application/use-cases/evm/tx-build.ts @@ -0,0 +1,75 @@ +import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index.js"; +import { planEvmFee } from "../../../domain/fees/evm-gas.js"; +import { resolveGasLimit } from "../../services/evm-gas-estimate.js"; +import type { EvmGateway } from "../../ports/chain/gateway-provider.js"; + +export interface EvmGasInput { + gasLimit?: string; + maxFee?: string; + priorityFee?: string; + nonce?: number; +} + +export interface EvmBuildRequest { + gateway: EvmGateway; + network: NetworkDescriptor; + from: string; + call: Record; + input: EvmGasInput; + onNonce?: (from: string, nonce: string) => void; +} + +export interface EvmBuildResult { + tx: UnsignedTx; + fee: Record; + warnings?: string[]; +} + +function overridesOf(input: EvmGasInput) { + return { + ...(input.gasLimit === undefined ? {} : { gasLimit: input.gasLimit }), + ...(input.maxFee === undefined ? {} : { maxFeeWei: input.maxFee }), + ...(input.priorityFee === undefined ? {} : { priorityFeeWei: input.priorityFee }), + }; +} + +export async function buildEvmUnsignedTx(request: EvmBuildRequest): Promise { + const { gateway, network, from, call, input } = request; + const [nonce, fee] = await Promise.all([ + // "pending", not "latest": a latest-based nonce refuses to queue behind a transaction of + // our own that has not been mined yet. + input.nonce === undefined + ? gateway.getTransactionCount(from, "pending") + : Promise.resolve(String(input.nonce)), + gateway.feeData(), + ]); + request.onNonce?.(from, nonce); + + const gasEstimate = await resolveGasLimit(gateway, { from, ...call }, input.gasLimit); + + const plan = planEvmFee({ + ...fee, + gasLimit: gasEstimate, + declaredFeeModel: network.feeModel, + overrides: overridesOf(input), + }); + + return { + tx: { + ...call, + chainId: Number(network.chainId), + nonce: Number(nonce), + gasLimit: plan.gasLimit, + ...(plan.mode === "eip1559" + ? { type: 2, maxFeePerGas: plan.maxFeeWei, maxPriorityFeePerGas: plan.priorityFeeWei } + : { type: 0, gasPrice: plan.gasPriceWei }), + }, + fee: { + feeModel: plan.mode, + maxCostWei: plan.maxCostWei, + gasLimit: plan.gasLimit, + maxPerGasWei: plan.maxFeeWei ?? plan.gasPriceWei, + }, + ...(plan.warnings === undefined ? {} : { warnings: plan.warnings }), + }; +} From e6e9ffcc783fbb4b1131352e30cc3c6967a7eadf Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 18:43:24 +0800 Subject: [PATCH 06/11] refactor: require EVM service dependencies --- .../use-cases/evm/account-service.test.ts | 14 +++++++++++++- .../application/use-cases/evm/account-service.ts | 12 ++++++------ .../use-cases/evm/contract-service.test.ts | 5 ++++- .../application/use-cases/evm/contract-service.ts | 6 +++--- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/ts/src/application/use-cases/evm/account-service.test.ts b/ts/src/application/use-cases/evm/account-service.test.ts index 4b077e95..edeaf583 100644 --- a/ts/src/application/use-cases/evm/account-service.test.ts +++ b/ts/src/application/use-cases/evm/account-service.test.ts @@ -10,6 +10,8 @@ import { EvmAccountService } from "./account-service.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { AccountScope } from "../../contracts/execution-scope.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { TokenRepository } from "../../ports/token-repository.js"; +import type { PriceProvider } from "../../ports/price-provider.js"; const scope: AccountScope = { activeAccount: "wlt_test.0", resolveAddress: () => "0xADDR" }; const net = { @@ -19,6 +21,12 @@ const net = { chainId: "1", capabilities: [], } as NetworkDescriptor; +const emptyTokens = { effective: () => [] } as unknown as TokenRepository; +const nullPrices = { + source: "test", + nativeUsd: async () => null, + tokenUsd: async () => new Map(), +} satisfies PriceProvider; function service(over: { balance?: string; nonce?: string; code?: string } = {}) { const gateway = { @@ -26,7 +34,11 @@ function service(over: { balance?: string; nonce?: string; code?: string } = {}) getTransactionCount: async () => over.nonce ?? "0", getCode: async () => over.code ?? "0x", }; - return new EvmAccountService({ get: () => gateway } as unknown as ChainGatewayProvider); + return new EvmAccountService( + { get: () => gateway } as unknown as ChainGatewayProvider, + emptyTokens, + nullPrices, + ); } describe("EvmAccountService.info", () => { diff --git a/ts/src/application/use-cases/evm/account-service.ts b/ts/src/application/use-cases/evm/account-service.ts index b51a4085..35565d38 100644 --- a/ts/src/application/use-cases/evm/account-service.ts +++ b/ts/src/application/use-cases/evm/account-service.ts @@ -17,8 +17,8 @@ import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js export class EvmAccountService { constructor( private readonly gateways: ChainGatewayProvider, - private readonly tokens?: TokenRepository, - private readonly prices?: PriceProvider, + private readonly tokens: TokenRepository, + private readonly prices: PriceProvider, ) {} /** @@ -35,7 +35,7 @@ export class EvmAccountService { async portfolio(scope: AccountScope, network: NetworkDescriptor) { const address = scope.resolveAddress("evm"); const gateway = this.gateways.get(network, "evm"); - const tokens = this.tokens!.effective(network.id, scope.activeAccount); + const tokens = this.tokens.effective(network.id, scope.activeAccount); const [nativeRaw, balances] = await Promise.all([ gateway.getNativeBalance(address), Promise.all( @@ -55,8 +55,8 @@ export class EvmAccountService { let tokenPrices = new Map(); try { [nativePrice, tokenPrices] = await Promise.all([ - this.prices!.nativeUsd(network.id), - this.prices!.tokenUsd( + this.prices.nativeUsd(network.id), + this.prices.tokenUsd( network.id, tokens.map((token) => token.id), ), @@ -87,7 +87,7 @@ export class EvmAccountService { network: network.id, account: scope.activeAccount, address, - priceSource: this.prices!.source, + priceSource: this.prices.source, ...(priceUnavailable ? { priceUnavailable: true, priceReason: "price_provider_error" } : {}), holdings, totalValueUsd: portfolioTotal(holdings), diff --git a/ts/src/application/use-cases/evm/contract-service.test.ts b/ts/src/application/use-cases/evm/contract-service.test.ts index 9879fe61..a6443dce 100644 --- a/ts/src/application/use-cases/evm/contract-service.test.ts +++ b/ts/src/application/use-cases/evm/contract-service.test.ts @@ -24,7 +24,10 @@ function service(result = "0x") { }, }; return { - svc: new EvmContractService({ get: () => gateway } as unknown as ChainGatewayProvider), + svc: new EvmContractService( + { get: () => gateway } as unknown as ChainGatewayProvider, + {} as unknown as TxPipeline, + ), seen, }; } diff --git a/ts/src/application/use-cases/evm/contract-service.ts b/ts/src/application/use-cases/evm/contract-service.ts index f2951a25..c68c593a 100644 --- a/ts/src/application/use-cases/evm/contract-service.ts +++ b/ts/src/application/use-cases/evm/contract-service.ts @@ -43,7 +43,7 @@ export interface EvmContractWriteInput extends TransactionModeInput { export class EvmContractService { constructor( private readonly gateways: ChainGatewayProvider, - private readonly pipeline?: TxPipeline, + private readonly pipeline: TxPipeline, ) {} /** @@ -151,9 +151,9 @@ export class EvmContractService { call: Record, onNonce?: (from: string, nonce: string) => void, ) { - if (transactionRequiresSigner(input)) this.pipeline!.assertCanSign(scope.activeAccount, "evm"); + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "evm"); let plan: Record = {}; - return this.pipeline!.run({ + return this.pipeline.run({ ctx: scope, net: network, account: scope.activeAccount, From 8ee6b397328d266db6cf294ab05b1754ecc20ade Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 18:45:07 +0800 Subject: [PATCH 07/11] refactor: centralize EVM RPC requests --- .../adapters/outbound/chain/evm/evm.test.ts | 11 ++++++ ts/src/adapters/outbound/chain/evm/evm.ts | 35 ++++++++++--------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/ts/src/adapters/outbound/chain/evm/evm.test.ts b/ts/src/adapters/outbound/chain/evm/evm.test.ts index 7ee2d44d..5c131014 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.test.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.test.ts @@ -76,6 +76,17 @@ describe("EvmRpcClient.getNativeBalance", () => { ).rejects.toMatchObject({ code: "rpc_error" }); }); + it("surfaces malformed JSON as rpc_error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, text: async () => "{not-json" })), + ); + + await expect( + new EvmRpcClient("https://node.example", 5_000).getNativeBalance(ADDR), + ).rejects.toMatchObject({ code: "rpc_error" }); + }); + it("aborts a hung call at timeoutMs instead of hanging", async () => { vi.stubGlobal( "fetch", diff --git a/ts/src/adapters/outbound/chain/evm/evm.ts b/ts/src/adapters/outbound/chain/evm/evm.ts index 68d6435b..ba70ba9d 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.ts @@ -196,6 +196,10 @@ export class EvmRpcClient implements EvmGateway { /** the JSON-RPC envelope, unthrown — callers that classify errors themselves need to see it. */ async #send(method: string, params: unknown[]): Promise { + return this.#request(method, params); + } + + async #request(method: string, params: unknown[]): Promise { this.#id += 1; let response: { ok: boolean; status?: number; text(): Promise }; try { @@ -211,7 +215,19 @@ export class EvmRpcClient implements EvmGateway { if (!response.ok) { throw new ChainError("rpc_error", `${method} failed: HTTP ${response.status}`); } - return JSON.parse(await response.text()) as JsonRpcResponse; + let body: unknown; + try { + body = JSON.parse(await response.text()); + } catch (e) { + throw new ChainError( + "rpc_error", + `${method} returned malformed JSON: ${(e as Error).message}`, + ); + } + if (body === null || typeof body !== "object" || Array.isArray(body)) { + throw new ChainError("rpc_error", `${method} returned a malformed JSON-RPC response`); + } + return body as JsonRpcResponse; } /** calldata for `transfer(address,uint256)`; the amount is already in the token's base units. */ @@ -470,22 +486,7 @@ export class EvmRpcClient implements EvmGateway { } async #call(method: string, params: unknown[]): Promise { - this.#id += 1; - let response: { ok: boolean; status?: number; text(): Promise }; - try { - response = await fetch(this.endpoint, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: this.#id, method, params }), - signal: AbortSignal.timeout(this.timeoutMs), - }); - } catch (e) { - throw new ChainError("rpc_error", `${method} failed: ${(e as Error).message}`); - } - if (!response.ok) { - throw new ChainError("rpc_error", `${method} failed: HTTP ${response.status}`); - } - const body = JSON.parse(await response.text()) as JsonRpcResponse; + const body = await this.#request(method, params); if (body.error) { throw new ChainError("rpc_error", `${method} failed: ${body.error.message}`); } From e8b0d354303aa229276ab1b574f28fe7f8ccf44e Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 18:46:00 +0800 Subject: [PATCH 08/11] refactor: reuse EVM ABI call encoder --- ts/src/adapters/outbound/chain/evm/evm.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/ts/src/adapters/outbound/chain/evm/evm.ts b/ts/src/adapters/outbound/chain/evm/evm.ts index ba70ba9d..2711634f 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.ts @@ -360,20 +360,7 @@ export class EvmRpcClient implements EvmGateway { signature: string, params: Array<{ type: string; value: unknown }>, ): Promise { - let data: string; - try { - const iface = new Interface([`function ${signature}`]); - data = iface.encodeFunctionData( - signature.slice(0, signature.indexOf("(")), - params.map((p) => p.value), - ); - } catch (e) { - throw new ChainError( - "invalid_value", - `could not encode ${signature}: ${(e as Error).message}`, - ); - } - return this.call(contract, data); + return this.call(contract, this.encodeFunctionCall(signature, params)); } /** From e4bf7e620b4607c616aea1bbac80ef40439ea32c Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 18:51:24 +0800 Subject: [PATCH 09/11] fix: guard EVM safe integer conversions --- .../adapters/outbound/chain/evm/evm.test.ts | 12 ++++++++ ts/src/adapters/outbound/chain/evm/evm.ts | 25 ++++++++++++++-- .../use-cases/evm/chain-service.test.ts | 6 ++++ .../use-cases/evm/chain-service.ts | 15 ++++++++-- .../use-cases/evm/contract-service.test.ts | 8 ++++- .../use-cases/evm/transaction-service.test.ts | 19 +++++++++++- .../use-cases/evm/transaction-service.ts | 2 +- ts/src/application/use-cases/evm/tx-build.ts | 10 +++++-- ts/src/domain/numbers/index.ts | 29 +++++++++++++++++++ ts/src/domain/types/tx.ts | 2 +- 10 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 ts/src/domain/numbers/index.ts diff --git a/ts/src/adapters/outbound/chain/evm/evm.test.ts b/ts/src/adapters/outbound/chain/evm/evm.test.ts index 5c131014..2f425d1c 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.test.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.test.ts @@ -720,6 +720,14 @@ describe("EvmRpcClient.getTransactionReceipt", () => { expect(r?.contractAddress).toBe("0xdead"); }); + + it("rejects a block number that cannot be represented safely", async () => { + stubRpc({ status: "0x1", blockNumber: "0x20000000000000" }); + + await expect( + new EvmRpcClient("https://node.example", 5_000).getTransactionReceipt("0xabc"), + ).rejects.toMatchObject({ code: "rpc_error" }); + }); }); describe("EvmRpcClient.encodeErc20Transfer", () => { @@ -911,6 +919,10 @@ describe("EvmRpcClient contract-write encoding", () => { expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/); expect(client().contractAddressFor(ADDR, "1")).not.toBe(addr); }); + + it("rejects a CREATE nonce that cannot be represented safely", () => { + expect(() => client().contractAddressFor(ADDR, "9007199254740993")).toThrow(); + }); }); describe("EvmRpcClient.getTransactionByHash", () => { diff --git a/ts/src/adapters/outbound/chain/evm/evm.ts b/ts/src/adapters/outbound/chain/evm/evm.ts index 2711634f..29d9f886 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.ts @@ -14,6 +14,7 @@ import { type TransactionLike, } from "ethers"; import { ChainError } from "../../../../domain/errors/index.js"; +import { decimalToSafeNumber, quantityToSafeNumber } from "../../../../domain/numbers/index.js"; import { classifyEvmRejection, isAlreadyKnown } from "./node-errors.js"; import type { DeployConstructorArgs, @@ -186,7 +187,13 @@ export class EvmRpcClient implements EvmGateway { ...(price === undefined ? {} : { effectiveGasPriceWei: price.toString(10) }), ...(r.blockNumber === undefined ? {} - : { blockNumber: Number(BigInt(String(r.blockNumber))) }), + : { + blockNumber: quantityToSafeNumber( + r.blockNumber, + "receipt blockNumber", + rpcIntegerError, + ), + }), ...(r.contractAddress === undefined || r.contractAddress === null ? {} : { contractAddress: r.contractAddress }), @@ -322,7 +329,7 @@ export class EvmRpcClient implements EvmGateway { */ contractAddressFor(from: string, nonce: string): string { try { - return getCreateAddress({ from, nonce: Number(nonce) }); + return getCreateAddress({ from, nonce: decimalToSafeNumber(nonce, "nonce", valueError) }); } catch (e) { throw new ChainError( "invalid_value", @@ -465,7 +472,11 @@ export class EvmRpcClient implements EvmGateway { const raw = await this.#viewCall(contract, ERC20.encodeFunctionData("decimals", [])); if (raw === undefined) return undefined; try { - return Number(ERC20.decodeFunctionResult("decimals", raw)[0]); + return decimalToSafeNumber( + String(ERC20.decodeFunctionResult("decimals", raw)[0]), + "decimals", + rpcIntegerError, + ); } catch { // A value that is not a uint8 is the contract answering something else, not a node fault. return undefined; @@ -539,6 +550,14 @@ function toRpcQuantities(tx: Record): Record { return out; } +function rpcIntegerError(message: string) { + return new ChainError("rpc_error", message); +} + +function valueError(message: string) { + return new ChainError("invalid_value", message); +} + /** * JSON-RPC quantities are hex. Every amount downstream is a decimal base-unit string, and a wei * balance exceeds Number.MAX_SAFE_INTEGER, so this goes through BigInt — never parseInt. diff --git a/ts/src/application/use-cases/evm/chain-service.test.ts b/ts/src/application/use-cases/evm/chain-service.test.ts index f6e4a72b..816da37b 100644 --- a/ts/src/application/use-cases/evm/chain-service.test.ts +++ b/ts/src/application/use-cases/evm/chain-service.test.ts @@ -122,6 +122,12 @@ describe("EvmChainService.node", () => { expect(out.peers).toBeNull(); }); + it("degrades peers to null when the count is outside the safe integer range", async () => { + const out = await service({ peerCount: "9007199254740993" }).node(net); + + expect(out.peers).toBeNull(); + }); + it("degrades the solid block to null on a chain that does not serve finalized", async () => { const out = await service({ finalized: new ChainError("rpc_error", "unknown block") }).node( net, diff --git a/ts/src/application/use-cases/evm/chain-service.ts b/ts/src/application/use-cases/evm/chain-service.ts index a3a53314..4ae40bfc 100644 --- a/ts/src/application/use-cases/evm/chain-service.ts +++ b/ts/src/application/use-cases/evm/chain-service.ts @@ -1,5 +1,6 @@ import { endpointHost, type NetworkDescriptor } from "../../../domain/types/index.js"; import { evmFeeMode } from "../../../domain/fees/evm-gas.js"; +import { decimalToSafeNumber, quantityToSafeNumber } from "../../../domain/numbers/index.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; /** The protocol's fixed gas cost of a plain native transfer — the unit `chain prices` translates @@ -8,9 +9,16 @@ const NATIVE_TRANSFER_GAS = 21_000; /** hex QUANTITY → number, for the small values (block heights) this view reports. */ function quantity(value: unknown): number | null { - if (typeof value !== "string" || value === "") return null; try { - return Number(BigInt(value)); + return quantityToSafeNumber(value, "quantity", (message) => new Error(message)); + } catch { + return null; + } +} + +function decimal(value: unknown): number | null { + try { + return decimalToSafeNumber(value, "quantity", (message) => new Error(message)); } catch { return null; } @@ -84,6 +92,7 @@ export class EvmChainService { const headNumber = quantity(headBlock?.number) ?? 0; const solidNumber = quantity((finalized as Record | null)?.number); const headTimestamp = quantity(headBlock?.timestamp); + const peerCount = peers === null ? null : decimal(peers); return { // HOST only — same reason as the TRON side: an endpoint may carry an API key in its path, @@ -106,7 +115,7 @@ export class EvmChainService { // `eth_syncing` answers this directly: false means caught up. Unreachable → unknown, which // is not the same as "out of sync". inSync: syncing === null ? null : syncing === false, - peers: peers === null ? null : { connected: Number(peers), active: Number(peers) }, + peers: peerCount === null ? null : { connected: peerCount, active: peerCount }, }; } } diff --git a/ts/src/application/use-cases/evm/contract-service.test.ts b/ts/src/application/use-cases/evm/contract-service.test.ts index a6443dce..46f48697 100644 --- a/ts/src/application/use-cases/evm/contract-service.test.ts +++ b/ts/src/application/use-cases/evm/contract-service.test.ts @@ -11,7 +11,13 @@ import { EvmContractService } from "./contract-service.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; -const net = { id: "evm:1", family: "evm", nativeSymbol: "ETH" } as NetworkDescriptor; +const net = { + id: "evm:1", + family: "evm", + nativeSymbol: "ETH", + chainId: "1", + capabilities: [], +} as NetworkDescriptor; const TOKEN = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; diff --git a/ts/src/application/use-cases/evm/transaction-service.test.ts b/ts/src/application/use-cases/evm/transaction-service.test.ts index 9f9b2d6a..22142310 100644 --- a/ts/src/application/use-cases/evm/transaction-service.test.ts +++ b/ts/src/application/use-cases/evm/transaction-service.test.ts @@ -112,6 +112,23 @@ describe("EvmTransactionService.send — native transfer", () => { expect(built[0]!.value).toBe("12345"); }); + + it("rejects a pending nonce that cannot be represented safely", async () => { + const { service } = harness({ nonce: "9007199254740993" }); + + await expect( + service.send(scope(), SEPOLIA, { to: RECEIVER, amount: "1" } as never), + ).rejects.toMatchObject({ code: "invalid_value" }); + }); + + it("rejects an unsafe chain id before building a transaction", async () => { + const { service } = harness(); + const unsafeChain = { ...SEPOLIA, chainId: "9007199254740993" } satisfies NetworkDescriptor; + + await expect( + service.send(scope(), unsafeChain, { to: RECEIVER, amount: "1" } as never), + ).rejects.toMatchObject({ code: "invalid_value" }); + }); }); describe("EvmTransactionService.send — fee overrides", () => { @@ -821,7 +838,7 @@ describe("EvmTransactionService.info", () => { amount: "1", symbol: "ETH", blockNumber: 5, - gasUsed: 21000, + gasUsed: "21000", feeWei: "1000", // §6.5 收斂: one case throughout, so an agent matches "success" and never "SUCCESS". status: "success", diff --git a/ts/src/application/use-cases/evm/transaction-service.ts b/ts/src/application/use-cases/evm/transaction-service.ts index 94fb367f..499b779c 100644 --- a/ts/src/application/use-cases/evm/transaction-service.ts +++ b/ts/src/application/use-cases/evm/transaction-service.ts @@ -517,7 +517,7 @@ export class EvmTransactionService { ...(receipt.blockNumber === undefined ? {} : { blockNumber: receipt.blockNumber as number }), - ...(receipt.gasUsed === undefined ? {} : { gasUsed: Number(receipt.gasUsed) }), + ...(receipt.gasUsed === undefined ? {} : { gasUsed: String(receipt.gasUsed) }), ...(receipt.feeWei === undefined ? {} : { feeWei: String(receipt.feeWei) }), ...(receipt.effectiveGasPriceWei === undefined ? {} diff --git a/ts/src/application/use-cases/evm/tx-build.ts b/ts/src/application/use-cases/evm/tx-build.ts index ad9ace13..97e6c04b 100644 --- a/ts/src/application/use-cases/evm/tx-build.ts +++ b/ts/src/application/use-cases/evm/tx-build.ts @@ -1,5 +1,7 @@ import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index.js"; import { planEvmFee } from "../../../domain/fees/evm-gas.js"; +import { UsageError } from "../../../domain/errors/index.js"; +import { decimalToSafeNumber } from "../../../domain/numbers/index.js"; import { resolveGasLimit } from "../../services/evm-gas-estimate.js"; import type { EvmGateway } from "../../ports/chain/gateway-provider.js"; @@ -33,6 +35,10 @@ function overridesOf(input: EvmGasInput) { }; } +function invalidInteger(message: string) { + return new UsageError("invalid_value", message); +} + export async function buildEvmUnsignedTx(request: EvmBuildRequest): Promise { const { gateway, network, from, call, input } = request; const [nonce, fee] = await Promise.all([ @@ -57,8 +63,8 @@ export async function buildEvmUnsignedTx(request: EvmBuildRequest): Promise Error; + +const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); + +function safeNonNegative(value: bigint, field: string, invalid: ErrorFactory): number { + if (value < 0n) throw invalid(`${field} must be a non-negative integer`); + if (value > MAX_SAFE) throw invalid(`${field} exceeds Number.MAX_SAFE_INTEGER`); + return Number(value); +} + +export function decimalToSafeNumber(value: unknown, field: string, invalid: ErrorFactory): number { + if (typeof value !== "string" || !/^(?:0|[1-9][0-9]*)$/.test(value)) { + throw invalid(`${field} must be a non-negative decimal integer`); + } + return safeNonNegative(BigInt(value), field, invalid); +} + +export function quantityToSafeNumber(value: unknown, field: string, invalid: ErrorFactory): number { + if (typeof value !== "string" || value === "") { + throw invalid(`${field} must be a hex quantity`); + } + let parsed: bigint; + try { + parsed = BigInt(value); + } catch { + throw invalid(`${field} must be a hex quantity`); + } + return safeNonNegative(parsed, field, invalid); +} diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index 4fe81b79..0981029b 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -283,7 +283,7 @@ export interface TxInfoView extends TxParties { /** head height minus this transaction's block; best-effort, see TxStatusView.confirmations. */ confirmations?: number; energyUsed?: number; // tron execution resource - gasUsed?: number; // evm execution resource + gasUsed?: number | string; // evm execution resource feeSun?: number; // tron native fee (sun) // EVM native fee. A separate field rather than a shared `fee`: the UNIT is in the name, so a // reader can never mistake one family's magnitude for the other's (18 decimals vs 6). From b1bc761966c51153f93924346787e85f4c294a58 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 18:56:09 +0800 Subject: [PATCH 10/11] docs: refresh EVM command metadata --- .../adapters/inbound/cli/commands/contract.ts | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index e8b3a42e..2bb372d7 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -29,9 +29,9 @@ function jsonArray(raw: string | undefined, flag = "--params"): unknown[] { throw new UsageError("invalid_value", `${flag} must be a JSON array`); } -// call/send parameters are ABI-encoded from {type, value} entries. Validate the shape at the -// command boundary so a malformed entry fails as invalid_value here, not as an opaque encoder/RPC -// error deep in TronWeb. (deploy params are raw positional values — they use jsonArray, not this.) +// Contract parameters are ABI-encoded from {type, value} entries. Validate the shape at the +// command boundary so a malformed entry fails as invalid_value here, not as an opaque family +// encoder/RPC error later. const typedParam = z .object({ type: z.string().min(1), value: z.unknown() }) .refine((e) => e.value !== undefined, { message: "value is required" }); @@ -86,17 +86,6 @@ function assertConstructorEncodable(abi: unknown): void { } } -/** - * Constructor args are RAW positional values here (`[100, "T..."]`) — types come from the ABI — - * whereas `contract call` / `send` take `{type,value}` entries. TronWeb rejects the wrong one too, - * but as ethers' `invalid BigNumberish value (argument="value", ...)`: an internal argument name - * that collides with the user's own `value` key and reads like a bad number rather than a wrong - * format. The two-format split is this CLI's own design, so name it in our own words. - * - * Only the unambiguous case is claimed — every entry an object with exactly `type` (a non-empty - * string) and `value`. A mixed or partial array is left to TronWeb rather than guessed at, and a - * genuine struct arg with those two field names can still be passed in positional array form. - */ /** * `--constructor-params` entries, as `{type, value}` — the same form `contract call` and * `contract send` take. @@ -637,11 +626,6 @@ export const contractDeploySpec: ChainSpec = { description: "Deploy contract creation bytecode and report the new contract's address.\n" + "Flags marked (tron) or (evm) apply only on networks of that family; using one on the other family is rejected.", - // The Ledger TRON app firmware rejects CreateSmartContract (APDU 0x6a80), even with - // blind-signing enabled; software accounts sign and deploy it fine. - requires: [ - "a software (non-Ledger) account (tron) — the Ledger TRON app cannot sign a contract deployment; the Ledger Ethereum app can", - ], baseFields: deployFields, baseRefine: deployRefine, examples: [ From c41b43acff25e1738ac1025385fb10e8607a9bd9 Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 19:28:04 +0800 Subject: [PATCH 11/11] chore: fix post-rebase quality gates --- ts/test/golden.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 05c7c071..ee65137a 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -338,7 +338,10 @@ describe("golden CLI — command help contracts", () => { it("tx send --help summary leads with 'Send' and human --amount (E2)", () => { const r = run(["tx", "send", "--help"], { password: null }); expect(r.status).toBe(0); - expect(r.stdout).toContain("Send native coins or tokens with human --amount"); + // Leads with the imperative verb (§10.1 rule 1) and stays family-neutral; the human-unit + // --amount flag is what the E2 contract is really about, so assert it directly. + expect(r.stdout).toMatch(/^Send the native coin, or a token/m); + expect(r.stdout).toMatch(/^ +--amount +human amount/m); }); it("block --help documents the height as a positional arg, not a --number flag (H4)", () => {