Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions ts/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
22 changes: 3 additions & 19 deletions ts/src/adapters/inbound/cli/commands/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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: [
Expand Down
8 changes: 4 additions & 4 deletions ts/src/adapters/inbound/cli/commands/tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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 —
Expand Down
6 changes: 3 additions & 3 deletions ts/src/adapters/inbound/cli/commands/typed-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
9 changes: 5 additions & 4 deletions ts/src/adapters/inbound/cli/help/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 44 additions & 0 deletions ts/src/adapters/inbound/cli/help/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,50 @@ 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
Expand Down
8 changes: 4 additions & 4 deletions ts/src/adapters/inbound/cli/help/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -536,10 +536,10 @@ export class HelpService {
}
}

function mergedFields(def: ChainCommandDefinition): ZodObject<ZodRawShape> {
function mergedFields(def: ChainCommandDefinition, family?: ChainFamily): ZodObject<ZodRawShape> {
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);
}

Expand Down
3 changes: 0 additions & 3 deletions ts/src/adapters/inbound/cli/render/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions ts/src/adapters/outbound/chain/evm/evm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -709,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", () => {
Expand Down Expand Up @@ -900,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", () => {
Expand Down
75 changes: 41 additions & 34 deletions ts/src/adapters/outbound/chain/evm/evm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }),
Expand All @@ -196,6 +203,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<JsonRpcResponse> {
return this.#request(method, params);
}

async #request(method: string, params: unknown[]): Promise<JsonRpcResponse> {
this.#id += 1;
let response: { ok: boolean; status?: number; text(): Promise<string> };
try {
Expand All @@ -211,7 +222,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. */
Expand Down Expand Up @@ -306,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",
Expand Down Expand Up @@ -344,20 +367,7 @@ export class EvmRpcClient implements EvmGateway {
signature: string,
params: Array<{ type: string; value: unknown }>,
): Promise<string> {
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));
}

/**
Expand Down Expand Up @@ -462,30 +472,19 @@ 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;
}
}

async #call(method: string, params: unknown[]): Promise<unknown> {
this.#id += 1;
let response: { ok: boolean; status?: number; text(): Promise<string> };
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}`);
}
Expand Down Expand Up @@ -551,6 +550,14 @@ function toRpcQuantities(tx: Record<string, unknown>): Record<string, unknown> {
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.
Expand Down
Loading
Loading