From 11c98aa0df9534a811001a35e73eac1a4852e963 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 24 Aug 2026 10:03:33 +0800 Subject: [PATCH 01/23] feat: evm extension --- ...wallet-cli-architecture-source-of-truth.md | 96 ++- ts/package-lock.json | 669 +++++++++++++++++ ts/package.json | 2 + .../adapters/inbound/cli/commands/account.ts | 16 +- ts/src/adapters/inbound/cli/commands/block.ts | 5 + ts/src/adapters/inbound/cli/commands/chain.ts | 83 ++- .../adapters/inbound/cli/commands/config.ts | 10 +- .../adapters/inbound/cli/commands/contact.ts | 2 +- .../cli/commands/contract.deploy.test.ts | 128 +++- .../adapters/inbound/cli/commands/contract.ts | 186 ++++- .../cli/commands/family-fields.test.ts | 80 ++ .../inbound/cli/commands/message.sign.test.ts | 2 +- .../adapters/inbound/cli/commands/network.ts | 13 + .../inbound/cli/commands/permission.ts | 6 +- .../adapters/inbound/cli/commands/shared.ts | 41 +- .../cli/commands/text-formatters.test.ts | 218 +++++- ts/src/adapters/inbound/cli/commands/token.ts | 72 +- .../cli/commands/transaction-options.test.ts | 23 +- .../inbound/cli/commands/tx.multisig.test.ts | 2 +- .../inbound/cli/commands/tx.sign.test.ts | 10 +- ts/src/adapters/inbound/cli/commands/tx.ts | 108 ++- .../inbound/cli/commands/typed-data.test.ts | 2 +- .../cli/commands/wallet.backup.test.ts | 37 + .../cli/commands/wallet.current.test.ts | 98 ++- .../cli/commands/wallet.import-ledger.test.ts | 6 +- .../inbound/cli/commands/wallet.test.ts | 47 ++ .../adapters/inbound/cli/commands/wallet.ts | 75 +- .../inbound/cli/context/context.test.ts | 46 ++ ts/src/adapters/inbound/cli/context/index.ts | 17 +- .../adapters/inbound/cli/contracts/command.ts | 16 +- ts/src/adapters/inbound/cli/globals/index.ts | 2 +- ts/src/adapters/inbound/cli/help/help.test.ts | 88 ++- ts/src/adapters/inbound/cli/help/index.ts | 43 +- .../inbound/cli/input/secret/index.ts | 7 + .../inbound/cli/input/secret/secret.test.ts | 25 + .../inbound/cli/output/output.test.ts | 2 +- ts/src/adapters/inbound/cli/render/account.ts | 3 +- .../inbound/cli/render/block-render.test.ts | 64 ++ .../inbound/cli/render/family-render.test.ts | 93 ++- ts/src/adapters/inbound/cli/render/family.ts | 70 +- ts/src/adapters/inbound/cli/render/misc.ts | 31 +- .../inbound/cli/render/scalars.test.ts | 75 ++ ts/src/adapters/inbound/cli/render/scalars.ts | 46 +- ts/src/adapters/inbound/cli/render/tx.ts | 25 +- ts/src/adapters/inbound/cli/render/wallet.ts | 34 +- ts/src/adapters/inbound/cli/schemas/index.ts | 33 + ts/src/adapters/inbound/cli/shell/index.ts | 6 +- .../adapters/outbound/chain/evm/evm.test.ts | 685 ++++++++++++++++++ ts/src/adapters/outbound/chain/evm/evm.ts | 478 ++++++++++++ .../outbound/chain/evm/node-errors.ts | 50 ++ .../chain/evm/signing-strategy.test.ts | 210 ++++++ .../outbound/chain/evm/signing-strategy.ts | 101 +++ .../outbound/chain/tron/provider.test.ts | 4 + ts/src/adapters/outbound/config/builtins.ts | 57 +- .../adapters/outbound/config/config.test.ts | 231 +++++- ts/src/adapters/outbound/config/index.ts | 83 ++- .../outbound/contactbook/contactbook.test.ts | 52 +- ts/src/adapters/outbound/contactbook/index.ts | 24 +- .../adapters/outbound/gasfree/client.test.ts | 2 +- ts/src/adapters/outbound/gasfree/client.ts | 3 +- ts/src/adapters/outbound/keystore/index.ts | 21 +- .../outbound/keystore/keystore.test.ts | 54 +- ts/src/adapters/outbound/ledger/evm.test.ts | 239 ++++++ ts/src/adapters/outbound/ledger/index.ts | 176 ++++- .../outbound/persistence/migration.test.ts | 140 ++++ .../outbound/persistence/migration.ts | 96 +++ .../adapters/outbound/price/coingecko.test.ts | 78 ++ ts/src/adapters/outbound/price/coingecko.ts | 52 +- .../outbound/tokenbook/builtins.test.ts | 40 + .../adapters/outbound/tokenbook/builtins.ts | 31 + .../adapters/outbound/tronlink/client.test.ts | 2 +- ts/src/adapters/outbound/tronlink/client.ts | 6 +- .../ports/chain/gateway-provider.ts | 59 ++ .../application/ports/contact-repository.ts | 2 + ts/src/application/ports/network-registry.ts | 2 + .../services/evm-confirmation.test.ts | 95 +++ .../application/services/evm-confirmation.ts | 44 ++ .../services/pipeline/pipeline.test.ts | 2 +- .../services/pipeline/sign-only.test.ts | 2 +- .../services/recipient-resolver.test.ts | 142 +++- .../services/recipient-resolver.ts | 57 +- ts/src/application/services/signer/index.ts | 22 +- .../services/signer/resolver.test.ts | 37 +- ts/src/application/services/target/index.ts | 29 +- .../services/target/target.test.ts | 15 +- .../use-cases/account-balance-service.test.ts | 67 ++ .../use-cases/account-balance-service.ts | 29 + .../use-cases/config-service.test.ts | 143 ++++ .../application/use-cases/config-service.ts | 107 ++- .../use-cases/contact-service.test.ts | 130 ++++ .../application/use-cases/contact-service.ts | 52 +- .../use-cases/evm/account-service.test.ts | 199 +++++ .../use-cases/evm/account-service.ts | 122 ++++ .../use-cases/evm/block-service.ts | 11 + .../use-cases/evm/chain-service.test.ts | 143 ++++ .../use-cases/evm/chain-service.ts | 87 +++ .../use-cases/evm/contract-service.test.ts | 170 +++++ .../use-cases/evm/contract-service.ts | 191 +++++ .../use-cases/evm/token-service.test.ts | 130 ++++ .../use-cases/evm/token-service.ts | 77 ++ .../use-cases/evm/transaction-service.test.ts | 513 +++++++++++++ .../use-cases/evm/transaction-service.ts | 402 ++++++++++ .../use-cases/message-service.test.ts | 53 ++ .../application/use-cases/message-service.ts | 3 + .../use-cases/portfolio-holdings.test.ts | 79 ++ .../use-cases/portfolio-holdings.ts | 72 ++ .../use-cases/token-book-service.test.ts | 56 ++ .../use-cases/token-book-service.ts | 22 + .../use-cases/tron/account-service.test.ts | 15 +- .../use-cases/tron/account-service.ts | 64 +- .../use-cases/tron/asset-service.test.ts | 2 +- .../use-cases/tron/chain-service.test.ts | 1 + .../tron/contract-service.deploy.test.ts | 2 +- .../tron/contract-service.fee-limit.test.ts | 2 +- .../tron/contract-service.governance.test.ts | 2 +- .../use-cases/tron/contract-service.ts | 4 +- .../use-cases/tron/exchange-service.test.ts | 2 +- .../use-cases/tron/gasfree-service.test.ts | 2 +- .../use-cases/tron/gasfree-service.ts | 3 +- .../tron/governance-artifact.test.ts | 2 +- .../tron/governance-transaction-mode.test.ts | 2 +- .../multisig-collaboration-service.test.ts | 2 +- .../use-cases/tron/multisig-service.test.ts | 2 +- .../use-cases/tron/permission-service.test.ts | 2 +- .../use-cases/tron/proposal-service.test.ts | 2 +- .../use-cases/tron/reward-service.test.ts | 2 +- .../use-cases/tron/sig-service.test.ts | 2 +- .../tron/stake-service.query.test.ts | 1 + .../tron/stake-service.unfreeze.test.ts | 1 + .../tron/stake-service.withdraw.test.ts | 1 + .../tron/transaction-service.send.test.ts | 2 +- .../tron/transaction-service.status.test.ts | 2 +- .../use-cases/tron/vote-service.test.ts | 2 +- .../use-cases/tron/witness-service.test.ts | 2 +- .../use-cases/wallet-service.keystore.test.ts | 58 +- .../application/use-cases/wallet-service.ts | 43 +- ts/src/bootstrap/composition.ts | 30 +- ts/src/bootstrap/families/evm.test.ts | 146 ++++ ts/src/bootstrap/families/evm.ts | 147 ++++ ts/src/bootstrap/families/tron.ts | 22 +- ts/src/bootstrap/family-registry.ts | 3 +- ts/src/bootstrap/migration-gate.test.ts | 72 ++ ts/src/bootstrap/migration-gate.ts | 37 + ts/src/bootstrap/migration-steps.test.ts | 58 ++ ts/src/bootstrap/migration-steps.ts | 36 + ts/src/bootstrap/migration-wiring.test.ts | 222 ++++++ ts/src/bootstrap/runner.test.ts | 48 +- ts/src/bootstrap/runner.ts | 16 + ts/src/domain/address/address.test.ts | 96 +++ ts/src/domain/address/index.ts | 26 + ts/src/domain/contact/contact.test.ts | 71 +- ts/src/domain/contact/index.ts | 43 +- ts/src/domain/derivation/derivation.test.ts | 14 + ts/src/domain/derivation/index.ts | 7 +- ts/src/domain/family/chain-family.ts | 2 +- ts/src/domain/family/family.test.ts | 38 +- ts/src/domain/family/index.ts | 22 +- ts/src/domain/fees/evm-gas.test.ts | 158 ++++ ts/src/domain/fees/evm-gas.ts | 128 ++++ ts/src/domain/migration/index.ts | 41 ++ ts/src/domain/migration/migration.test.ts | 88 +++ ts/src/domain/migration/wallets-v2.test.ts | 162 +++++ ts/src/domain/migration/wallets-v2.ts | 58 ++ ts/src/domain/sources/sources.test.ts | 2 +- ts/src/domain/types/contact.ts | 3 +- ts/src/domain/types/network.ts | 33 +- ts/src/domain/types/tx.ts | 5 + ts/src/domain/types/wallet.ts | 7 + ts/src/domain/wallet/wallet.test.ts | 46 +- ts/test/contract-deploy.test.ts | 29 +- ts/test/golden.test.ts | 86 ++- 171 files changed, 10710 insertions(+), 563 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/family-fields.test.ts create mode 100644 ts/src/adapters/inbound/cli/render/block-render.test.ts create mode 100644 ts/src/adapters/inbound/cli/render/scalars.test.ts create mode 100644 ts/src/adapters/outbound/chain/evm/evm.test.ts create mode 100644 ts/src/adapters/outbound/chain/evm/evm.ts create mode 100644 ts/src/adapters/outbound/chain/evm/node-errors.ts create mode 100644 ts/src/adapters/outbound/chain/evm/signing-strategy.test.ts create mode 100644 ts/src/adapters/outbound/chain/evm/signing-strategy.ts create mode 100644 ts/src/adapters/outbound/ledger/evm.test.ts create mode 100644 ts/src/adapters/outbound/persistence/migration.test.ts create mode 100644 ts/src/adapters/outbound/persistence/migration.ts create mode 100644 ts/src/adapters/outbound/tokenbook/builtins.test.ts create mode 100644 ts/src/application/services/evm-confirmation.test.ts create mode 100644 ts/src/application/services/evm-confirmation.ts create mode 100644 ts/src/application/use-cases/account-balance-service.test.ts create mode 100644 ts/src/application/use-cases/account-balance-service.ts create mode 100644 ts/src/application/use-cases/contact-service.test.ts create mode 100644 ts/src/application/use-cases/evm/account-service.test.ts create mode 100644 ts/src/application/use-cases/evm/account-service.ts create mode 100644 ts/src/application/use-cases/evm/block-service.ts create mode 100644 ts/src/application/use-cases/evm/chain-service.test.ts create mode 100644 ts/src/application/use-cases/evm/chain-service.ts create mode 100644 ts/src/application/use-cases/evm/contract-service.test.ts create mode 100644 ts/src/application/use-cases/evm/contract-service.ts create mode 100644 ts/src/application/use-cases/evm/token-service.test.ts create mode 100644 ts/src/application/use-cases/evm/token-service.ts create mode 100644 ts/src/application/use-cases/evm/transaction-service.test.ts create mode 100644 ts/src/application/use-cases/evm/transaction-service.ts create mode 100644 ts/src/application/use-cases/message-service.test.ts create mode 100644 ts/src/application/use-cases/portfolio-holdings.test.ts create mode 100644 ts/src/application/use-cases/portfolio-holdings.ts create mode 100644 ts/src/application/use-cases/token-book-service.test.ts create mode 100644 ts/src/application/use-cases/token-book-service.ts create mode 100644 ts/src/bootstrap/families/evm.test.ts create mode 100644 ts/src/bootstrap/families/evm.ts create mode 100644 ts/src/bootstrap/migration-gate.test.ts create mode 100644 ts/src/bootstrap/migration-gate.ts create mode 100644 ts/src/bootstrap/migration-steps.test.ts create mode 100644 ts/src/bootstrap/migration-steps.ts create mode 100644 ts/src/bootstrap/migration-wiring.test.ts create mode 100644 ts/src/domain/address/address.test.ts create mode 100644 ts/src/domain/fees/evm-gas.test.ts create mode 100644 ts/src/domain/fees/evm-gas.ts create mode 100644 ts/src/domain/migration/index.ts create mode 100644 ts/src/domain/migration/migration.test.ts create mode 100644 ts/src/domain/migration/wallets-v2.test.ts create mode 100644 ts/src/domain/migration/wallets-v2.ts diff --git a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md index 73087b803..d6dac36d8 100644 --- a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md +++ b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md @@ -61,7 +61,7 @@ If the implementation and this document disagree, the change must fix one side o ### 1.2 Current Boundaries -- The only formal `ChainFamily` is currently `tron`; EVM is a planned but not-yet-public family. +- `ChainFamily` is `tron | evm`. Each family carries its own BIP44 template via `FamilyMeta.indexAt` — TRON hangs the account number at the `account` level, EVM at `address_index` — so the coin type alone does not determine a path. `FAMILIES` is a mapped type (`{ [F in ChainFamily]: FamilyMeta & { family: F } }`) so each entry keeps its literal family and `FamilyPlugin` still binds. - Ledger currently implements only the TRON app. - Network transport is TRON FullNode HTTP / TronWeb; `httpEndpoint` is not an Ethereum JSON-RPC or gRPC endpoint. - `create`, the various `import` commands, `delete`, and `backup` may be interactive in a controlled way; other commands fail fast when arguments are missing. @@ -236,7 +236,8 @@ flowchart LR PRE --> COMPOSE[composeCliRuntime] COMPOSE --> META{help/version/schema
or bare invocation?} META -->|yes| HELP[HelpService] - META -->|no| SHELL[buildCli + parseAsync] + META -->|no| GATE[migration gate] + GATE --> SHELL[buildCli + parseAsync] HELP --> FUNNEL[Runner terminal boundary] SHELL --> FUNNEL FUNNEL --> CLOSE[close Prompter] @@ -262,6 +263,34 @@ interface FamilyPlugin { } ``` +### 3.3 The Migration Gate + +Persisted state is migrated **eagerly and completely at startup, or not at all** (ADR-0008). The +gate sits after the help/version short-circuit — so `--help` stays reachable on a stale or +unmigratable keystore — and before any command dispatches. + +- A registry of steps, one per versioned file, each declaring `currentVersion`, whether migrating + a given document needs the master password, and how to migrate it. `contacts.json` and + `tokens.json` need none: the first is already family-keyed at rest, the second is keyed by + network id, so EVM only adds keys to both. +- Everything stale is applied in **one `writeJsonAll` transaction**, under the same advisory lock + every other mutator takes, so a concurrent process cannot have its work overwritten by a stale + read. A pre-migration copy is kept as `.v.bak` and never pruned: the transaction is + crash-safe but not *change*-safe, and a migration that succeeds while being wrong would + otherwise destroy the only copy of the prior state. +- The password is demanded only if some pending migration needs one, which is + `SOURCE_KINDS[type].hasSecret` — so a keystore of only `ledger` / `watch` accounts upgrades + silently. Failure is `migration_required` (exit 2); `--password-stdin` is honoured, so a + pipeline can self-heal without a human. +- The staleness test is `version < CURRENT`, not `!==`: a file written by a newer binary is left + alone rather than migrated downward. +- The absent-file default must synthesise `CURRENT_VERSION`, not a literal — that default is + persisted on first write, so a literal would stamp every new keystore stale. + +Eagerness is what lets `ChainAddresses` stay a **total** `Record`. A lazy or +partial backfill would force it to `Partial`, making a missing address reachable at every read +site and letting `list` output drift between runs. + `bootstrap/families/tron.ts` is TRON's concrete composition: it builds the `TronRpcClient`, the TronGrid history reader, the TRON use cases, and registers each command via `registerTronChainCommands`, which `addChain`s the neutral `ChainSpec` for each command together with its TRON `FamilyBinding`. Application and adapters must not import the family registry in reverse. --- @@ -277,8 +306,8 @@ interface FamilyPlugin { | `path` | Neutral commands use the full path; chain commands use a cross-family logical path. | | `family` | Omitted for neutral commands; when present, the resolved network selects the family implementation. | | `stdin` | A dedicated **command-scoped** stdin channel, one of `tx` or `message` (signed-tx JSON / message to sign). This field does not cover the master password, which is fed by the **global** `--password-stdin` (see the CLI surface section). Wallet secrets (`mnemonic`, `privateKey`) and the master-password *change* are TTY-only and have no stdin flag — see `secretsTtyOnly`. | -| `network` | `none` (never touches a chain) or `optional` (resolves `--network`, else `config.defaultNetwork`). There is no `required`: the default-network fallback always applies, so nothing can demand an explicit `--network`. | -| `wallet` | `none` or `optional`; optional can override the active account with `--account`. | +| `network` | `none` (never touches a chain) or `optional` (resolves `--network`, else `config.defaultNetwork`). There is no `required`: the default-network fallback always applies, so nothing can demand an explicit `--network`. **A NEUTRAL command may also be `optional`** — `list`, `current` and `backup` are, because the selected network acts as a DISPLAY SELECTOR (which family's address to show, which family's key to export), not as a target to contact. That does not make them chain commands: they are still dispatched by path, not by family. | +| `wallet` | `none` or `optional`. `optional` means the command has an implicit ACTIVE ACCOUNT that `--account` can override; it drives up-front account resolution, the `--account` help line, and the Requires block. It deliberately does NOT gate any account-vs-network compatibility check — see §6.3. | | `auth` | An unlock declaration for help/catalog; actual software signing uses lazy decrypt. | | `broadcasts` | Controls whether help reveals `--wait`. | | `passwordMode` | `establish` or `verify`, controls interactive master-password priming. | @@ -293,7 +322,7 @@ The stable command id is derived from metadata as `path.join(".")` for every com ### 4.2 The Two Command Classes and Routing -A chain command is one `ChainCommandDefinition` — a service-free `ChainSpec` plus a `families` table of per-family `FamilyBinding`s (`run` + optional `fields`/`refine` delta). The registry keys it by logical path; dispatch resolves the network, then selects the binding by `network.family`. When the resolved network's family has no binding for that command, dispatch returns `network_family_mismatch`. The merged input schema is `baseFields` plus each binding's `fields`, and validation composes `baseRefine` then each binding's `refine`. `isChainCommand` (presence of `families`) is the discriminator between the two command kinds. +A chain command is one `ChainCommandDefinition` — a service-free `ChainSpec` plus a `families` table of per-family `FamilyBinding`s (`run` + optional `fields`/`refine` delta). The registry keys it by logical path; dispatch resolves the network, then selects the binding by `network.family`. When the resolved network's family has no binding for that command, dispatch returns `family_mismatch` (renamed from `network_family_mismatch`; the code also covers an account, or a raw transaction, disagreeing with the target network). The merged input schema is `baseFields` plus each binding's `fields`, and validation composes `baseRefine` then each binding's `refine`. `isChainCommand` (presence of `families`) is the discriminator between the two command kinds. ```mermaid flowchart LR @@ -415,7 +444,10 @@ The account is the unit of selection and operation. `--account` accepts a canoni ### 6.2 Derivation and Addresses - BIP39 English wordlist; `create` generates 128-bit entropy (12 words). -- HD path: `m/44'/{coinType}'/{account}'/0/0`; the TRON coin type is 195. +- HD path follows each family's own ecosystem template, which differ in SHAPE and not only in coin + type: TRON hangs the account number at the account level (`m/44'/195'/'/0/0`), EVM at the + address_index level (`m/44'/60'/0'/0/`). `FamilyMeta.indexAt` carries which, so the coin type + alone never determines a path. - secp256k1 derives the address from an uncompressed 65-byte public key. - The seed vault stores encrypted entropy and an optional BIP39 passphrase, not the mnemonic string directly. - The public address cache lives in wallet metadata; read/build/estimate do not require decrypting secrets. @@ -430,6 +462,20 @@ The account is the unit of selection and operation. `--account` accepts a canoni - When the active account is deleted, the first remaining account is chosen; if none, it is set to `null`. - `current` returns only the persistent active account. +**An account is judged against a network where an ADDRESS IS DEMANDED, never where a network is +resolved.** `ExecutionScope.resolveAddress(family)` (and `SignerResolver` on the signing path) +raises `family_mismatch` when the account has no address in that family, naming the account's own +chain and how to switch. `TargetResolver` deliberately does not perform this check. + +The check used to live in `TargetResolver`, firing the moment a network was resolved. Two things +were wrong with that. It prevented nothing — without it, any command that truly needs the address +fails at `resolveAddress`, still before any RPC — so it was only ever a better error, earlier. And +it fired at the wrong moment: a command may resolve a network without ever demanding one family's +address (`current` resolves one to choose which family's receive QR to draw), and such a command +was refused for a condition that did not apply to it. Placing the check at the point of demand is +also self-maintaining: a new command needs no policy flag to opt in or out, because asking for an +address is what triggers it. + --- ## 7. Application: Use Cases, Services, and Ports @@ -465,7 +511,7 @@ An inbound command's responsibility is to turn argv/Zod input and `ExecutionCont ### 7.3 Reusable Services -- `TargetResolver`: network selection and single-family account compatibility. +- `TargetResolver`: network selection only. It deliberately does **not** judge the active account against the resolved network — see §6.3. - `CapabilityRegistry`: per-network feature gate. - `SignerResolver`: source → software/device signer. - `TxPipeline`: shared build/estimate/sign/broadcast lifecycle. @@ -476,27 +522,49 @@ An inbound command's responsibility is to turn argv/Zod input and `ExecutionCont ## 8. Network, Gateway, and Capability -The current descriptor: +`NetworkDescriptor` is a discriminated union on `family`: ```ts -interface TronNetworkDescriptor { +interface NetworkBase { id: string - family: "tron" chainId: string - aliases: string[] - httpEndpoint?: string - feeModel?: "tron-resource" + nativeSymbol: string // TRX / ETH / BNB — see below + feeModel?: FeeModel capabilities: string[] } +interface TronNetworkDescriptor extends NetworkBase { + family: "tron" + httpEndpoint?: string // TronGrid HTTP fullHost + tronlinkHttpEndpoint?: string + gasfree?: GasFreeNetworkConfig +} +interface EvmNetworkDescriptor extends NetworkBase { + family: "evm" + httpEndpoint?: string // JSON-RPC +} +type NetworkDescriptor = TronNetworkDescriptor | EvmNetworkDescriptor ``` +`nativeSymbol` is a NETWORK fact, not a family one. `evm:1` and `evm:56` share every encoding and +arithmetic rule that makes them EVM, but their coins are ETH and BNB; a family-level symbol can +only ever be right for one chain of the family, and reading one rendered a BNB balance as "ETH". +The family still owns what is genuinely family-wide — the base-unit name (`wei`) and its decimals. +`FamilyMeta` deliberately has no `nativeSymbol`, so the wrong one cannot be read. + +There are no `aliases` on the descriptor: they live in a flat `config.aliases` book (ADR-0010). + +A network from `config.yaml` is validated at load — missing `family` / `chainId` / `nativeSymbol`, +or an unknown family, raises `invalid_value` naming the network and the field, and `capabilities` +defaults to empty. Without that, an incomplete hand-added network travelled until something +dereferenced it, surfacing as a bare `internal_error` before any command ran. + | ID | Alias | Endpoint | | --- | --- | --- | | `tron:mainnet` | `tron` | `https://api.trongrid.io` | | `tron:nile` | `nile` | `https://nile.trongrid.io` | | `tron:shasta` | `shasta` | `https://api.shasta.trongrid.io` | -Canonical-id resolution is case-insensitive. Aliases remain descriptor metadata but are not accepted as network selectors. `network: optional` adopts `config.defaultNetwork` when `--network` is not specified, and that value must be a canonical id. Ledger/watch pin a single family, and a family mismatch must fail before any RPC. +Canonical-id resolution is case-insensitive. **Aliases ARE accepted as network selectors** (ADR-0010, superseding the previous rule): they live in a flat `config.aliases` book, not on the descriptor, and are resolved once in `NetworkRegistry.resolve` — canonical id first, book second, so an alias can never shadow a real id. Nothing downstream of resolution ever sees an alias. `network: optional` adopts `config.defaultNetwork` when `--network` is not specified. Ledger/watch pin a single family, and a family mismatch must fail before any RPC. `ChainGatewayRegistry` is injected with the family factory by Bootstrap and caches the client by network id. Its generic `client()` may only use the truly common minimal capabilities; a family use case obtains the `TronGateway` via the guarded `get(net, "tron")`. TRON staking and the future EVM gas/nonce must not be forced into a universal gateway. diff --git a/ts/package-lock.json b/ts/package-lock.json index 37e73310a..91c0fc6fa 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -9,6 +9,7 @@ "version": "4.12.0", "license": "LGPL-3.0-or-later", "dependencies": { + "@ledgerhq/hw-app-eth": "^7.8.15", "@ledgerhq/hw-app-trx": "^6.36.3", "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", "@ledgerhq/hw-transport-node-speculos-http": "^6.36.4", @@ -19,6 +20,7 @@ "@scure/bip32": "^2.2.0", "@scure/bip39": "^2.2.0", "axios": "^1.18.1", + "ethers": "6.13.5", "lossless-json": "^4.3.0", "qrcode": "^1.5.4", "tronweb": "6.4.0", @@ -673,6 +675,398 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@ethersproject/abi": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.8.0.tgz", + "integrity": "sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-provider": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", + "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/networks": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/web": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-signer": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.8.0.tgz", + "integrity": "sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0" + } + }, + "node_modules/@ethersproject/address": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.8.0.tgz", + "integrity": "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/rlp": "^5.8.0" + } + }, + "node_modules/@ethersproject/base64": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.8.0.tgz", + "integrity": "sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0" + } + }, + "node_modules/@ethersproject/bignumber": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.8.0.tgz", + "integrity": "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "bn.js": "^5.2.1" + } + }, + "node_modules/@ethersproject/bytes": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.8.0.tgz", + "integrity": "sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/constants": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.8.0.tgz", + "integrity": "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0" + } + }, + "node_modules/@ethersproject/hash": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.8.0.tgz", + "integrity": "sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/keccak256": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.8.0.tgz", + "integrity": "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "js-sha3": "0.8.0" + } + }, + "node_modules/@ethersproject/logger": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.8.0.tgz", + "integrity": "sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT" + }, + "node_modules/@ethersproject/networks": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.8.0.tgz", + "integrity": "sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/properties": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.8.0.tgz", + "integrity": "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/rlp": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.8.0.tgz", + "integrity": "sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/signing-key": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.8.0.tgz", + "integrity": "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "bn.js": "^5.2.1", + "elliptic": "6.6.1", + "hash.js": "1.1.7" + } + }, + "node_modules/@ethersproject/strings": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.8.0.tgz", + "integrity": "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/transactions": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", + "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/rlp": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0" + } + }, + "node_modules/@ethersproject/web": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.8.0.tgz", + "integrity": "sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -790,12 +1184,86 @@ "semver": "7.7.3" } }, + "node_modules/@ledgerhq/domain-service": { + "version": "1.8.15", + "resolved": "https://registry.npmjs.org/@ledgerhq/domain-service/-/domain-service-1.8.15.tgz", + "integrity": "sha512-27MknOfgI3FAkvyj8N4tgAIhMS/m9VpFUfEc/bUjb5g0MPcACJNusDleTpK3LvRqcUTJRr98w8eSR6ZmOtSLgA==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/logs": "6.17.0", + "@ledgerhq/types-live": "^6.120.0", + "axios": "1.13.5", + "eip55": "^2.1.1", + "react": "19.1.4", + "react-dom": "19.1.4" + } + }, "node_modules/@ledgerhq/errors": { "version": "6.36.0", "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-6.36.0.tgz", "integrity": "sha512-o2Q5hNvf2TzAzlH8ORAozppRbzixRPYDfmSQrP7FOcM997OEH7qDleXgp/uMpvRdxR/t3CJCG+n0i+bU/oYMKA==", "license": "Apache-2.0" }, + "node_modules/@ledgerhq/evm-tools": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/evm-tools/-/evm-tools-1.14.0.tgz", + "integrity": "sha512-VL+Ymt0g/hkm98JKFPMYk/cVP+A81Ho9nvJY93j1Cl9u75tHYJDNJLjbzYUnLf45Ix6589kQTGx/+uW9cAFv0Q==", + "license": "Apache-2.0", + "dependencies": { + "@ethersproject/constants": "^5.7.0", + "@ethersproject/hash": "^5.7.0", + "@ledgerhq/live-env": "^3.0.0", + "axios": "1.13.5", + "crypto-js": "4.2.0" + } + }, + "node_modules/@ledgerhq/hw-app-eth": { + "version": "7.8.15", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-app-eth/-/hw-app-eth-7.8.15.tgz", + "integrity": "sha512-PZTRTYCH0IRSpbotc+4OJ73brSL3S4OBKGdqgQAOUHZBpbaZaeaP+9IGPArWOw7rGL5ziiApWdzy0YxwvGvOBw==", + "license": "Apache-2.0", + "dependencies": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/rlp": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "@ledgerhq/domain-service": "^1.8.15", + "@ledgerhq/evm-tools": "^1.14.0", + "@ledgerhq/hw-transport": "6.35.7", + "@ledgerhq/hw-transport-mocker": "^6.34.7", + "@ledgerhq/logs": "6.17.0", + "@ledgerhq/types-live": "^6.120.0", + "axios": "1.13.5", + "bignumber.js": "^9.1.2", + "semver": "7.7.3" + } + }, + "node_modules/@ledgerhq/hw-app-eth/node_modules/@ledgerhq/devices": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-8.17.0.tgz", + "integrity": "sha512-l+rrVQEjR1hSWOLD00LFX4zbS8yB1M/Mb6UYPmSHGO7TmE1CFbZ4CmDJC/kZSl3hdrXCJ+YUNpvaLCcSWDwA+Q==", + "license": "Apache-2.0", + "dependencies": { + "semver": "7.7.3" + } + }, + "node_modules/@ledgerhq/hw-app-eth/node_modules/@ledgerhq/errors": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-7.0.0.tgz", + "integrity": "sha512-+Q/vykUlNeIxiM+I3cu1B660WLkzlmIsHLTV9QNV5D2/Ocplx3QMg52NYq2X7OAfGQnfH1rQvhn/NrjT+t9wBA==", + "license": "Apache-2.0" + }, + "node_modules/@ledgerhq/hw-app-eth/node_modules/@ledgerhq/hw-transport": { + "version": "6.35.7", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-6.35.7.tgz", + "integrity": "sha512-vVhAVQ56+7A5FY5Mr09HY+bmf3H6TXpwsj+/xbadNkjl//e/YzTWfpXk4eBnj3hwk1PQ9Mn6pKFH0BHjS2BKlg==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/devices": "8.17.0", + "@ledgerhq/errors": "^7.0.0", + "@ledgerhq/logs": "^6.17.0", + "events": "^3.3.0" + } + }, "node_modules/@ledgerhq/hw-app-trx": { "version": "6.36.3", "resolved": "https://registry.npmjs.org/@ledgerhq/hw-app-trx/-/hw-app-trx-6.36.3.tgz", @@ -817,6 +1285,44 @@ "events": "^3.3.0" } }, + "node_modules/@ledgerhq/hw-transport-mocker": { + "version": "6.34.7", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-mocker/-/hw-transport-mocker-6.34.7.tgz", + "integrity": "sha512-AyaO4unEHhZbyyo9y16z36uOi9pqY6kqVtWhTAp1PLZuQhXR/Y0m+Yd3yJrjUvlNpIOrrVN1rA+gHlmewl43Og==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/hw-transport": "6.35.7", + "@ledgerhq/logs": "^6.17.0", + "rxjs": "7.8.2" + } + }, + "node_modules/@ledgerhq/hw-transport-mocker/node_modules/@ledgerhq/devices": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-8.17.0.tgz", + "integrity": "sha512-l+rrVQEjR1hSWOLD00LFX4zbS8yB1M/Mb6UYPmSHGO7TmE1CFbZ4CmDJC/kZSl3hdrXCJ+YUNpvaLCcSWDwA+Q==", + "license": "Apache-2.0", + "dependencies": { + "semver": "7.7.3" + } + }, + "node_modules/@ledgerhq/hw-transport-mocker/node_modules/@ledgerhq/errors": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-7.0.0.tgz", + "integrity": "sha512-+Q/vykUlNeIxiM+I3cu1B660WLkzlmIsHLTV9QNV5D2/Ocplx3QMg52NYq2X7OAfGQnfH1rQvhn/NrjT+t9wBA==", + "license": "Apache-2.0" + }, + "node_modules/@ledgerhq/hw-transport-mocker/node_modules/@ledgerhq/hw-transport": { + "version": "6.35.7", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-6.35.7.tgz", + "integrity": "sha512-vVhAVQ56+7A5FY5Mr09HY+bmf3H6TXpwsj+/xbadNkjl//e/YzTWfpXk4eBnj3hwk1PQ9Mn6pKFH0BHjS2BKlg==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/devices": "8.17.0", + "@ledgerhq/errors": "^7.0.0", + "@ledgerhq/logs": "^6.17.0", + "events": "^3.3.0" + } + }, "node_modules/@ledgerhq/hw-transport-node-hid-noevents": { "version": "6.35.4", "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-6.35.4.tgz", @@ -843,12 +1349,28 @@ "rxjs": "7.8.2" } }, + "node_modules/@ledgerhq/live-env": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/live-env/-/live-env-3.0.0.tgz", + "integrity": "sha512-z15hY6+YFHxEpLew/rgKyJNCETC3d2olXc7bzPDLsv3gqbg49zE/ufAcCIfLX0tT8dr7PGIG1KBQbiBNPcVZ6g==", + "license": "Apache-2.0" + }, "node_modules/@ledgerhq/logs": { "version": "6.17.0", "resolved": "https://registry.npmjs.org/@ledgerhq/logs/-/logs-6.17.0.tgz", "integrity": "sha512-yra33g5q/AU7+PwAws+GaVpQGUuxnDREjVBnviJjcaJLVKuLzI4pnj8Bd3nY3fypM5k1yZEYKEXfUuGFUjP2+w==", "license": "Apache-2.0" }, + "node_modules/@ledgerhq/types-live": { + "version": "6.120.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/types-live/-/types-live-6.120.0.tgz", + "integrity": "sha512-9aub/pkbiJxIMJa3nxJi2ofqIudNP4JfhDxy4t/FhFUOFfI1GM+UqdsehKlkzlfLAEl7H24L9vuNBd7EXX8LCg==", + "license": "Apache-2.0", + "dependencies": { + "bignumber.js": "^9.1.2", + "rxjs": "7.8.2" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -2497,6 +3019,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -2510,6 +3038,12 @@ "node": "20 || >=22" } }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -2765,6 +3299,13 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2911,6 +3452,36 @@ "node": ">= 0.4" } }, + "node_modules/eip55": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/eip55/-/eip55-2.1.1.tgz", + "integrity": "sha512-WcagVAmNu2Ww2cDUfzuWVntYwFxbvZ5MvIyLZpMjTTkjD6sCvkGOiS86jTppzu9/gWsc8isLHAeMBWK02OnZmA==", + "license": "MIT", + "dependencies": { + "keccak": "^3.0.3" + } + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -3816,6 +4387,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -3828,6 +4409,17 @@ "node": ">= 0.4" } }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4002,6 +4594,12 @@ "node": ">=10" } }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", + "license": "MIT" + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4036,6 +4634,27 @@ "node": ">=6" } }, + "node_modules/keccak": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.4.tgz", + "integrity": "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/keccak/node_modules/node-addon-api": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", + "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==", + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4443,6 +5062,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -4555,6 +5186,17 @@ "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", "license": "MIT" }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-hid": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/node-hid/-/node-hid-2.1.2.tgz", @@ -5057,6 +5699,27 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/react": { + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.4.tgz", + "integrity": "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz", + "integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.4" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -5279,6 +5942,12 @@ "regexp-tree": "~0.1.1" } }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", diff --git a/ts/package.json b/ts/package.json index e90075e35..334d4d336 100644 --- a/ts/package.json +++ b/ts/package.json @@ -55,6 +55,7 @@ }, "license": "LGPL-3.0-or-later", "dependencies": { + "@ledgerhq/hw-app-eth": "^7.8.15", "@ledgerhq/hw-app-trx": "^6.36.3", "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", "@ledgerhq/hw-transport-node-speculos-http": "^6.36.4", @@ -65,6 +66,7 @@ "@scure/bip32": "^2.2.0", "@scure/bip39": "^2.2.0", "axios": "^1.18.1", + "ethers": "6.13.5", "lossless-json": "^4.3.0", "qrcode": "^1.5.4", "tronweb": "6.4.0", diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts index 5d598ecf5..a19f3df2f 100644 --- a/ts/src/adapters/inbound/cli/commands/account.ts +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -1,5 +1,7 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { AccountBalanceService } from "../../../../application/use-cases/account-balance-service.js"; +import type { EvmAccountService } from "../../../../application/use-cases/evm/account-service.js"; import type { TronAccountService } from "../../../../application/use-cases/tron/account-service.js"; import { ciEnum } from "../arity/index.js"; import { Schemas } from "../schemas/index.js"; @@ -114,8 +116,10 @@ export const accountBalanceSpec: ChainSpec = { formatText: TextFormatters.accountBalance, }; -export const accountBalanceTronBinding = (svc: TronAccountService): FamilyBinding => ({ - run: async (ctx, net) => svc.balance(ctx, net, "tron"), +/** Shared by every family: the balance read is family-neutral, so one binding serves them all + * and the family comes from the selected network. */ +export const accountBalanceBinding = (svc: AccountBalanceService): FamilyBinding => ({ + run: async (ctx, net) => svc.balance(ctx, net, net.family), }); export const accountInfoSpec: ChainSpec = { @@ -133,6 +137,14 @@ export const accountInfoTronBinding = (svc: TronAccountService): FamilyBinding = run: async (ctx, net) => svc.info(ctx, net), }); +export const accountPortfolioEvmBinding = (svc: EvmAccountService): FamilyBinding => ({ + run: async (ctx, net) => svc.portfolio(ctx, net), +}); + +export const accountInfoEvmBinding = (svc: EvmAccountService): FamilyBinding => ({ + run: async (ctx, net) => svc.info(ctx, net), +}); + export const accountHistorySpec: ChainSpec = { path: ["account", "history"], network: "optional", diff --git a/ts/src/adapters/inbound/cli/commands/block.ts b/ts/src/adapters/inbound/cli/commands/block.ts index 5cc28f9c8..2874c48b8 100644 --- a/ts/src/adapters/inbound/cli/commands/block.ts +++ b/ts/src/adapters/inbound/cli/commands/block.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronBlockService } from "../../../../application/use-cases/tron/block-service.js"; +import type { EvmBlockService } from "../../../../application/use-cases/evm/block-service.js"; import { Schemas } from "../schemas/index.js"; import { TextFormatters } from "../render/index.js"; @@ -23,3 +24,7 @@ export const blockSpec: ChainSpec = { export const blockTronBinding = (svc: TronBlockService): FamilyBinding => ({ run: async (_ctx, net, input) => svc.get(net, input.number), }); + +export const blockEvmBinding = (svc: EvmBlockService): FamilyBinding => ({ + run: async (_ctx, net, input) => svc.get(net, input.number), +}); diff --git a/ts/src/adapters/inbound/cli/commands/chain.ts b/ts/src/adapters/inbound/cli/commands/chain.ts index baa6f37a2..93a52c28d 100644 --- a/ts/src/adapters/inbound/cli/commands/chain.ts +++ b/ts/src/adapters/inbound/cli/commands/chain.ts @@ -1,8 +1,58 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronChainService } from "../../../../application/use-cases/tron/chain-service.js"; +import type { EvmChainService } from "../../../../application/use-cases/evm/chain-service.js"; import { TextFormatters } from "../render/index.js"; +/** `chain node` is its own export, not part of the TRON bundle below: every family can report + * node status, so the spec is shared and each family brings its own binding. */ +/** `chain prices` is shared: every family prices transactions somehow, though the fields differ. */ +export const chainPricesSpec: ChainSpec = { + path: ["chain", "prices"], + network: "optional", + wallet: "none", + auth: "none", + summary: "Transaction pricing for the selected network", + description: + "Show what a transaction costs to send on this network. The fields are family-shaped:\n" + + "TRON reports energy/bandwidth unit prices (in SUN; 1 TRX = 1,000,000 SUN) and the memo\n" + + "fee. An EVM chain reports its fee model plus base/priority/gas price (in wei).", + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli chain prices" }], + formatText: TextFormatters.chainPrices, +}; + +export const chainPricesTronBinding = (service: TronChainService): FamilyBinding => ({ + run: async (_ctx, net) => service.prices(net), +}); + +export const chainPricesEvmBinding = (service: EvmChainService): FamilyBinding => ({ + run: async (_ctx, net) => service.prices(net), +}); + +export const chainNodeSpec: ChainSpec = { + path: ["chain", "node"], + network: "optional", + wallet: "none", + auth: "none", + summary: "Connected node status (version / sync / peers)", + description: + "Show the connected node's status: version, head/solid block height, sync state,\n" + + 'and peer connections. Useful to tell "node out of sync" from "problem with my\n' + + 'transaction". Fields the endpoint does not expose are shown as "—" (null in json).', + baseFields: z.object({}), + examples: [{ cmd: "wallet-cli chain node" }], + formatText: TextFormatters.chainNode, +}; + +export const chainNodeTronBinding = (service: TronChainService): FamilyBinding => ({ + run: async (_ctx, net) => service.node(net), +}); + +export const chainNodeEvmBinding = (service: EvmChainService): FamilyBinding => ({ + run: async (_ctx, net) => service.node(net), +}); + export function chainDefinitions( service: TronChainService, ): Array<{ spec: ChainSpec; binding: FamilyBinding }> { @@ -29,38 +79,5 @@ export function chainDefinitions( }, binding: { run: async (_ctx, net, input) => service.params(net, input.key) }, }, - { - spec: { - path: ["chain", "prices"], - network: "optional", - wallet: "none", - auth: "none", - summary: "Energy/bandwidth unit price and memo fee", - description: - "Show current energy/bandwidth unit price (in SUN; 1 TRX = 1,000,000 SUN)\n" + - "and the memo fee.", - baseFields: z.object({}), - examples: [{ cmd: "wallet-cli chain prices" }], - formatText: TextFormatters.chainPrices, - }, - binding: { run: async (_ctx, net) => service.prices(net) }, - }, - { - spec: { - path: ["chain", "node"], - network: "optional", - wallet: "none", - auth: "none", - summary: "Connected node status (version / sync / peers)", - description: - "Show the connected node's status: version, head/solid block height, sync state,\n" + - 'and peer connections. Useful to tell "node out of sync" from "problem with my\n' + - 'transaction". Fields the endpoint does not expose are shown as "—" (null in json).', - baseFields: z.object({}), - examples: [{ cmd: "wallet-cli chain node" }], - formatText: TextFormatters.chainNode, - }, - binding: { run: async (_ctx, net) => service.node(net) }, - }, ]; } diff --git a/ts/src/adapters/inbound/cli/commands/config.ts b/ts/src/adapters/inbound/cli/commands/config.ts index f9f263155..9d5adc9cd 100644 --- a/ts/src/adapters/inbound/cli/commands/config.ts +++ b/ts/src/adapters/inbound/cli/commands/config.ts @@ -9,10 +9,16 @@ import { TextFormatters } from "../render/index.js"; export function registerConfigCommands(registry: CommandRegistry, service: ConfigService): void { const fields = z.object({ + // Not an enum: `networks..httpEndpoint` is a nested path, and the id segment is + // open-ended (any canonical id or alias). The service validates the key and names the + // supported ones, so a typo gets a precise message rather than a yargs enum dump. key: z - .enum(CONFIG_KEYS) + .string() + .min(1) .optional() - .describe("config key to read or set; omit to show the whole effective config"), + .describe( + `config key to read or set (${CONFIG_KEYS.join(", ")}, or networks..httpEndpoint); omit to show the whole effective config`, + ), value: z.string().min(1).optional().describe("new value; omit to read the key"), }); diff --git a/ts/src/adapters/inbound/cli/commands/contact.ts b/ts/src/adapters/inbound/cli/commands/contact.ts index 3f5e3b45c..165be5bf2 100644 --- a/ts/src/adapters/inbound/cli/commands/contact.ts +++ b/ts/src/adapters/inbound/cli/commands/contact.ts @@ -22,7 +22,7 @@ export function registerContactCommands(registry: CommandRegistry, service: Cont 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.", + "Add a locally stored recipient. The address is validated against the family it belongs to (T… = TRON, 0x… = EVM), and the name can then be used anywhere an address is accepted.", fields: addFields, input: addFields, examples: [ diff --git a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts index dd1b16397..50f2085c6 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { contractDeployTronBinding } from "./contract.js"; +import { + contractDeployEvmBinding, + contractDeploySpec, + contractDeployTronBinding, + contractSendEvmBinding, +} from "./contract.js"; import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; /** @@ -119,61 +124,114 @@ describe("contract deploy — ABI constructor guard", () => { }); }); -describe("contract deploy — --params form guard", () => { - const ABI = ctor({ stateMutability: "nonpayable" }); +/** + * §7.3 renamed the deploy inputs and changed the parameter form. + * + * `--params` meant `{type,value}` on `contract call`/`send` but bare positional values on + * `deploy` — one flag name, two incompatible formats across sibling commands, which is why a + * guard existed to explain the difference. `--constructor-params` unifies the form, so that + * guard now points the other way: the typed form is the accepted one. + * + * `--abi` stays REQUIRED on TRON and is tagged (tron). TronWeb's createSmartContract derives + * constructor types from the ABI and takes only bare values; ethers needs no ABI at all. + * Synthesising an ABI from the caller's inline types would hand TronWeb something nothing can + * check — a mistyped parameter would encode cleanly and deploy a wrong contract. + */ +function deployTyped(input: Record) { + const deploy = vi.fn(async (_c: unknown, _n: unknown, _i: { parameters: unknown[] }) => ({ + kind: "tx-receipt" as const, + })); + const binding = contractDeployTronBinding({ deploy } as unknown as TronContractService); + const run = () => + binding.run({} as never, {} as never, { code: "6080", feeLimit: "1000000", ...input } as never); + return { run, deploy }; +} - it("passes raw positional values, the documented deploy form", async () => { - const { run, deploy } = deployWith({ +describe("contract deploy — --constructor-params takes the typed form", () => { + const ABI = ctor({ stateMutability: "nonpayable", inputs: [{ name: "x", type: "uint256" }] }); + + it("accepts {type,value} entries and passes their values to the encoder", async () => { + const { run, deploy } = deployTyped({ abi: ABI, - params: '[100, "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"]', + constructorParams: '[{"type":"uint256","value":"100"},{"type":"string","value":"My Token"}]', }); await expect(run()).resolves.toBeDefined(); - expect(deploy.mock.calls[0]![2]).toMatchObject({ - parameters: [100, "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"], - }); + + // TronWeb takes bare values alongside the ABI, so the values are unwrapped here. + expect(deploy.mock.calls[0]![2]).toMatchObject({ parameters: ["100", "My Token"] }); }); - it("defaults to no constructor args when --params is omitted", async () => { - const { run, deploy } = deployWith({ abi: ABI }); + it("defaults to no constructor args when the flag is omitted", async () => { + const { run, deploy } = deployTyped({ abi: ABI }); await expect(run()).resolves.toBeDefined(); expect(deploy.mock.calls[0]![2]).toMatchObject({ parameters: [] }); }); - // Measured: TronWeb rejects this too, as ethers' `invalid BigNumberish value (argument="value")` - // — an internal argument name that collides with the user's own key. Same refusal, named. - it("rejects the {type,value} form that contract call/send take", async () => { - const params = '[{"type":"uint256","value":"100"}]'; - const { run, deploy } = deployWith({ abi: ABI, params }); + // The inverted guard: bare values were the old deploy form and are now the wrong one. + it("rejects the bare positional form that --params used to take", async () => { + const { run, deploy } = deployTyped({ abi: ABI, constructorParams: '[100, "My Token"]' }); + await expect(run()).rejects.toMatchObject({ code: "invalid_value", - message: expect.stringContaining("raw positional values"), + message: expect.stringContaining("type"), }); expect(deploy).not.toHaveBeenCalled(); }); - it("rejects a multi-entry {type,value} array", async () => { - const params = '[{"type":"uint256","value":"1"},{"type":"address","value":"T..."}]'; - const { run } = deployWith({ abi: ABI, params }); + it("still refuses an ABI whose constructor TronWeb would crash on", async () => { + const { run } = deployTyped({ abi: ctor({ stateMutability: 42 }), constructorParams: "[]" }); await expect(run()).rejects.toMatchObject({ code: "invalid_value" }); }); +}); - // Only the unambiguous all-typed array is claimed. Anything else could be a legitimate struct or - // a half-edited command line, and TronWeb's arity/type errors read fine on their own - // ("constructor needs 1 but 2 provided"). - it.each([ - ["a mixed array", '[100, {"type":"uint256","value":"1"}]'], - ["objects carrying a third key", '[{"type":"uint256","value":"1","name":"cap"}]'], - ["objects whose type is not a string", '[{"type":1,"value":"1"}]'], - ["objects whose type is empty", '[{"type":"","value":"1"}]'], - ["an empty array", "[]"], - ])("leaves %s to TronWeb", async (_label, params) => { - const { run, deploy } = deployWith({ abi: ABI, params }); +describe("contract deploy — code input channel", () => { + const ABI = ctor({ stateMutability: "nonpayable", inputs: [] }); + + it("takes the bytecode inline with --code", async () => { + const { run, deploy } = deployTyped({ abi: ABI, code: "6080" }); await expect(run()).resolves.toBeDefined(); - expect(deploy).toHaveBeenCalledOnce(); + expect(deploy.mock.calls[0]![2]).toMatchObject({ bytecode: "6080" }); + }); + + // These are schema rules, so they are asserted against the schema: calling the binding + // directly bypasses zod entirely and would pass no matter what the refine said. + const parse = (input: Record) => + contractDeploySpec.baseFields + .superRefine(contractDeploySpec.baseRefine!) + .safeParse({ dryRun: false, signOnly: false, buildOnly: false, permissionId: 0, ...input }); + + it("refuses both --code and --code-file at once", () => { + expect(parse({ code: "6080", codeFile: "./Token.bin" }).success).toBe(false); + }); + + it("refuses neither", () => { + expect(parse({}).success).toBe(false); + }); + + it("accepts exactly one of them", () => { + expect(parse({ code: "6080" }).success).toBe(true); + expect(parse({ codeFile: "./Token.bin" }).success).toBe(true); + }); +}); + +describe("contract deploy — EVM flag surface", () => { + // A flag that is offered but ignored is worse than an absent one: the caller believes the + // value was applied. `deploy` hardcodes value 0, and §7.3's usage line does not list + // --call-value, so it must not appear here — unlike `contract send`, which does use it. + it("offers no --call-value, which deploy would ignore", () => { + expect(Object.keys(contractDeployEvmBinding({} as never).fields?.shape ?? {})).not.toContain( + "callValue", + ); + }); + + it("still offers the four gas flags", () => { + const keys = Object.keys(contractDeployEvmBinding({} as never).fields?.shape ?? {}); + expect(keys).toEqual(expect.arrayContaining(["gasLimit", "maxFee", "priorityFee", "nonce"])); }); - it("still rejects --params that is not a JSON array", async () => { - const { run } = deployWith({ abi: ABI, params: '{"type":"uint256"}' }); - await expect(run()).rejects.toMatchObject({ code: "invalid_value", message: /JSON array/ }); + it("keeps --call-value on contract send, which does apply it", () => { + expect(Object.keys(contractSendEvmBinding({} as never).fields?.shape ?? {})).toContain( + "callValue", + ); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index 00cbbd00e..60be2c5c4 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -3,8 +3,10 @@ import { readFile } from "node:fs/promises"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import { UsageError } from "../../../../domain/errors/index.js"; import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; +import type { EvmContractService } from "../../../../application/use-cases/evm/contract-service.js"; import type { TronContractParameter } from "../../../../application/ports/chain/tron-gateway.js"; -import { Schemas } from "../schemas/index.js"; +import { Schemas, addressFieldsFor, allRefines } from "../schemas/index.js"; +import { gweiToWei } from "../../../../domain/fees/evm-gas.js"; import { governanceTxModeFields, governanceTxRefine } from "./shared.js"; import { TextFormatters } from "../render/index.js"; @@ -87,33 +89,26 @@ function assertConstructorEncodable(abi: unknown): void { * 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. */ -function deployParameters(raw: string | undefined): unknown[] { - const values = jsonArray(raw); - const allTyped = - values.length > 0 && - values.every((v) => { - if (!v || typeof v !== "object" || Array.isArray(v)) return false; - const keys = Object.keys(v); - return ( - keys.length === 2 && - keys.includes("type") && - keys.includes("value") && - typeof (v as { type: unknown }).type === "string" && - (v as { type: string }).type !== "" - ); - }); - if (allTyped) { +/** + * `--constructor-params` entries, as `{type, value}` — the same form `contract call` and + * `contract send` take. + * + * Deploy used to take bare positional values here while its siblings took typed entries: one + * flag name, two incompatible formats. That is what §7.3 unified, so the guard that used to + * reject the typed form now rejects the bare one. + */ +function typedConstructorParams(raw: string | undefined): TronContractParameter[] { + const values = jsonArray(raw, "--constructor-params"); + if (!z.array(typedParam).safeParse(values).success) { throw new UsageError( "invalid_value", - '--params takes raw positional values for deploy (e.g. [100, "T..."]); {"type","value"} ' + - "entries are the `contract call`/`send` form — deploy reads the types from the ABI constructor", + '--constructor-params entries must be {"type","value"} objects with a non-empty ABI type', ); } - return values; + return values as TronContractParameter[]; } - const callFields = z.object({ - contract: Schemas.addressFor("tron").describe("TRON contract address"), + contract: Schemas.address().describe("contract address"), method: z.string().min(1).describe("function signature, e.g. balanceOf(address)"), params: z .string() @@ -138,24 +133,72 @@ export const contractCallSpec: ChainSpec = { }; export const contractCallTronBinding = (svc: TronContractService): FamilyBinding => ({ + refine: addressFieldsFor("tron", "contract"), + run: async (_ctx, net, input) => + svc.call(net, input.contract, input.method, typedParams(input.params)), +}); + +export const contractCallEvmBinding = (svc: EvmContractService): FamilyBinding => ({ + refine: addressFieldsFor("evm", "contract"), run: async (_ctx, net, input) => svc.call(net, input.contract, input.method, typedParams(input.params)), }); const sendFields = z.object({ - contract: Schemas.addressFor("tron").describe("TRON contract address"), + contract: Schemas.address().describe("contract address"), method: z.string().min(1).describe("function signature, e.g. transfer(address,uint256)"), params: z .string() .optional() .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), + ...governanceTxModeFields, +}); + +/** TRON prices a contract call in SUN and burns energy up to a fee limit; both flag names say so. */ +const tronContractWriteFields = z.object({ callValueSun: Schemas.uintString() .default("0") .describe("native TRX attached to the call, in SUN"), feeLimit: Schemas.positiveIntString() .default("100000000") .describe("maximum energy fee to burn, in SUN"), - ...governanceTxModeFields, +}); + +/** EVM prices it in gas. `--call-value` is in whole coins, matching `tx send --amount`; the + * per-gas fields are gwei, the unit every wallet and explorer uses. */ +const evmGasFields = z.object({ + gasLimit: Schemas.positiveIntString() + .optional() + .describe("gas units to authorise; defaults to the node's estimate, unpadded"), + maxFee: z.string().optional().describe("maximum total fee per gas, in gwei (EIP-1559 only)"), + priorityFee: z.string().optional().describe("tip per gas, in gwei (EIP-1559 only)"), + nonce: z.coerce + .number() + .int() + .min(0) + .optional() + .describe("transaction nonce; defaults to the account's pending nonce"), +}); + +/** `contract send` additionally takes a call value; `contract deploy` does not — a deployment's + * value is always zero here, and offering a flag the command ignores is worse than omitting it. */ +const evmContractWriteFields = evmGasFields.extend({ + callValue: z + .string() + .optional() + .describe("native coin to attach to the call, in whole coins (e.g. 0.1)"), +}); + +const evmContractWrite = { + fields: evmContractWriteFields, + refine: addressFieldsFor("evm", "contract"), +}; + +/** gwei on the flag, wei below it. */ +const withEvmFees = (input: Record) => ({ + ...input, + ...(input.maxFee === undefined ? {} : { maxFee: gweiToWei(String(input.maxFee)) }), + ...(input.priorityFee === undefined ? {} : { priorityFee: gweiToWei(String(input.priorityFee)) }), }); export const contractSendSpec: ChainSpec = { @@ -176,7 +219,39 @@ export const contractSendSpec: ChainSpec = { formatText: TextFormatters.txReceipt, }; +export const contractSendEvmBinding = (svc: EvmContractService): FamilyBinding => ({ + ...evmContractWrite, + run: async (ctx, net, input) => + svc.send(ctx, net, { ...withEvmFees(input), params: typedParams(input.params) }), +}); + +/** the creation bytecode, from `--code` or `--code-file`. */ +async function creationBytecode(input: { code?: string; codeFile?: string }): Promise { + if (!input.codeFile) return input.code!; + try { + return await readFile(input.codeFile, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new UsageError("file_not_found", `code file not found: ${input.codeFile}`); + } + throw new UsageError("invalid_value", `cannot read code file: ${input.codeFile}`); + } +} + +export const contractDeployEvmBinding = (svc: EvmContractService): FamilyBinding => ({ + fields: evmGasFields, + run: async (ctx, net, input) => + svc.deploy(ctx, net, { + ...withEvmFees(input), + bytecode: await creationBytecode(input), + // ethers encodes straight from the inline types; no ABI is involved. + params: typedConstructorParams(input.constructorParams), + }), +}); + export const contractSendTronBinding = (svc: TronContractService): FamilyBinding => ({ + fields: tronContractWriteFields, + refine: addressFieldsFor("tron", "contract"), run: async (ctx, net, input) => svc.send(ctx, net, { ...input, @@ -185,18 +260,63 @@ export const contractSendTronBinding = (svc: TronContractService): FamilyBinding }); const deployFields = z.object({ - abi: z.string().min(1).describe("contract ABI as a JSON array string"), - bytecode: z.string().min(1).describe("compiled contract bytecode as hex, 0x-prefixed or bare"), - feeLimit: Schemas.positiveIntString().describe("maximum energy fee to burn, in SUN"), - params: z + code: z + .string() + .min(1) + .optional() + .describe("contract creation bytecode, hex-encoded; provide exactly one of --code or --code-file"), + codeFile: z + .string() + .min(1) + .optional() + .describe("path to a file holding the creation bytecode; bytecode often exceeds the shell's argument limit"), + constructorParams: z .string() .optional() .describe( - 'constructor args as a JSON array of raw positional values, e.g. [100, "T..."]; types are taken from the ABI constructor; omit to pass no constructor args', + 'constructor arguments as a JSON array of {type,value} entries, e.g. [{"type":"uint8","value":"18"}]; omit to pass none', ), ...governanceTxModeFields, }); +/** the spec's two base rules: the shared governance modes, plus exactly one bytecode source. + * Written out rather than composed generically because the two refines read different field + * sets, and a generic combinator would have to erase one of their types to fit them together. */ +function deployRefine( + value: { code?: string; codeFile?: string; expiration?: number; buildOnly?: boolean }, + ctx: z.RefinementCtx, +): void { + governanceTxRefine(value as never, ctx); + codeSourceRefine(value, ctx); +} + +/** exactly one bytecode source, matching the rule `contract create2` already applies. */ +function codeSourceRefine(value: { code?: string; codeFile?: string }, ctx: z.RefinementCtx): void { + if ([value.code !== undefined, value.codeFile !== undefined].filter(Boolean).length !== 1) { + ctx.addIssue({ + code: "custom", + path: ["code"], + message: "provide exactly one of --code or --code-file", + }); + } +} + +/** + * TRON's deploy inputs. + * + * `--abi` stays REQUIRED here rather than becoming optional: TronWeb's createSmartContract + * derives the constructor's types from the ABI and takes only bare values, so without it there + * is nothing to encode against. Synthesising an ABI from the caller's inline types would hand + * TronWeb something no one can check — a mistyped parameter would encode cleanly and deploy a + * contract built from the wrong arguments. ethers needs no ABI, which is why this is `(tron)`. + */ +const tronDeployFields = z.object({ + abi: z.string().min(1).describe("contract ABI as a JSON array string"), + feeLimit: Schemas.positiveIntString() + .default("100000000") + .describe("maximum energy fee to burn, in SUN"), +}); + export const contractDeploySpec: ChainSpec = { path: ["contract", "deploy"], network: "optional", @@ -211,7 +331,7 @@ export const contractDeploySpec: ChainSpec = { "a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type", ], baseFields: deployFields, - baseRefine: governanceTxRefine, + baseRefine: deployRefine, examples: [ { cmd: "wallet-cli contract deploy --abi '[...]' --bytecode 60... --fee-limit 1000000000 --params '[100, \"T...\"]'", @@ -221,6 +341,7 @@ export const contractDeploySpec: ChainSpec = { }; export const contractDeployTronBinding = (svc: TronContractService): FamilyBinding => ({ + fields: tronDeployFields, run: async (ctx, net, input) => { let abi: unknown; try { @@ -232,7 +353,10 @@ export const contractDeployTronBinding = (svc: TronContractService): FamilyBindi return svc.deploy(ctx, net, { ...input, abi, - parameters: deployParameters(input.params), + bytecode: await creationBytecode(input), + // TronWeb takes bare values beside the ABI, so the typed entries are unwrapped here. The + // TYPES still come from the ABI — the inline ones only decide what the caller meant. + parameters: typedConstructorParams(input.constructorParams).map((entry) => entry.value), }); }, }); diff --git a/ts/src/adapters/inbound/cli/commands/family-fields.test.ts b/ts/src/adapters/inbound/cli/commands/family-fields.test.ts new file mode 100644 index 000000000..ac4b2c381 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/family-fields.test.ts @@ -0,0 +1,80 @@ +/** + * Family-scoped command fields. + * + * A ChainSpec's `baseFields` is shared by every family, so it may only declare what every family + * actually has. Two consequences this file pins down: + * + * - TRC10 (`--asset-id`) is a TRON concept. It belongs to the TRON binding, not to the base. + * - An address flag stays a plain string in the base and is validated by the family's own + * `refine`, so help/catalog show one flag while each family still rejects the other's format. + * (Merging two same-named family fields would collapse in help — `mergedFields` is + * last-writer-wins — so the base-plus-refine shape is the one that survives EVM registration.) + */ +import { describe, it, expect } from "vitest"; +import { composeRefines } from "../shell/index.js"; +import { + tokenBalanceSpec, + tokenBalanceTronBinding, + tokenInfoSpec, + tokenInfoTronBinding, +} from "./token.js"; +import { txSendSpec, txSendTronBinding } from "./tx.js"; +import { contractCallSpec, contractCallTronBinding } from "./contract.js"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; + +const TRON_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const EVM_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; + +/** the schema dispatch actually parses against: base + family fields + both refines. */ +function effectiveSchema(spec: ChainSpec, binding: FamilyBinding) { + const fields = binding.fields ? spec.baseFields.extend(binding.fields.shape) : spec.baseFields; + return composeRefines(fields, spec.baseRefine, binding.refine); +} + +const svc = {} as never; + +describe("token selector fields", () => { + it("keeps --asset-id out of the shared base (TRC10 is TRON-only)", () => { + expect(Object.keys(tokenBalanceSpec.baseFields.shape)).not.toContain("assetId"); + expect(Object.keys(tokenInfoSpec.baseFields.shape)).not.toContain("assetId"); + }); + + it("declares --asset-id on the TRON binding instead", () => { + expect(Object.keys(tokenBalanceTronBinding(svc).fields?.shape ?? {})).toContain("assetId"); + expect(Object.keys(tokenInfoTronBinding(svc).fields?.shape ?? {})).toContain("assetId"); + }); + + it("still requires exactly one selector on TRON once the refine moved to the binding", () => { + const schema = effectiveSchema(tokenBalanceSpec, tokenBalanceTronBinding(svc)); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ contract: TRON_CONTRACT, assetId: "1002000" }).success).toBe(false); + expect(schema.safeParse({ contract: TRON_CONTRACT }).success).toBe(true); + expect(schema.safeParse({ assetId: "1002000" }).success).toBe(true); + }); +}); + +describe("address flags shared across families", () => { + const cases: Array<[string, ChainSpec, FamilyBinding, Record]> = [ + ["token balance", tokenBalanceSpec, tokenBalanceTronBinding(svc), {}], + ["tx send", txSendSpec, txSendTronBinding(svc), { to: TRON_CONTRACT, amount: "1" }], + ["contract call", contractCallSpec, contractCallTronBinding(svc), { method: "balanceOf()" }], + ]; + + it.each(cases)("%s leaves the base --contract family-neutral", (_name, spec) => { + const parsed = spec.baseFields + .pick({ contract: true }) + .safeParse({ contract: EVM_CONTRACT }); + expect(parsed.success && parsed.data.contract).toBe(EVM_CONTRACT); + }); + + it.each(cases)("%s rejects a non-TRON --contract on the TRON binding", (_n, spec, binding, base) => { + const result = effectiveSchema(spec, binding).safeParse({ ...base, contract: EVM_CONTRACT }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain("invalid tron address"); + }); + + it.each(cases)("%s accepts a TRON --contract on the TRON binding", (_n, spec, binding, base) => { + const result = effectiveSchema(spec, binding).safeParse({ ...base, contract: TRON_CONTRACT }); + expect(result.success).toBe(true); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/message.sign.test.ts b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts index cb468ac1a..a217c0680 100644 --- a/ts/src/adapters/inbound/cli/commands/message.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts @@ -31,7 +31,7 @@ describe("message sign exclusive group", () => { activeAccount: "main", secrets: { pick: (inline: string | undefined) => inline ?? "from-stdin" }, } as never; - await messageSignBinding(service as never).run(ctx, { family: "tron" } as never, { + await messageSignBinding(service as never).run(ctx, { family: "tron", nativeSymbol: "TRX" } as never, { message: "hello", }); expect(received).toBe("hello"); diff --git a/ts/src/adapters/inbound/cli/commands/network.ts b/ts/src/adapters/inbound/cli/commands/network.ts index 16a292e23..b396a0fdc 100644 --- a/ts/src/adapters/inbound/cli/commands/network.ts +++ b/ts/src/adapters/inbound/cli/commands/network.ts @@ -6,6 +6,15 @@ import type { CommandDefinition } from "../contracts/index.js"; import { CommandRegistry } from "../registry/index.js"; import { TextFormatters } from "../render/index.js"; +function endpointHost(url: string | undefined): string { + if (!url) return ""; + try { + return new URL(url).host; + } catch { + return ""; + } +} + export function registerNetworkCommands(reg: CommandRegistry): void { const empty = z.object({}); @@ -23,9 +32,13 @@ export function registerNetworkCommands(reg: CommandRegistry): void { run: async (ctx) => ctx.networkRegistry.all().map((n) => ({ id: n.id, + alias: ctx.networkRegistry.aliasOf(n.id), family: n.family, chainId: n.chainId, feeModel: n.feeModel, + // host only: an endpoint may carry an API key in its path, and this output is not a + // secret surface. `config get networks` is the place to confirm a full URL. + endpoint: endpointHost(n.httpEndpoint), })), } satisfies CommandDefinition); } diff --git a/ts/src/adapters/inbound/cli/commands/permission.ts b/ts/src/adapters/inbound/cli/commands/permission.ts index 8f17329b5..d4802990e 100644 --- a/ts/src/adapters/inbound/cli/commands/permission.ts +++ b/ts/src/adapters/inbound/cli/commands/permission.ts @@ -5,7 +5,7 @@ import { UsageError } from "../../../../domain/errors/index.js"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import { TextFormatters } from "../render/index.js"; import { exactlyOne, readBoundedTextFile } from "./artifact.js"; -import { txModeFields } from "./shared.js"; +import { txModeFields, tronTxModeFields } from "./shared.js"; const showFields = z.object({}); @@ -40,8 +40,8 @@ const updateFields = z.object({ buildOnly: txModeFields.buildOnly, // dry-run/sign-only wording is specific to a permission replacement, but the permission group // and expiration semantics are the shared ones — reuse them rather than keep a second copy. - permissionId: txModeFields.permissionId, - expiration: txModeFields.expiration, + permissionId: tronTxModeFields.permissionId, + expiration: tronTxModeFields.expiration, }); 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 0e9bdc731..bd856260b 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -11,23 +11,10 @@ import type { MessageService } from "../../../../application/use-cases/message-s // ── execution-mode flags shared by every signing command ───────────────────────── /** 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"), - signOnly: z - .boolean() - .default(false) - .describe("sign and output complete transaction hex without broadcasting"), - // Both multi-sig routes start from this artifact: the hex relay (`tx sign --file --out`) and the - // TronLink queue (`tx multisig --create`). Naming only one would read as "service path only". - buildOnly: z - .boolean() - .default(false) - .describe( - "build and output unsigned complete transaction hex without unlocking; the entry point for multi-party signing (relay it with `tx sign`, or open a queue with `tx multisig --create`)", - ), +/** TRON multi-signature concepts: a permission group to sign under, and a longer expiry while + * signatures are collected. Neither exists on a single-signature chain, so they belong to the + * TRON binding rather than to every family's flag set. */ +export const tronTxModeFields = { permissionId: z.coerce .number() .int() @@ -48,9 +35,29 @@ export const txModeFields = { ), }; +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"), + // Both multi-sig routes start from this artifact: the hex relay (`tx sign --file --out`) and the + // TronLink queue (`tx multisig --create`). Naming only one would read as "service path only". + buildOnly: z + .boolean() + .default(false) + .describe( + "build and output unsigned complete transaction hex without unlocking; the entry point for multi-party signing (relay it with `tx sign`, or open a queue with `tx multisig --create`)", + ), +}; + /** Full transaction controls required by governance/administrative writes. */ export const governanceTxModeFields = { ...txModeFields, + ...tronTxModeFields, buildOnly: z .boolean() .default(false) 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 b71e0125a..36c9ef17b 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -17,8 +17,12 @@ import { registerContactCommands } from "./contact.js"; import { registerAddressCommands } from "./address.js"; import { registerEncodingCommands } from "./encoding.js"; +// A chain command always has a resolved network by the time its formatter runs, so the default +// carries one. renderFamily() now refuses to guess (it used to silently default to tron, which +// would render wei as TRX), and a fixture without `net` would not represent any real invocation. const ctx = (over: Partial = {}): TextRenderContext => ({ command: "x", + net: { id: "tron:nile", family: "tron", nativeSymbol: "TRX" } as never, ...over, }); @@ -260,9 +264,9 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" net: { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", feeModel: "tron-resource", - aliases: [], capabilities: [], }, }), @@ -283,7 +287,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" rawAmount: "10000", contract: "TXYZtokenContract", to: "Tdest", - }); + }, ctx()); expect(out).toContain("Sent 10000 TXYZtokenContract"); expect(out).not.toContain("TRX"); }); @@ -295,7 +299,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" rawAmount: "500000", assetId: "1005416", to: "Tdest", - }); + }, ctx()); expect(out).toContain("Sent 500000 asset 1005416"); expect(out).not.toContain("TRX"); }); @@ -308,7 +312,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" to: "Tdest", blockNumber: 66000000, feeSun: "268000", - }); + }, ctx()); expect(out).toContain("✅"); expect(out).toContain("Sent 1 TRX"); expect(out).toContain("#66,000,000"); @@ -325,7 +329,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" blockNumber: 0, energyUsed: 0, feeSun: 0, - }); + }, ctx()); expect(out).toContain("#0"); expect(out).toMatch(/Energy\s+0/); expect(out).toContain("0 TRX"); @@ -340,7 +344,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" result: "OUT_OF_ENERGY", blockNumber: 1, failed: true, - }); + }, ctx()); expect(out).toContain("❌"); expect(out).toContain("Called transfer"); expect(out).toContain("TR7contract"); @@ -358,9 +362,9 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" net: { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", feeModel: "tron-resource", - aliases: [], capabilities: [], }, }), @@ -378,7 +382,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" rawAmount: "10000", contract: "TXYZtoken", to: "Tdest", - } as any); + } as any, ctx()); expect(out).toContain("Dry run"); expect(out).not.toContain("[object Object]"); expect(out).toContain("29,650 energy"); @@ -393,7 +397,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" rawAmount: "10000", contract: "TXYZtoken", to: "Tdest", - } as any); + } as any, ctx()); expect(out).toContain("29,650 energy"); expect(out).not.toContain("covered by staked energy"); }); @@ -415,7 +419,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" tx: { txID: "cc0a6f68" }, address: "TEF2CvkixrkzwbreCRFCQ7sZGj9AVFAkQq", payer: "TMSgJxtPw29", - } as any) as string; + } as any, ctx()) as string; it("account activate dry-run: renders the total creation fee, not [object Object]", () => { const out = dryRun(activateFee); @@ -428,7 +432,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" ["createAccountFeeSun alone", { minimumFeeSun: "100000" }, "0.1 TRX"], ["a zero fee", { minimumFeeSun: "0" }, "0 TRX"], // fees use fromBaseUnits (exact decimal, no thousands separators) like every other Fee row - ["a large fee", { minimumFeeSun: "9000000000" }, "9000 TRX"], + ["a large fee", { minimumFeeSun: "9000000000" }, "9,000 TRX"], // §1.4 grouping ])("account activate dry-run: %s", (_name, fee, expected) => { expect(dryRun(fee)).toContain(expected); }); @@ -491,7 +495,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: 1000000, - } as any) as string; + } as any, ctx()) as string; expect(out).toContain("Dry run tx broadcast"); expect(out).toContain('Permission active "finance" (id 2) threshold 2'); expect(out).toContain("Progress 2 / 2 — threshold reached"); @@ -505,7 +509,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: 0, - } as any) as string; + } as any, ctx()) as string; expect(out).toContain("abc123"); }); @@ -518,7 +522,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: fee, - } as any) as string; + } as any, ctx()) as string; expect(out.match(/multi-sign fee/gi) ?? []).toHaveLength(1); expect(out).toContain(expected); }); @@ -530,7 +534,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" txId: "abc123", transaction: broadcastApproval, multiSignFeeSun: 1000000, - } as any) as string; + } as any, ctx()) as string; expect(out).toContain("abc123"); expect(out).toContain("pending — not yet on-chain"); expect(out).toContain("Track it:"); @@ -544,7 +548,7 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" txId: "abc", amountSun: "2000000", resource: "energy", - }); + }, ctx()); expect(out).toContain("Staked"); expect(out).toContain("2 TRX"); expect(out).toContain("energy"); @@ -765,9 +769,9 @@ describe("txInfo formatter (per-family, narrowed on ctx.net.family)", () => { net: { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", feeModel: "tron-resource", - aliases: [], capabilities: [], }, }), @@ -792,7 +796,9 @@ describe("accountInfo staking summary", () => { ); it("preserves staking amounts above Number.MAX_SAFE_INTEGER when supplied as strings", () => { - expect(accountInfo("9007199254740993")).toContain("9007199254.740993 TRX"); + // grouped per §1.4; the point of this test is that the fraction survives intact past + // Number.MAX_SAFE_INTEGER, which it still does. + expect(accountInfo("9007199254740993")).toContain("9,007,199,254.740993 TRX"); }); it("omits the staking summary for an already-unsafe numeric amount", () => { @@ -852,7 +858,7 @@ describe("sign-only receipt", () => { address: "TSigner", txId: "abc123", }; - const ctx = { command: "tx sign", net: { family: "tron", id: "nile" } } as never; + const ctx = { command: "tx sign", net: { family: "tron", nativeSymbol: "TRX", id: "nile" } } as never; // The signature is the product of a signing command and has to be copied somewhere, so it must // never be shortened. Before this it showed a truncated txID — redundant with the TxID row and @@ -887,3 +893,177 @@ describe("sign-only receipt", () => { expect(out).not.toContain("Fee"); }); }); + +// `config networks` used to be a list of ids (an array, which rendered fine). It is now a map of +// id -> endpoint, and `aliases` is a map too — both printed as "[object Object]" until this. +describe("config renders map-valued keys", () => { + it("renders a single-key read as a titled block", () => { + const out = TextFormatters.config({ + key: "aliases", + value: { nile: "tron:nile", sepolia: "evm:11155111" }, + }); + + // `titled` is the house shape: bare title line, then indented fields (no colon) — see + // asset.ts / exchange.ts / governance.ts for the same form. + expect(out.split("\n")[0]).toBe("aliases"); + expect(out).toMatch(/^ {2}nile\s+tron:nile$/m); + expect(out).toMatch(/^ {2}sepolia\s+evm:11155111$/m); + expect(out).not.toContain("[object Object]"); + }); + + // The whole-config view is an overview: it names what exists rather than dumping every value, + // which for 7 networks plus 7 aliases would bury the scalar settings. + it("summarises map-valued keys in the whole-config view", () => { + const out = TextFormatters.config({ + defaultOutput: "text", + networks: { "tron:nile": "nile.trongrid.io", "evm:1": "ethereum-rpc.publicnode.com" }, + }); + + expect(out).toContain("tron:nile"); + expect(out).toContain("evm:1"); + expect(out).not.toContain("[object Object]"); + }); +}); + +// §1.4 draws a distinction the renderer previously did not: a VALUATION gets 2 decimals, a UNIT +// PRICE gets 4. This column had no coverage at all, so the two were silently the same. +describe("portfolio price vs valuation precision", () => { + const portfolio = (priceUsd: string, valueUsd: string) => + TextFormatters.accountPortfolio( + { + address: "Towner", + holdings: [{ symbol: "USDT", balance: "1000", priceUsd, valueUsd }], + totalValueUsd: valueUsd, + }, + ctx(), + ) as string; + + it("shows a depegged stablecoin's price instead of rounding it to a dollar", () => { + expect(portfolio("0.9998", "999.80")).toContain("$0.9998"); + }); + + it("keeps the valuation at two decimals", () => { + expect(portfolio("0.9998", "999.8")).toContain("$999.80"); + }); + + it("does not collapse a sub-cent price to zero", () => { + expect(portfolio("0.0001", "0.10")).toContain("$0.0001"); + }); +}); + +// The address already says which chain it is (T… / 0x…), so a Family column repeats it in +// vocabulary the user never needs otherwise. Externally the book is a flat name↔address map. +describe("contact list is a flat name-to-address map", () => { + const listed = () => + TextFormatters.contactList( + { + contacts: [ + { name: "tron-friend", address: "TWer2Ygk5", note: null }, + { name: "evm-friend", address: "0xe2E1a549", note: "team" }, + ], + }, + ) as string; + + it("has no Family column — the address already tells you the chain", () => { + expect(listed().split("\n")[0]).not.toMatch(/\bFamily\b/); + }); + + it("still lists every entry, whichever chain it belongs to", () => { + const out = listed(); + expect(out).toContain("TWer2Ygk5"); + expect(out).toContain("0xe2E1a549"); + }); +}); + +// §3.7: the address column follows the SELECTED NETWORK's family. text never puts both families +// side by side — the table doubles in width and the user only cares about the chain in use. +describe("list shows one family's addresses at a time", () => { + const accounts = [ + { + accountId: "wlt_a.0", + label: "main", + type: "seed", + index: 0, + active: true, + addresses: { tron: "TSRmq8kP9dEf", evm: "0x7a3fc19b" }, + }, + { + accountId: "wlt_l", + label: "ledger-evm", + type: "ledger", + index: null, + active: false, + family: "evm", + nativeSymbol: "ETH", + addresses: { evm: "0x91b24d0e" }, + }, + { + accountId: "wlt_w", + label: "team-vault", + type: "watch", + index: null, + active: false, + family: "tron", + nativeSymbol: "TRX", + addresses: { tron: "TBhCfAyt3TCUp" }, + }, + ]; + const listed = (family: "tron" | "evm") => + TextFormatters.walletList(accounts, ctx({ net: { family } as never })) as string; + + it("shows the TRON column under a TRON network", () => { + const out = listed("tron"); + expect(out).toContain("TSRmq8kP9dEf"); + expect(out).not.toContain("0x7a3fc19b"); + }); + + it("shows the EVM column under an EVM network", () => { + const out = listed("evm"); + expect(out).toContain("0x7a3fc19b"); + expect(out).not.toContain("TSRmq8kP9dEf"); + }); + + // A single-family account has nothing to show on the other family's network, and an empty row + // is worse than no row. + it("hides single-family accounts that do not belong to the selected network", () => { + expect(listed("tron")).not.toContain("ledger-evm"); + expect(listed("evm")).not.toContain("team-vault"); + }); + + it("keeps the accounts that do belong", () => { + expect(listed("tron")).toContain("team-vault"); + expect(listed("evm")).toContain("ledger-evm"); + }); +}); + +// `--keystore` picks ONE of a seed account's two keys, and with --network omitted that choice +// comes from config.defaultNetwork. The receipt has to say which key was written, or the same +// command on two machines silently produces different secrets with nothing to tell them apart. +describe("keystore receipt names the exported family", () => { + const receipt = (extra: Record) => + TextFormatters.walletBackup( + { + accountId: "wlt_a.0", + out: "/tmp/x.keystore.json", + format: "keystore", + secretType: "privateKey", + fileMode: "0600", + bytes: 491, + ...extra, + }, + ) as string; + + it("shows the family a keystore export used", () => { + expect(receipt({ family: "evm" })).toMatch(/^\s*Family\s+evm$/m); + }); + + // A mnemonic covers every family, so there is nothing to disambiguate and a row would imply + // a choice that was never made. + it("omits the row for a native backup", () => { + const out = TextFormatters.walletBackup( + { accountId: "wlt_a.0", out: "/tmp/x.json", secretType: "mnemonic", bytes: 313 }, + ) as string; + + expect(out).not.toMatch(/\bFamily\b/); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/token.ts b/ts/src/adapters/inbound/cli/commands/token.ts index 783512fc7..1fb2c5614 100644 --- a/ts/src/adapters/inbound/cli/commands/token.ts +++ b/ts/src/adapters/inbound/cli/commands/token.ts @@ -1,21 +1,44 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { TronTokenService } from "../../../../application/use-cases/tron/token-service.js"; -import { Schemas } from "../schemas/index.js"; +import { Schemas, addressFieldsFor, allRefines } from "../schemas/index.js"; import { TextFormatters } from "../render/index.js"; import { tokenSelector } from "./token-selector.js"; +import type { TokenBookService } from "../../../../application/use-cases/token-book-service.js"; +import type { EvmTokenService } from "../../../../application/use-cases/evm/token-service.js"; +/** Shared across families: every family has a token contract. TRC10 does not exist outside TRON, + * so `--asset-id` — and the "exactly one selector" rule it is half of — belong to the TRON + * binding below, not here. */ const selectorFields = z.object({ - contract: Schemas.addressFor("tron") - .optional() - .describe("TRC20 contract address; provide exactly one of --contract or --asset-id"), - assetId: z - .string() - .regex(/^\d+$/) - .optional() - .describe("TRC10 numeric asset id; provide exactly one of --asset-id or --contract"), + contract: Schemas.address().optional().describe("token contract address"), }); +/** the EVM half: no TRC10 equivalent exists, so `--contract` is simply required, and the shared + * neutral field is validated as an EVM address here. */ +const evmSelector = { + refine: allRefines( + (v: { contract?: string }, ctx: z.RefinementCtx) => { + if (v.contract === undefined) { + ctx.addIssue({ code: "custom", path: ["contract"], message: "--contract is required" }); + } + }, + addressFieldsFor("evm", "contract"), + ), +}; + +/** the TRON half of the selector: TRC10 asset id + the XOR rule + TRON address format. */ +const tronSelector = { + fields: z.object({ + assetId: z + .string() + .regex(/^\d+$/) + .optional() + .describe("TRC10 numeric asset id; provide exactly one of --asset-id or --contract"), + }), + refine: allRefines(tokenSelector, addressFieldsFor("tron", "contract")), +}; + export const tokenBalanceSpec: ChainSpec = { path: ["token", "balance"], network: "optional", @@ -24,12 +47,32 @@ export const tokenBalanceSpec: ChainSpec = { capability: "account.balance.token", summary: "Show a single token balance (--contract / --asset-id)", baseFields: selectorFields, - baseRefine: tokenSelector, examples: [{ cmd: "wallet-cli token balance --contract TR7..." }], formatText: TextFormatters.tokenBalance, }; +export const tokenBalanceEvmBinding = (svc: EvmTokenService): FamilyBinding => ({ + ...evmSelector, + run: async (ctx, net, input) => svc.balance(ctx, net, input), +}); + +export const tokenInfoEvmBinding = (svc: EvmTokenService): FamilyBinding => ({ + ...evmSelector, + run: async (_ctx, net, input) => svc.info(net, input), +}); + +export const tokenAddEvmBinding = (svc: EvmTokenService): FamilyBinding => ({ + ...evmSelector, + run: async (ctx, net, input) => svc.add(ctx, net, input), +}); + +export const tokenRemoveEvmBinding = (svc: EvmTokenService): FamilyBinding => ({ + ...evmSelector, + run: async (ctx, net, input) => svc.remove(ctx, net, input), +}); + export const tokenBalanceTronBinding = (svc: TronTokenService): FamilyBinding => ({ + ...tronSelector, run: async (ctx, net, input) => svc.balance(ctx, net, input), }); @@ -41,12 +84,12 @@ export const tokenInfoSpec: ChainSpec = { capability: "account.balance.token", summary: "Show token metadata (name/symbol/decimals/totalSupply)", baseFields: selectorFields, - baseRefine: tokenSelector, examples: [{ cmd: "wallet-cli token info --contract TR7..." }], formatText: TextFormatters.tokenInfo, }; export const tokenInfoTronBinding = (svc: TronTokenService): FamilyBinding => ({ + ...tronSelector, run: async (_ctx, net, input) => svc.info(net, input), }); @@ -58,12 +101,12 @@ export const tokenAddSpec: ChainSpec = { capability: "token.tokenbook", summary: "Add a token to the address book (fetches symbol/decimals)", baseFields: selectorFields, - baseRefine: tokenSelector, examples: [{ cmd: "wallet-cli token add --contract TR7..." }], formatText: TextFormatters.tokenBookAdd, }; export const tokenAddTronBinding = (svc: TronTokenService): FamilyBinding => ({ + ...tronSelector, run: async (ctx, net, input) => svc.add(ctx, net, input), }); @@ -79,7 +122,8 @@ export const tokenListSpec: ChainSpec = { formatText: TextFormatters.tokenBookList, }; -export const tokenListTronBinding = (svc: TronTokenService): FamilyBinding => ({ +/** Shared by every family: listing merges the book's two layers and touches no chain. */ +export const tokenListBinding = (svc: TokenBookService): FamilyBinding => ({ run: async (ctx, net) => svc.list(ctx, net), }); @@ -91,11 +135,11 @@ export const tokenRemoveSpec: ChainSpec = { capability: "token.tokenbook", summary: "Remove a user-added token from the address book", baseFields: selectorFields, - baseRefine: tokenSelector, examples: [{ cmd: "wallet-cli token remove --contract TR7..." }], formatText: TextFormatters.tokenBookRemove, }; export const tokenRemoveTronBinding = (svc: TronTokenService): FamilyBinding => ({ + ...tronSelector, run: async (ctx, net, input) => svc.remove(ctx, net, input), }); diff --git a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts index 6b4b02319..c9bfa167f 100644 --- a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts +++ b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts @@ -3,11 +3,15 @@ import { readFileSync, readdirSync } from "node:fs"; import { join, relative } from "node:path"; import { z } from "zod"; import { permissionUpdateSpec } from "./permission.js"; -import { governanceTxModeFields, txModeFields } from "./shared.js"; +import { governanceTxModeFields, tronTxModeFields, txModeFields } from "./shared.js"; + +// --permission-id and --expiration are TRON multi-signature concepts and live on the TRON +// binding's field set; these assertions are about their wording, which did not move. +const txModeAndTron = { ...txModeFields, ...tronTxModeFields }; describe("transaction option argv coercion", () => { it("accepts numeric --permission-id and --expiration values from argv", () => { - const parsed = z.object(txModeFields).parse({ + const parsed = z.object(txModeAndTron).parse({ buildOnly: true, permissionId: "2", expiration: "86400000", @@ -34,8 +38,8 @@ describe("transaction option argv coercion", () => { // mean nor what the limits are. A co-signer reading `--help` could not tell which permission group // to pass, nor that the collection window they were extending is capped at 24h. describe("shared --permission-id / --expiration document their semantics", () => { - const describeOf = (name: keyof typeof txModeFields): string => - (txModeFields[name] as { description?: string }).description ?? ""; + const describeOf = (name: keyof typeof txModeAndTron): string => + (txModeAndTron[name] as { description?: string }).description ?? ""; it("spells out what a permission group id means", () => { const text = describeOf("permissionId"); @@ -45,7 +49,7 @@ describe("shared --permission-id / --expiration document their semantics", () => }); it("states the expiration cap, and quotes the number the schema actually enforces", () => { - const schema = z.object(txModeFields); + const schema = z.object(txModeAndTron); expect(schema.safeParse({ buildOnly: true, expiration: 86_400_000 }).success).toBe(true); expect(schema.safeParse({ buildOnly: true, expiration: 86_400_001 }).success).toBe(false); // the description must quote that same bound — a stale number here is worse than none @@ -100,7 +104,7 @@ describe("reference pages keep up with the shared transaction options", () => { it("every --permission-id row carries the same value key the flag's help does", () => { // taken from the schema, not restated here: one wording, two surfaces const key = /\((0=owner[^)]*)\)/.exec( - (txModeFields.permissionId as { description?: string }).description ?? "", + (tronTxModeFields.permissionId as { description?: string }).description ?? "", )?.[1]; expect(key).toBeTruthy(); @@ -157,9 +161,12 @@ describe("reference pages keep up with the shared transaction options", () => { describe("governance --permission-id shares the protocol bound with every other command", () => { const field = (fields: Record) => z.object(fields as never); - it("rejects a permission id above the protocol maximum at the schema, as txModeFields does", () => { + it("rejects a permission id above the protocol maximum, as the TRON field set does", () => { expect(field(governanceTxModeFields).safeParse({ permissionId: "10" }).success).toBe(false); - expect(field(txModeFields).safeParse({ permissionId: "10" }).success).toBe(false); + // The bound lives with the flag, which is TRON-only: a permission group is a TRON concept, + // so the shared set no longer declares it at all. + expect(field(tronTxModeFields).safeParse({ permissionId: "10" }).success).toBe(false); + expect(Object.keys(txModeFields)).not.toContain("permissionId"); }); it("still accepts the whole valid range", () => { diff --git a/ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts b/ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts index 5f3acf601..b488f0b0d 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.multisig.test.ts @@ -3,7 +3,7 @@ import type { TronMultisigCollaborationService } from "../../../../application/u import { txTronLinkMultisigBinding } from "./tx.js"; const A = "TLZz5XKerAAebbRdScB3jmSPr5DHSpGJJP"; -const NETWORK = { family: "tron", id: "tron:nile" } as never; +const NETWORK = { family: "tron", nativeSymbol: "TRX", id: "tron:nile" } as never; const TX_ID = "ab".repeat(32); function harness() { 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 e98e72085..c32b43fe8 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts @@ -9,7 +9,7 @@ import { } from "./tx.js"; const ctx = { activeAccount: "main" } as never; -const net = { family: "tron", id: "nile" } as never; +const net = { family: "tron", nativeSymbol: "TRX", id: "nile" } as never; describe("tx sign spec", () => { it("does not broadcast and requires auth", () => { @@ -208,10 +208,12 @@ describe("tx send exclusive groups", () => { }); }); - it("marks the asset selector optional, since omitting it sends native TRX", () => { + it("marks the asset selector optional, since omitting it sends the native coin", () => { + // `--asset-id` is TRON-only and is declared on that binding. An exclusive group is + // spec-level and therefore shared by every family, so it may only name flags they all have. expect(groups().token).toEqual({ - label: "which asset to send; omit for native TRX", - flags: ["token", "contract", "asset-id"], + label: "which asset to send; omit for the network's native coin", + flags: ["token", "contract"], select: "at-most-one", }); expect(txSendSpec.baseFields.safeParse({ to: "T...", amount: "1" }).success).toBe(true); diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index 2c07320dc..d5d50faf6 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -2,36 +2,31 @@ 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 { EvmTransactionService } from "../../../../application/use-cases/evm/transaction-service.js"; +import { gweiToWei } from "../../../../domain/fees/evm-gas.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 { TronMultisigCollaborationService } from "../../../../application/use-cases/tron/multisig-collaboration-service.js"; import type { TransactionArtifactWriter } from "../../../../application/ports/transaction-artifact-writer.js"; -import { Schemas } from "../schemas/index.js"; -import { amountSelector, txModeFields, unifiedAmountFields } from "./shared.js"; +import { Schemas, addressFieldsFor, allRefines } from "../schemas/index.js"; +import { amountSelector, tronTxModeFields, txModeFields, 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). +// baseFields carry only what every family has: a recipient, an asset selector that is either a +// book symbol or a contract, and an amount. Everything priced or numbered per chain — TRON's +// fee limit and TRC10 asset id, EVM's gas flags and nonce — lives on that family's binding. const sendFields = z.object({ to: z .string() .trim() .min(1) .max(128) - .describe("recipient TRON base58 address or local contact name"), + .describe("recipient address for the selected network, or a local contact name"), token: z.string().min(1).optional().describe("token symbol from the address book"), - contract: Schemas.addressFor("tron") + contract: Schemas.address() .optional() - .describe("TRC20 contract address; omit with --asset-id for native TRX"), - assetId: z - .string() - .regex(/^\d+$/) - .optional() - .describe("TRC10 numeric asset id; omit with --contract for native TRX"), - feeLimit: Schemas.positiveIntString() - .default("100000000") - .describe("maximum TRX energy fee to burn for TRC20 transfers, in SUN"), + .describe("token contract address; omit with --asset-id 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", @@ -52,8 +47,10 @@ export const txSendSpec: ChainSpec = { { label: "the amount to send", flags: ["amount", "raw-amount"], select: "exactly-one" }, // omitting all three is the native-TRX path, so this set is optional as a whole. { - label: "which asset to send; omit for native TRX", - flags: ["token", "contract", "asset-id"], + // `--asset-id` is TRON-only and is declared on that binding; this group is spec-level, so + // it may only name flags every family actually has. + label: "which asset to send; omit for the network's native coin", + flags: ["token", "contract"], select: "at-most-one", }, ], @@ -67,8 +64,75 @@ export const txSendSpec: ChainSpec = { formatText: TextFormatters.txReceipt, }; +const tronSendFields = z.object({ + assetId: z + .string() + .regex(/^\d+$/) + .optional() + .describe("TRC10 numeric asset id; omit with --contract for native TRX"), + feeLimit: Schemas.positiveIntString() + .default("100000000") + .describe("maximum TRX energy fee to burn for TRC20 transfers, in SUN"), + ...tronTxModeFields, +}); + +/** EVM pricing: gwei for the per-gas fields, because that is the unit every wallet, explorer and + * human uses for gas — wei would be nine zeros longer and a real typo risk. */ +const evmSendFields = z.object({ + gasLimit: Schemas.positiveIntString() + .optional() + .describe("gas units to authorise; defaults to the node's estimate, unpadded"), + maxFee: z + .string() + .optional() + .describe("maximum total fee per gas, in gwei (EIP-1559 chains only)"), + priorityFee: z + .string() + .optional() + .describe("tip per gas paid to the proposer, in gwei (EIP-1559 chains only)"), + nonce: z.coerce + .number() + .int() + .min(0) + .optional() + .describe("transaction nonce; defaults to the account's pending nonce"), +}); + +/** EVM has no multi-signature relay, so the artifact both ends exchange is raw hex: an unsigned + * serialisation in, a signed one out. TRON's `--transaction` JSON has no EVM meaning. */ +function evmHexOnly(input: { transaction?: string; hex?: string; file?: string }): string { + if (input.transaction !== undefined) { + throw new UsageError( + "invalid_option", + "--transaction is the TRON JSON form; on an EVM network pass raw hex with --hex or --file", + ); + } + return hexInput(input); +} + +export const txSignEvmBinding = (svc: EvmTransactionService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.sign(ctx, net, evmHexOnly(input)), +}); + +export const txBroadcastEvmBinding = (svc: EvmTransactionService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.broadcast(ctx, net, evmHexOnly(input)), +}); + +export const txSendEvmBinding = (svc: EvmTransactionService): FamilyBinding => ({ + fields: evmSendFields, + refine: addressFieldsFor("evm", "contract"), + run: async (ctx, net, input) => + svc.send(ctx, net, { + ...input, + // gwei on the flag, wei everywhere below it. + ...(input.maxFee === undefined ? {} : { maxFee: gweiToWei(input.maxFee) }), + ...(input.priorityFee === undefined ? {} : { priorityFee: gweiToWei(input.priorityFee) }), + }), +}); + export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => ({ - refine: tokenOptional, + fields: tronSendFields, + refine: allRefines(tokenOptional, addressFieldsFor("tron", "contract")), run: async (ctx, net, input) => svc.send(ctx, net, input), }); @@ -387,6 +451,10 @@ export const txStatusTronBinding = (svc: TronTransactionService): FamilyBinding run: async (_ctx, net, input) => svc.status(net, input.txid), }); +export const txStatusEvmBinding = (svc: EvmTransactionService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.status(ctx, net, input.txid), +}); + const infoFields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") }); export const txInfoSpec: ChainSpec = { @@ -404,6 +472,10 @@ export const txInfoTronBinding = (svc: TronTransactionService): FamilyBinding => run: async (_ctx, net, input) => svc.info(net, input.txid), }); +export const txInfoEvmBinding = (svc: EvmTransactionService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.info(ctx, net, input.txid), +}); + function tokenOptional( value: { token?: string; contract?: string; assetId?: string }, context: z.RefinementCtx, diff --git a/ts/src/adapters/inbound/cli/commands/typed-data.test.ts b/ts/src/adapters/inbound/cli/commands/typed-data.test.ts index 5f9f5bc1e..9d0072329 100644 --- a/ts/src/adapters/inbound/cli/commands/typed-data.test.ts +++ b/ts/src/adapters/inbound/cli/commands/typed-data.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { typedDataSignSpec, typedDataSignBinding } from "./typed-data.js"; const ctx = { activeAccount: "main" } as never; -const net = { family: "tron", id: "nile", chainId: "728126428" } as never; +const net = { family: "tron", nativeSymbol: "TRX", id: "nile", chainId: "728126428" } as never; const PAYLOAD = JSON.stringify({ domain: { name: "SunPerp", version: "1", chainId: 728126428 }, diff --git a/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts index f9d7919f0..00e010061 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts @@ -149,3 +149,40 @@ describe("backup --records flag gating", () => { expect(spy.mock.results[0]!.value).toMatchObject({ pagination: { offset: 0, limit: null } }); }); }); + +// A V3 keystore holds ONE private key, and a seed account has a different one per family (§1.2 +// derives TRON at coin 195, EVM at coin 60). The selected network picks which — `family` is +// never exposed as a flag, because it is an internal concept and --network already selects it +// everywhere else in the CLI. +describe("backup --keystore exports the selected network's key", () => { + // --network reaches the shell through parseGlobals, not through argv, so it is supplied the + // way the real runner supplies it. + async function exportWith(network?: string) { + const f = fixture({ tty: true }); + await f.secrets.primePassword({ mode: "set" }); + f.keystore.import({ + secret: "test test test test test test test test test test test junk", + type: "seed", + label: "main", + }); + const spy = vi.spyOn(f.walletService, "backupKeystore").mockReturnValue({} as never); + if (network) f.shellOpts.globals.network = network; + + await buildCli(f.shellOpts).parseAsync(["backup", "main", "--keystore"]); + + return spy.mock.calls[0]!.at(-1); + } + + it.each([ + ["sepolia", "evm"], + ["nile", "tron"], + ])("exports %s's family (%s)", async (network, family) => { + expect(await exportWith(network)).toBe(family); + }); + + // No --family flag to forget, and no error either: the default network decides. The receipt + // names the family so the choice is never silent. + it("falls back to the configured default network", async () => { + expect(await exportWith()).toBe("tron"); // built-in default is tron:mainnet + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts index 9de68246f..5c0a3cf05 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts @@ -37,18 +37,23 @@ function command( if (!current || isChainCommand(current)) { throw new Error("current command missing"); } + // ExecutionContext always carries these; --qr now reads the selected network to choose which + // family's address to encode, so a fixture without them represents no real invocation. + const tronNet = { id: "tron:mainnet", family: "tron", nativeSymbol: "TRX", chainId: "mainnet", capabilities: [] }; const context = { activeAccount: options.account ?? "wlt_selected", output: options.output ?? "text", warn: vi.fn(), + network: undefined, + networkRegistry: { resolve: () => tronNet, resolveDefault: () => tronNet }, }; - return { current, context, walletService, qr }; + return { current, context, tronNet, 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 }); + const result = await fixture.current.run(fixture.context as never, fixture.tronNet as never, { qr: true }); expect(fixture.walletService.current).toHaveBeenCalledWith("wlt_selected"); expect(fixture.qr.encode).toHaveBeenCalledWith(ADDRESS); @@ -60,7 +65,7 @@ describe("current --qr", () => { 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 }); + const result = await fixture.current.run(fixture.context as never, fixture.tronNet as never, { qr: true }); expect(result).toEqual(descriptor); expect(fixture.qr.encode).not.toHaveBeenCalled(); @@ -68,9 +73,94 @@ describe("current --qr", () => { 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 }); + const result = await fixture.current.run(fixture.context as never, fixture.tronNet as never, { qr: true }); expect(result).toEqual(descriptor); expect(fixture.context.warn).toHaveBeenCalledWith(expect.stringContaining("too narrow")); }); }); + +const EVM_ADDRESS = "0xe2E1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; + +/** the same fixture, but with the network selector the QR now reads. */ +function withNetwork( + addresses: Record, + selected: string | undefined, + defaultFamily: "tron" | "evm" = "tron", +) { + const walletService = { current: vi.fn(() => ({ ...descriptor, addresses })) }; + const qr = { encode: vi.fn((a: string) => `QR(${a})`) }; + 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 net = (family: string) => ({ id: `${family}:x`, family, chainId: "x", capabilities: [] }); + const context = { activeAccount: "wlt_selected", output: "text" as const, warn: vi.fn() }; + // the shell resolves --network (else config.defaultNetwork) and hands it to run() + const network = net(selected ? (selected.startsWith("evm") ? "evm" : "tron") : defaultFamily); + return { current, context, network, qr }; +} + +// §3.8: --qr encodes the address for the SELECTED NETWORK's family. Handing someone a receive +// code for a different chain is a fund-loss shape, so this never falls back to whatever the +// account happens to have. +describe("current --qr picks the address by network family", () => { + const both = { tron: ADDRESS, evm: EVM_ADDRESS }; + + it("encodes the EVM address when an EVM network is selected", async () => { + const f = withNetwork(both, "evm:11155111"); + const result = (await f.current.run(f.context as never, f.network as never, { qr: true })) as { + receiveAddress: string; + }; + + expect(result.receiveAddress).toBe(EVM_ADDRESS); + expect(f.qr.encode).toHaveBeenCalledWith(EVM_ADDRESS); + }); + + it("encodes the TRON address when a TRON network is selected", async () => { + const f = withNetwork(both, "tron:nile"); + const result = (await f.current.run(f.context as never, f.network as never, { qr: true })) as { + receiveAddress: string; + }; + + expect(result.receiveAddress).toBe(ADDRESS); + }); + + it("uses the configured default network when --network is omitted", async () => { + const f = withNetwork(both, undefined, "evm"); + const result = (await f.current.run(f.context as never, f.network as never, { qr: true })) as { + receiveAddress: string; + }; + + expect(result.receiveAddress).toBe(EVM_ADDRESS); + }); + + it("refuses instead of falling back when the account has no address for that family", async () => { + const f = withNetwork({ evm: EVM_ADDRESS }, "tron:nile"); + + let code: string | undefined; + try { + await f.current.run(f.context as never, f.network as never, { qr: true }); + } catch (e) { + code = (e as { code?: string }).code; + } + + expect(code).toBe("family_mismatch"); + expect(f.qr.encode).not.toHaveBeenCalled(); + }); + + // The error is scoped to --qr. Looking at an account is local and must not depend on which + // network happens to be selected. + it("still shows a mismatched single-family account when --qr is absent", async () => { + const f = withNetwork({ evm: EVM_ADDRESS }, "tron:nile"); + + const result = await f.current.run(f.context as never, f.network as never, { qr: false }); + + expect(result).toMatchObject({ addresses: { evm: EVM_ADDRESS } }); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts index 1ee465995..79d465fab 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts @@ -28,7 +28,9 @@ describe("wallet import-ledger contract", () => { expect(r.success && (r.data as { index?: number }).index).toBe(2); }); - it("rejects a hidden-family app (EVM is not currently exposed)", () => { - expect(ok({ app: "ethereum", index: 0 })).toBe(false); + // Previously "rejects a hidden-family app (EVM is not currently exposed)". The app list is + // derived from FAMILIES[f].ledger, so wiring hw-app-eth exposes `--app ethereum` by itself. + it("accepts the ethereum app now that the EVM family is ledger-wired", () => { + expect(ok({ app: "ethereum", index: 0 })).toBe(true); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.test.ts index 394f65fc7..d07460192 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.test.ts @@ -397,3 +397,50 @@ describe("wallet delete", () => { ).rejects.toMatchObject({ code: "tty_required" }); }); }); + +// §3.7 filters the text listing to the selected network's family. Filtering silently would let a +// user with only an EVM Ledger run `list` on the default TRON network and see no hardware +// account at all, with nothing telling them --network exists. +describe("list reports what the family filter hid", () => { + function listCommand(accounts: unknown[], family: string) { + const registry = new CommandRegistry(); + registerWalletCommands(registry, { + walletService: { list: () => accounts } as never, + ledger: {} as never, + qr: { encode: () => null }, + }); + const list = registry.resolveNeutral(["list"]); + if (!list || isChainCommand(list)) throw new Error("list command missing"); + const warn = vi.fn(); + const net = { id: `${family}:x`, family, chainId: "x", capabilities: [] }; + return { list, warn, net }; + } + + const mixed = [ + { accountId: "a.0", type: "seed", addresses: { tron: "T1", evm: "0x1" } }, + { accountId: "b", type: "ledger", family: "evm", addresses: { evm: "0x2" } }, + { accountId: "c", type: "watch", family: "evm", addresses: { evm: "0x3" } }, + ]; + + it("warns how many accounts belong to another network", async () => { + const f = listCommand(mixed, "tron"); + await f.list.run({ warn: f.warn, output: "text" } as never, f.net as never, {}); + + expect(f.warn).toHaveBeenCalledWith(expect.stringMatching(/2 .*--network/s)); + }); + + it("says nothing when every account belongs to this network", async () => { + const f = listCommand(mixed, "evm"); + await f.list.run({ warn: f.warn, output: "text" } as never, f.net as never, {}); + + expect(f.warn).not.toHaveBeenCalled(); + }); + + // json carries every family already, so a warning there would be noise about nothing. + it("stays silent in json mode, which is not filtered", async () => { + const f = listCommand(mixed, "tron"); + await f.list.run({ warn: f.warn, output: "json" } as never, f.net as never, {}); + + expect(f.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index 514dc618c..8166da443 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -350,15 +350,41 @@ export function registerWalletCommands( // ── list ───────────────────────────────────────────────────────────────── reg.add({ path: ["list"], - network: "none", + // The network is a DISPLAY SELECTOR, not a target: no node is contacted. `wallet: "none"` + // means the resolver skips its single-family ACCOUNT check, which would otherwise refuse to + // list anything whenever the active account's family differed from the network. + network: "optional", wallet: "none", auth: "none", summary: "List wallets/accounts (no unlock needed)", + description: + "List every local account, grouped by HD seed and by type. The address column shows the " + + "family of the selected network (--network, else config.defaultNetwork); JSON output " + + "always carries every family's address.", fields: empty, input: empty, - examples: [{ cmd: "wallet-cli list --output json" }], + examples: [ + { cmd: "wallet-cli list" }, + { cmd: "wallet-cli list --network sepolia" }, + { cmd: "wallet-cli list --output json" }, + ], formatText: TextFormatters.walletList, - run: async () => wallets.list(), + run: async (context, network) => { + const accounts = wallets.list(); + // The text table is filtered to one family; say so, or a user whose only hardware account + // is on the other chain sees an empty list with no hint that --network would reveal it. + // json is unfiltered, so a warning there would be noise about nothing. + if (context.output === "text" && network) { + const hidden = accounts.filter((a) => !a.addresses[network.family]).length; + if (hidden > 0) { + context.warn( + `${hidden} account(s) have no ${network.family} address and are not shown; ` + + "use --network to switch, or --output json to see every family", + ); + } + } + return accounts; + }, } satisfies CommandDefinition); // ── use ────────────────────────────────────────────────────────────────── @@ -395,26 +421,41 @@ export function registerWalletCommands( }); reg.add({ path: ["current"], - network: "none", + // Safe now that the target resolver no longer judges the account against the network: this + // command must always be able to SHOW an account, whatever chain it lives on. The network + // only decides which family's address --qr encodes. + network: "optional", wallet: "optional", auth: "none", summary: "Show the current active account", description: - "Show the selected account locally. --qr appends a scannable TRON receive-address QR in text mode without unlocking or accessing the network.", + "Show the selected account locally, with one address line per chain family it has. --qr " + + "appends a scannable receive-address QR in text mode, for the family of the selected " + + "network (--network, else config.defaultNetwork) — 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" }, + { cmd: "wallet-cli current --qr --network sepolia" }, ], formatText: TextFormatters.walletCurrent, - run: async (context, _network, input) => { + run: async (context, network, input) => { const descriptor = wallets.current(context.activeAccount); if (!input.qr || context.output !== "text") return descriptor; - const address = descriptor.addresses.tron; + // The network is a DISPLAY SELECTOR here, not a target: this command performs no chain I/O, + // so it stays `network: "none"` and resolves lazily, only for --qr. That keeps a plain + // `current` working for an account whose family does not match the active network — you + // must always be able to look at your own account. + const address = network ? descriptor.addresses[network.family] : undefined; if (!address) { - throw new UsageError("invalid_value", "selected account has no TRON receive address"); + // Deliberately no fallback to whichever family the account does have: a receive QR for + // the wrong chain is scanned, paid into, and lost. + throw new UsageError( + "family_mismatch", + `selected account has no ${network?.family} address; ${network?.id} cannot receive to it`, + ); } const qr = services.qr?.encode(address) ?? null; if (!qr) { @@ -607,7 +648,10 @@ export function registerWalletCommands( }); reg.add({ path: ["backup"], - network: "none", + // The network selects WHICH key `--keystore` exports (a seed account holds one per family), + // not a chain to contact. Safe as "optional" because `wallet: "none"` keeps the resolver's + // single-family ACCOUNT check out of the way. + network: "optional", wallet: "none", auth: "required", interactive: true, @@ -639,7 +683,7 @@ export function registerWalletCommands( { cmd: "wallet-cli backup --records --account main --from 2026-08-01" }, ], formatText: TextFormatters.walletBackup, - run: async (ctx, _net, input) => { + run: async (ctx, network, input) => { if (input.records) { return wallets.backupRecords({ from: utcInstant(input.from), @@ -655,8 +699,17 @@ export function registerWalletCommands( mode: "verify", verify: (pw) => wallets.verifyPassword(pw), }); + // A keystore holds ONE private key, and a seed account has a different one per family + // (§1.2). The selected network picks which — `family` is never exposed as a flag; the + // network is the one selector users learn. The receipt echoes it, so an export that fell + // back to config.defaultNetwork still says out loud which key it wrote. return input.keystore - ? wallets.backupKeystore(account, input.out, ctx.secrets.read("password")) + ? wallets.backupKeystore( + account, + input.out, + ctx.secrets.read("password"), + (network ?? ctx.networkRegistry.resolveDefault()).family, + ) : wallets.backup(account, input.out); }, } satisfies CommandDefinition); diff --git a/ts/src/adapters/inbound/cli/context/context.test.ts b/ts/src/adapters/inbound/cli/context/context.test.ts index 512cbea84..614fbf80f 100644 --- a/ts/src/adapters/inbound/cli/context/context.test.ts +++ b/ts/src/adapters/inbound/cli/context/context.test.ts @@ -43,3 +43,49 @@ describe("ExecutionContext direct address target", () => { expect(ctx.resolveAddress("tron")).toBe(address); }); }); + +// The single-family guard used to live in TargetResolver, firing when a NETWORK was resolved. +// That was the wrong moment: `current` resolves one (to choose which family's QR to draw) yet +// never demands a single family's address, and was refused for a condition that did not apply to +// it. The guard now fires here — where an address is actually demanded — still before any RPC. +describe("resolveAddress on a family the account does not have", () => { + const EVM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + + function ctxForEvmWatch() { + const sm = new StreamManager("json", false, () => {}, () => {}); + const deps = { + config: { timeoutMs: 1 }, + streams: sm, + formatter: createOutputFormatter("json", sm, 0), + keystore: { + activeAccount: () => "wlt_w", + resolveAccount: () => ({ + wallet: { id: "wlt_w", source: { type: "watch", family: "evm", address: EVM } }, + index: -1, + }), + }, + } as unknown as RuntimeDeps; + return buildExecutionContext({ output: "json", verbose: false } as Globals, deps); + } + + it("reports family_mismatch rather than a bare missing address", () => { + let code: string | undefined; + try { + ctxForEvmWatch().resolveAddress("tron"); + } catch (e) { + code = (e as { code?: string }).code; + } + expect(code).toBe("family_mismatch"); + }); + + // This is the one error where the user has done nothing wrong — the account simply lives on + // another chain — so the message has to carry the way out. + it("names the account's own family and how to switch", () => { + expect(() => ctxForEvmWatch().resolveAddress("tron")).toThrow(/evm/); + expect(() => ctxForEvmWatch().resolveAddress("tron")).toThrow(/--network|defaultNetwork/); + }); + + it("still returns the address for a family the account does have", () => { + expect(ctxForEvmWatch().resolveAddress("evm")).toBe(EVM); + }); +}); diff --git a/ts/src/adapters/inbound/cli/context/index.ts b/ts/src/adapters/inbound/cli/context/index.ts index f323bfc4d..f865923c9 100644 --- a/ts/src/adapters/inbound/cli/context/index.ts +++ b/ts/src/adapters/inbound/cli/context/index.ts @@ -3,6 +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 { sourceFamily } from "../../../../domain/sources/index.js"; import type { AccountRef, ChainFamily, @@ -22,7 +23,7 @@ import type { OutputFormatter } from "../output/index.js"; import type { Prompter } from "../input/prompt/index.js"; import type { AccountStore } from "../../../../application/ports/account-store.js"; import { accountRef, walletAddress } from "../../../../domain/wallet/index.js"; -import { WalletError } from "../../../../domain/errors/index.js"; +import { UsageError, WalletError } from "../../../../domain/errors/index.js"; import { SOURCE_KINDS } from "../../../../domain/sources/index.js"; import { addressCodec, familyOf } from "../../../../domain/family/index.js"; @@ -99,8 +100,18 @@ class ExecutionContextImpl implements ExecutionContext { } 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`); + if (!address) { + // The account exists, it simply lives on another chain — the one error here where the user + // did nothing wrong, so the message carries the way out. `missing_wallet_address` would + // conflate this with "no account at all", which is a different problem with a different fix. + const own = sourceFamily(wallet.source); + throw new UsageError( + "family_mismatch", + own + ? `selected account is ${own}-only and has no ${family} address; pass --network for a ${own} network, or change defaultNetwork` + : `active account has no ${family} address`, + ); + } return address; } diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index 6d042cda6..5b45bac4b 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -102,11 +102,19 @@ interface CommandDefinitionBase { commandIdFor?: (input: I) => string; } -/** A neutral (family-less) command — wallet/config/meta operations that never receive a - * chain target. Networked commands are ChainCommandDefinitions. */ +/** + * A neutral (family-less) command — wallet/config/meta operations that are not dispatched by + * family. Networked *chain* commands are ChainCommandDefinitions. + * + * `network: "optional"` does not make it a chain command: it means the selected network is a + * DISPLAY SELECTOR (which family's address to show), not a target to act on. No node is + * contacted. Such a command must be `wallet: "none"`, or the target resolver's single-family + * ACCOUNT check applies and it would refuse to run whenever the active account's family differs + * from the network — wrong for a purely local listing. + */ export interface CommandDefinition extends CommandDefinitionBase { - network: "none"; - run(ctx: ExecutionContext, net: undefined, input: I): Promise; + network: "none" | "optional"; + run(ctx: ExecutionContext, net: NetworkDescriptor | undefined, input: I): Promise; } /** One family's slice of a chain command: how it runs + its extra flags/validation. diff --git a/ts/src/adapters/inbound/cli/globals/index.ts b/ts/src/adapters/inbound/cli/globals/index.ts index 200b40d41..a9e97d454 100644 --- a/ts/src/adapters/inbound/cli/globals/index.ts +++ b/ts/src/adapters/inbound/cli/globals/index.ts @@ -54,7 +54,7 @@ export const GLOBAL_FLAG_SPECS: readonly GlobalFlagSpec[] = [ kind: "value", valueType: "string", description: - "canonical network id, e.g. tron:mainnet, tron:nile, tron:shasta; chain commands fall back to config.defaultNetwork when omitted", + "network id or alias, e.g. nile, sepolia, bsc, or evm:11155111; falls back to config.defaultNetwork when omitted", }, { name: "account", diff --git a/ts/src/adapters/inbound/cli/help/help.test.ts b/ts/src/adapters/inbound/cli/help/help.test.ts index ea89bb1c8..403d42295 100644 --- a/ts/src/adapters/inbound/cli/help/help.test.ts +++ b/ts/src/adapters/inbound/cli/help/help.test.ts @@ -90,7 +90,9 @@ describe("shipped exclusive groups actually render", () => { it("renders both of tx send's groups, with the right requirement wording", () => { const out = optionsOf(txSendSpec); expect(out).toContain(" Exactly one of these — the amount to send:"); - expect(out).toContain(" At most one of these — which asset to send; omit for native TRX:"); + expect(out).toContain( + " At most one of these — which asset to send; omit for the network's native coin:", + ); const amount = out[out.indexOf(" Exactly one of these — the amount to send:") + 1]!; expect(amount).toContain("--amount"); expect(out[out.indexOf(" Exactly one of these — the amount to send:") + 2]).toContain( @@ -395,3 +397,87 @@ describe("Requires: master password line", () => { expect(line).not.toContain("locked"); }); }); + +// §「family 專屬 flag 在 help 裡全量展示、按族標註,不按網路裁剪」: help is STATIC — --network does +// not shape it — so both families' flags appear together and each says which family it belongs to. +describe("help tags family-specific flags", () => { + function twoFamilyHelp() { + const reg = new CommandRegistry(); + const spec = chainSpec(["tx", "send"], { + to: z.string().describe("recipient"), + amount: z.string().optional().describe("amount to send"), + }); + reg.addChain(spec, "tron", { + run: async () => ({}), + fields: z.object({ feeLimit: z.string().optional().describe("max TRX to burn") }), + }); + reg.addChain(spec, "evm", { + run: async () => ({}), + fields: z.object({ + gasLimit: z.string().optional().describe("gas units"), + maxFee: z.string().optional().describe("max fee in gwei"), + }), + }); + const stream = makeStream(); + new HelpService(reg, stream, "9.9.9").handleMeta(["tx", "send", "--help"]); + return stream.last!; + } + + it("lists both families' flags, however the network is set", () => { + const out = twoFamilyHelp(); + expect(out).toContain("--fee-limit"); + expect(out).toContain("--gas-limit"); + expect(out).toContain("--max-fee"); + }); + + it("marks each family-specific flag with its family, at the end of the line", () => { + const line = (flag: string) => + twoFamilyHelp() + .split("\n") + .find((l) => l.includes(`${flag} `) || l.trimEnd().endsWith(flag))!; + + expect(line("--fee-limit").trimEnd()).toMatch(/\(tron\)$/); + expect(line("--gas-limit").trimEnd()).toMatch(/\(evm\)$/); + expect(line("--max-fee").trimEnd()).toMatch(/\(evm\)$/); + }); + + // A flag BOTH families declare (each with its own validation) is shared, not family-specific. + it("leaves a flag declared by every family untagged", () => { + const reg = new CommandRegistry(); + const spec = chainSpec(["tx", "send"], { to: z.string().describe("recipient") }); + const shared = z.object({ memo: z.string().optional().describe("note") }); + reg.addChain(spec, "tron", { run: async () => ({}), fields: shared }); + reg.addChain(spec, "evm", { run: async () => ({}), fields: shared }); + const stream = makeStream(); + new HelpService(reg, stream, "9.9.9").handleMeta(["tx", "send", "--help"]); + + const memoLine = stream.last!.split("\n").find((l) => l.includes("--memo"))!; + expect(memoLine).not.toMatch(/\((tron|evm)\)/); + }); + + // A binding may narrow a base field for its own family; the flag still exists for everyone, + // so it stays untagged. + it("leaves a base field untagged even when one family refines it", () => { + const reg = new CommandRegistry(); + const spec = chainSpec(["tx", "send"], { to: z.string().describe("recipient") }); + reg.addChain(spec, "tron", { + run: async () => ({}), + fields: z.object({ to: z.string().min(34).describe("recipient") }), + }); + reg.addChain(spec, "evm", { run: async () => ({}) }); + const stream = makeStream(); + new HelpService(reg, stream, "9.9.9").handleMeta(["tx", "send", "--help"]); + + const toLine = stream.last!.split("\n").find((l) => l.includes("--to "))!; + expect(toLine).not.toMatch(/\((tron|evm)\)/); + }); + + // A flag every family accepts is not family-specific, and tagging it would imply a + // restriction that does not exist. + it("leaves shared flags untagged", () => { + const out = twoFamilyHelp(); + const toLine = out.split("\n").find((l) => l.includes("--to "))!; + + expect(toLine).not.toMatch(/\((tron|evm)\)/); + }); +}); diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index 9b977386a..2a44290a0 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -249,6 +249,7 @@ export class HelpService { wallet: spec.wallet, broadcasts: spec.broadcasts, fields: introspectFields(mergedFields(def)), + fieldFamilies: fieldFamilies(def), inputFlags: spec.stdin ? inputFlagsFor(spec) : [], exclusive: spec.exclusive, examples: spec.examples, @@ -268,6 +269,8 @@ export class HelpService { wallet: CommandDefinition["wallet"]; broadcasts?: boolean; fields: FieldInfo[]; + /** family-specific flags, so each can be marked with the family it belongs to. */ + fieldFamilies?: Map; inputFlags: readonly GlobalFlag[]; exclusive?: ChainSpec["exclusive"]; examples: CommandDefinition["examples"]; @@ -330,12 +333,15 @@ export class HelpService { const posNames = new Set((c.positionals ?? []).map((p) => p.field)); const flagFields = posNames.size ? c.fields.filter((f) => !posNames.has(f.name)) : c.fields; const optionRows: OptionRow[] = [ - ...flagFields.map((f) => ({ - key: f.kebab, - head: flagHead(f), - desc: f.description ?? "", - tag: flagTag(f), - })), + ...flagFields.map((f) => { + const family = c.fieldFamilies?.get(f.name); + return { + key: f.kebab, + head: flagHead(f), + desc: f.description ?? "", + tag: family ? `${flagTag(f)} (${family})` : flagTag(f), + }; + }), ...c.inputFlags.map((g) => ({ key: g.flag.replace(/^--/, ""), head: globalFlagHead(g), @@ -453,6 +459,31 @@ function mergedFields(def: ChainCommandDefinition): ZodObject { return z.object(shape); } +/** + * Which family a flag belongs to, for the flags that belong to exactly one. + * + * Help is static — `--network` does not shape it — so every family's flags are listed together + * and each says who it is for. A flag declared by more than one family, or present in + * baseFields, is shared: tagging it would imply a restriction that does not exist. + */ +function fieldFamilies(def: ChainCommandDefinition): Map { + const owners = new Map(); + for (const [family, binding] of Object.entries(def.families) as [ + ChainFamily, + ChainCommandDefinition["families"][ChainFamily], + ][]) { + for (const name of Object.keys(binding?.fields?.shape ?? {})) { + owners.set(name, [...(owners.get(name) ?? []), family]); + } + } + const shared = new Set(Object.keys(def.spec.baseFields.shape)); + return new Map( + [...owners] + .filter(([name, families]) => families.length === 1 && !shared.has(name)) + .map(([name, families]) => [name, families[0]!]), + ); +} + function metaPositionals(tokens: string[]): string[] { const valueFlags = new Set( GLOBAL_FLAGS.filter((flag) => flag.type !== "boolean").flatMap((flag) => diff --git a/ts/src/adapters/inbound/cli/input/secret/index.ts b/ts/src/adapters/inbound/cli/input/secret/index.ts index 59772d3b9..98315cfb5 100644 --- a/ts/src/adapters/inbound/cli/input/secret/index.ts +++ b/ts/src/adapters/inbound/cli/input/secret/index.ts @@ -137,6 +137,13 @@ export class SecretResolver implements ISecretResolver { mode: "set" | "verify"; verify?: (pw: string) => boolean; }): Promise { + // Already primed and still valid — reuse it. The startup migration gate primes the password + // before the command runs; without this an interactive run would prompt twice for the same + // secret. Only "verify" short-circuits: a "set" is establishing a NEW password, so it must ask. + const primed = this.#primed.get("password"); + if (plan.mode === "verify" && primed !== undefined && (plan.verify?.(primed) ?? true)) { + return; + } if (this.has("password")) { const pw = this.read("password"); if (plan.mode === "set") { diff --git a/ts/src/adapters/inbound/cli/input/secret/secret.test.ts b/ts/src/adapters/inbound/cli/input/secret/secret.test.ts index 3a0ba68ea..9f2043938 100644 --- a/ts/src/adapters/inbound/cli/input/secret/secret.test.ts +++ b/ts/src/adapters/inbound/cli/input/secret/secret.test.ts @@ -121,3 +121,28 @@ describe("clearPrimed (CP-08)", () => { expect(() => r.masterPassword()).toThrow(); // cache gone, no source → auth_required }); }); + +describe("primePassword reuses an already-primed password", () => { + // The migration gate (ADR-0008) primes the master password before the command runs. Without + // this, an interactive run prompts TWICE for the same password: once for the gate, once for + // the command. + it("does not prompt again when the password is already primed", async () => { + const backend = new Backend([PW]); // exactly ONE answer available + const r = new SecretResolver(streams(), {}, new Prompter(backend)); + + await r.primePassword({ mode: "verify", verify: (pw) => pw === PW }); + await r.primePassword({ mode: "verify", verify: (pw) => pw === PW }); + + expect(r.masterPassword()).toBe(PW); + }); + + it("re-prompts when the primed password does not satisfy the caller's check", async () => { + const OTHER = "Zyxwvu9!"; + const r = new SecretResolver(streams(), {}, new Prompter(new Backend([PW, OTHER]))); + + await r.primePassword({ mode: "verify", verify: (pw) => pw === PW }); + await r.primePassword({ mode: "verify", verify: (pw) => pw === OTHER }); + + expect(r.masterPassword()).toBe(OTHER); + }); +}); diff --git a/ts/src/adapters/inbound/cli/output/output.test.ts b/ts/src/adapters/inbound/cli/output/output.test.ts index 3c1b335e1..300439b1b 100644 --- a/ts/src/adapters/inbound/cli/output/output.test.ts +++ b/ts/src/adapters/inbound/cli/output/output.test.ts @@ -23,8 +23,8 @@ const cmd = { path: ["account", "balance"] } as unknown as CommandDefinition; const net: NetworkDescriptor = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: ["nile"], capabilities: [], }; diff --git a/ts/src/adapters/inbound/cli/render/account.ts b/ts/src/adapters/inbound/cli/render/account.ts index 98c3bb44a..4eb0e033a 100644 --- a/ts/src/adapters/inbound/cli/render/account.ts +++ b/ts/src/adapters/inbound/cli/render/account.ts @@ -5,6 +5,7 @@ import { formatScalar, formatInt, formatUsd, + formatUsdPrice, formatSun, formatTime, num, @@ -69,7 +70,7 @@ export const AccountFormatters = { const rows = holdings.map((h) => [ String(h.symbol ?? ""), h.balanceUnavailable ? "unavailable" : formatScalar(h.balance), - h.priceUsd === null || h.priceUsd === undefined ? "-" : `$${formatUsd(h.priceUsd)}`, + h.priceUsd === null || h.priceUsd === undefined ? "-" : `$${formatUsdPrice(h.priceUsd)}`, h.valueUsd === null || h.valueUsd === undefined ? "-" : `$${formatUsd(h.valueUsd)}`, ]); const total = diff --git a/ts/src/adapters/inbound/cli/render/block-render.test.ts b/ts/src/adapters/inbound/cli/render/block-render.test.ts new file mode 100644 index 000000000..48660d1b0 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/block-render.test.ts @@ -0,0 +1,64 @@ +/** + * `block` renders each family's RAW node object. + * + * The JSON contract for this command is deliberately "what the node said", so the two families + * arrive in different shapes: TRON nests the header under `block_header.raw_data` and reports + * milliseconds, while an EVM node returns a flat object of hex QUANTITY values and seconds. + * Normalizing for humans is therefore this renderer's job, and only this renderer's. + */ +import { describe, it, expect } from "vitest"; +import { TextFormatters } from "./index.js"; +import type { NetworkDescriptor } from "../../../../domain/types/index.js"; + +const ctxFor = (family: "tron" | "evm") => ({ + command: "block", + net: { family } as NetworkDescriptor, +}); + +const TRON_BLOCK = { + blockID: "0000000000abcdef", + block_header: { raw_data: { number: 1234567, timestamp: 1722925264000 } }, + transactions: [{}, {}], +}; + +const EVM_BLOCK = { + number: "0x12d687", + timestamp: "0x66b1c0d0", + hash: "0xabc", + transactions: ["0xdead", "0xbeef"], +}; + +describe("block renderer", () => { + it("reads TRON's nested header and millisecond timestamp", () => { + const out = TextFormatters.block!({ block: TRON_BLOCK }, ctxFor("tron"))!; + + expect(out).toContain("1,234,567"); + expect(out).toContain("2024-08-06 06:21:04 UTC"); + expect(out).toContain("2"); + }); + + it("decodes EVM hex quantities instead of printing them raw", () => { + const out = TextFormatters.block!({ block: EVM_BLOCK }, ctxFor("evm"))!; + + expect(out).toContain("1,234,567"); + expect(out).not.toContain("0x12d687"); + }); + + // Seconds read as milliseconds would date every EVM block to 1970. + it("treats the EVM timestamp as seconds", () => { + const out = TextFormatters.block!({ block: EVM_BLOCK }, ctxFor("evm"))!; + + expect(out).toContain("2024-08-06 06:21:04 UTC"); + expect(out).not.toContain("1970"); + }); + + it("counts EVM transactions", () => { + expect(TextFormatters.block!({ block: EVM_BLOCK }, ctxFor("evm"))!).toContain("2"); + }); + + it("says unknown rather than crashing when the chain has no such block", () => { + const out = TextFormatters.block!({ block: null }, ctxFor("evm"))!; + + expect(out).toContain("unknown"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/family-render.test.ts b/ts/src/adapters/inbound/cli/render/family-render.test.ts index c9727bed5..95850ed8d 100644 --- a/ts/src/adapters/inbound/cli/render/family-render.test.ts +++ b/ts/src/adapters/inbound/cli/render/family-render.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect } from "vitest"; -import { FAMILY_RENDER } from "./index.js"; +import { FAMILY_RENDER, renderFamily } from "./index.js"; describe("FAMILY_RENDER parity", () => { it("nativeAmount units", () => { - expect(FAMILY_RENDER.tron.nativeAmount("1000000")).toBe("1 TRX"); + expect(FAMILY_RENDER.tron.nativeAmount("1000000", "TRX")).toBe("1 TRX"); }); it("feeFallback: tron formats sun→TRX", () => { - expect(FAMILY_RENDER.tron.feeFallback("1000000")).toBe("1 TRX"); + expect(FAMILY_RENDER.tron.feeFallback("1000000", "TRX")).toBe("1 TRX"); }); it("addressLabel", () => { expect(FAMILY_RENDER.tron.addressLabel).toBe("TRON address"); @@ -17,8 +17,93 @@ describe("FAMILY_RENDER parity", () => { status: "SUCCESS", feeSun: "1000000", energyUsed: 5, - } as any); + } as any, "TRX"); expect(rows).toContainEqual(["Fee", "1 TRX"]); expect(rows.map((r) => r[0])).toContain("Energy"); }); }); + +describe("FAMILY_RENDER evm", () => { + it("renders a wei amount in ETH", () => { + expect(FAMILY_RENDER.evm.nativeAmount("1000000000000000000", "ETH")).toBe("1 ETH"); + }); + + it("renders a wei fee fallback in ETH", () => { + expect(FAMILY_RENDER.evm.feeFallback("21000000000000", "ETH")).toBe("0.000021 ETH"); + }); + + it("labels its address column for EVM", () => { + expect(FAMILY_RENDER.evm.addressLabel).toBe("EVM address"); + }); + + // The cross-cutting rule is that EVM reuses TRON's field set and only changes values and + // units — with the fee as the stated exception, because the unit is IN the field name. + it("renders gas used and the fee in ETH", () => { + const rows = FAMILY_RENDER.evm.txInfoRows({ + txid: "0xabc", + transaction: {}, + status: "confirmed", + gasUsed: 21_000, + feeWei: "441000000000000", + }, "ETH"); + const byLabel = Object.fromEntries(rows); + + expect(byLabel.Gas).toBe("21,000"); + expect(byLabel.Fee).toBe("0.000441 ETH"); + }); + + it("leaves the fee row empty rather than printing a zero fee that was never reported", () => { + const rows = FAMILY_RENDER.evm.txInfoRows({ txid: "0xabc", transaction: {} }, "ETH"); + expect(Object.fromEntries(rows).Fee).toBe(""); + }); + + // TxInfoView is a cross-family superset; each family picks only the fields it populates, so + // the EVM rows must not carry TRON's resource accounting. + it("omits TRON-only rows from its tx info", () => { + const labels = FAMILY_RENDER.evm.txInfoRows({ txid: "0xabc", transaction: {}, from: "0xa", to: "0xb", status: "confirmed" }, "ETH") + .map(([label]) => label); + + expect(labels).not.toContain("Energy"); + expect(labels).toContain("TxID"); + }); +}); + +describe("renderFamily", () => { + it("reads the family from the resolved network", () => { + expect(renderFamily({ command: "tx.info", net: { family: "evm", nativeSymbol: "ETH" } as never })).toBe("evm"); + }); + + // The old default was "tron". With one family that was unreachable; with two it silently + // renders wei amounts as TRX — a wrong-currency receipt, which is the worst way to be wrong. + // Chain commands always resolve a network before rendering, so this really is unreachable; + // the point is that if it ever happens it must be loud. + it("refuses to guess when no network was resolved", () => { + expect(() => renderFamily({ command: "tx.info" })).toThrow(); + expect(() => renderFamily(undefined)).toThrow(); + }); +}); + +// The regression this exists to prevent: FAMILY_RENDER is keyed by FAMILY, so evm:1 and evm:56 +// share one hook. With the symbol baked into the hook, 0.5 BNB on BSC rendered as "0.5 ETH". +describe("the native symbol comes from the network, not the family", () => { + it.each([ + ["evm", "ETH", "0.5 ETH"], + ["evm", "BNB", "0.5 BNB"], + ["tron", "TRX", "0.5 TRX"], + ])("renders %s/%s as %s", (family, symbol, expected) => { + const raw = family === "tron" ? "500000" : "500000000000000000"; + expect(FAMILY_RENDER[family as "tron" | "evm"].nativeAmount(raw, symbol)).toBe(expected); + }); + + it("labels a fee in the network's own coin", () => { + expect(FAMILY_RENDER.evm.feeFallback("21000000000000", "BNB")).toBe("0.000021 BNB"); + }); + + it("labels the tx-info fee row in the network's own coin", () => { + const rows = FAMILY_RENDER.evm.txInfoRows( + { txid: "0xabc", transaction: {}, feeWei: "21000000000000" }, + "BNB", + ); + expect(Object.fromEntries(rows).Fee).toBe("0.000021 BNB"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/family.ts b/ts/src/adapters/inbound/cli/render/family.ts index c6fb523f7..f8c3b2864 100644 --- a/ts/src/adapters/inbound/cli/render/family.ts +++ b/ts/src/adapters/inbound/cli/render/family.ts @@ -1,21 +1,27 @@ import type { TxInfoView } from "../../../../domain/types/index.js"; import type { TextRenderContext } from "../contracts/index.js"; import { ChainFamily } from "../../../../domain/family/index.js"; -import { formatScalar, formatInt, formatSun } from "./scalars.js"; +import { ExecutionError } from "../../../../domain/errors/index.js"; +import { formatScalar, formatInt, formatSun, formatWei } from "./scalars.js"; import { type Pair } from "./layout.js"; /** * Per-family render hooks — the one table that folds the scattered `r.family === tron ? … : …` * branches. Adding a chain = one entry here (alongside its FAMILIES + FamilyDef entries). */ +/** + * Every hook takes the native coin's `symbol` rather than baking one in. This table is keyed by + * FAMILY, so `evm:1` and `evm:56` share one entry — and their coins are ETH and BNB. A hook that + * hardcoded the symbol rendered a BNB balance as "ETH". + */ interface FamilyRenderHooks { /** the full TxInfo detail rows (family-shaped: Energy/TRX vs Gas/wei). Reads the flat * TxInfoView and picks its own family's fields — no narrowing cast (no closed union). */ - txInfoRows(r: TxInfoView): Pair[]; - /** native smallest-unit amount → display string (sun→TRX / wei). */ - nativeAmount(raw: string): string; + txInfoRows(r: TxInfoView, symbol: string): Pair[]; + /** native smallest-unit amount → display string (sun→TRX / wei→ETH or BNB). */ + nativeAmount(raw: string, symbol: string): string; /** fee fallback when no structured fee object is present. */ - feeFallback(fee: unknown): string; + feeFallback(fee: unknown, symbol: string): string; /** address-type label for the per-family address rows. */ addressLabel: string; } @@ -25,10 +31,10 @@ const txInfoAmount = (v: string | undefined, suffix: string): string => export const FAMILY_RENDER: Record = { tron: { - nativeAmount: (raw) => `${formatSun(raw)} TRX`, - feeFallback: (fee) => `${formatSun(fee)} TRX`, + nativeAmount: (raw, symbol) => `${formatSun(raw)} ${symbol}`, + feeFallback: (fee, symbol) => `${formatSun(fee)} ${symbol}`, addressLabel: "TRON address", - txInfoRows: (r) => [ + txInfoRows: (r, symbol) => [ ["TxID", r.txid], ["From", r.from ?? ""], ["To", r.to ?? ""], @@ -36,7 +42,22 @@ export const FAMILY_RENDER: Record = { ["Status", r.status ?? "unknown"], ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], ["Energy", r.energyUsed === undefined ? "" : formatInt(r.energyUsed)], - ["Fee", r.feeSun === undefined ? "" : `${formatSun(r.feeSun)} TRX`], + ["Fee", r.feeSun === undefined ? "" : `${formatSun(r.feeSun)} ${symbol}`], + ], + }, + evm: { + nativeAmount: (raw, symbol) => `${formatWei(raw)} ${symbol}`, + feeFallback: (fee, symbol) => `${formatWei(fee)} ${symbol}`, + addressLabel: "EVM address", + txInfoRows: (r, symbol) => [ + ["TxID", r.txid], + ["From", r.from ?? ""], + ["To", r.to ?? ""], + ["Amount", txInfoAmount(r.amount, r.symbol ? ` ${r.symbol}` : "")], + ["Status", r.status ?? "unknown"], + ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], + ["Gas", r.gasUsed === undefined ? "" : formatInt(r.gasUsed)], + ["Fee", r.feeWei === undefined ? "" : `${formatWei(r.feeWei)} ${symbol}`], ], }, }; @@ -45,8 +66,33 @@ export function familyAddressLabel(family: string): string { return FAMILY_RENDER[family as ChainFamily]?.addressLabel ?? `${family} address`; } -/** the active chain family for a chain-command renderer. Chain commands always resolve a network - * before running, so `ctx.net` is present; the tron fallback only guards a shape that can't occur. */ +/** + * The active chain family for a chain-command renderer. Chain commands always resolve a network + * before running, so `ctx.net` is present and this cannot legitimately fail. + * + * It throws rather than defaulting: the previous default was "tron", which was harmless while + * that was the only family and renders wei amounts as TRX now that it is not. A receipt naming + * the wrong currency is worse than no receipt. + */ +/** the selected network's native coin symbol — the one the render hooks label amounts with. */ +export function renderSymbol(ctx?: TextRenderContext): string { + const symbol = ctx?.net?.nativeSymbol; + if (!symbol) { + throw new ExecutionError( + "internal_error", + "cannot render a native amount without a resolved network", + ); + } + return symbol; +} + export function renderFamily(ctx?: TextRenderContext): ChainFamily { - return ctx?.net?.family ?? "tron"; + const family = ctx?.net?.family; + if (!family) { + throw new ExecutionError( + "internal_error", + "cannot render a chain result without a resolved network", + ); + } + return family; } diff --git a/ts/src/adapters/inbound/cli/render/misc.ts b/ts/src/adapters/inbound/cli/render/misc.ts index 1225c4269..ef76cf6ed 100644 --- a/ts/src/adapters/inbound/cli/render/misc.ts +++ b/ts/src/adapters/inbound/cli/render/misc.ts @@ -1,19 +1,21 @@ import type { TextFormatter } from "../contracts/index.js"; import { formatScalar, formatInt, formatUtc, num, methodName } from "./scalars.js"; -import { type Obj, type Pair, asObj, kv, query, receipt, table, ok } from "./layout.js"; +import { type Obj, type Pair, asObj, kv, query, receipt, table, titled, ok } from "./layout.js"; export const MiscFormatters = { config: ((data) => renderConfig(asObj(data))) satisfies TextFormatter, networks: ((data) => table( - ["Network", "Family", "Chain", "Fee model"], + ["Network", "Alias", "Family", "Chain", "Fee model", "Endpoint"], (Array.isArray(data) ? data : []) .map(asObj) .map((n) => [ String(n.id ?? ""), + String(n.alias ?? ""), String(n.family ?? ""), String(n.chainId ?? ""), String(n.feeModel ?? ""), + String(n.endpoint ?? ""), ]), )) satisfies TextFormatter, @@ -42,11 +44,17 @@ export const MiscFormatters = { ["Signature", String(d.signature ?? "")], ]); }) satisfies TextFormatter, - block: ((data) => { + // `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); const n = block.number ?? header.number; - const ts = block.timestamp ?? header.timestamp; + const raw = block.timestamp ?? header.timestamp; + // Seconds read as milliseconds would date every EVM block to 1970. + const ts = + ctx.net?.family === "evm" && raw !== undefined ? num(raw, 0) * 1000 : raw; const txs = Array.isArray(block.transactions) ? block.transactions.length : 0; return query([ ["Number", n === undefined ? "" : `#${formatInt(n)}`], @@ -97,16 +105,29 @@ function renderConfig(d: Obj): string { ["Value", configValue(d.value)], ]); } - if ("key" in d) return kv([[String(d.key), configValue(d.value)]], ""); + if ("key" in d) { + // A map-valued key (networks, aliases) gets its own titled block; a scalar stays one line. + return isMap(d.value) + ? titled(String(d.key), Object.entries(d.value).map(([k, v]) => [k, configValue(v)] as Pair)) + : kv([[String(d.key), configValue(d.value)]], ""); + } return kv( Object.entries(d).map(([k, v]) => [k, configValue(v)] as Pair), "", ); } +/** a plain object value, i.e. one of the map-valued config keys. */ +function isMap(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + /** config values keep their literal form (no thousands grouping, raw key names). */ function configValue(v: unknown): string { if (Array.isArray(v)) return v.map(String).join(", "); + // In the whole-config overview a map is summarised by its keys — listing every value would + // bury the scalar settings under 7 networks and 7 aliases. Read the key itself for detail. + if (isMap(v)) return Object.keys(v).join(", "); return v === null || v === undefined ? "" : String(v); } diff --git a/ts/src/adapters/inbound/cli/render/scalars.test.ts b/ts/src/adapters/inbound/cli/render/scalars.test.ts new file mode 100644 index 000000000..9a29cecd2 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/scalars.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { formatAmount, formatSun, formatUsd, formatUsdPrice, formatWei } from "./scalars.js"; + +// §1.4. 18 decimals laid out in full is neither readable nor meaningful, so text output caps the +// fraction — but a balance must never be shown as something it is not. +describe("formatAmount", () => { + it.each([ + ["1000000", 6, "1"], + ["1204560000", 6, "1,204.56"], + ["250000000000000000", 18, "0.25"], + ["12345600000000000000", 18, "12.3456"], + ])("renders %s at %i decimals as %s", (raw, decimals, expected) => { + expect(formatAmount(raw, decimals)).toBe(expected); + }); + + it("caps the fraction at six places", () => { + expect(formatAmount("1234567890123456789", 18)).toBe("1.234567"); + }); + + // Truncate, never round: rounding 1.9999999 up to "2" overstates a balance, and for a wallet + // an overstatement is the dangerous direction. + it("truncates rather than rounds", () => { + expect(formatAmount("1999999900000000000", 18)).toBe("1.999999"); + }); + + // The critical one: 1 wei rendered as "0" reads as an empty account. + it.each([ + ["1", 18], + ["999999999999", 18], + ])("renders a non-zero amount below display precision as <0.000001 (%s @ %i)", (raw, decimals) => { + expect(formatAmount(raw, decimals)).toBe("<0.000001"); + }); + + // The boundary: 0.000001 is exactly representable, so it prints in full. At 6 decimals one + // base unit IS 0.000001, which is why a TRON amount can never fall below display precision. + it.each([ + ["1000000000000", 18], + ["1", 6], + ])("prints the smallest representable amount in full (%s @ %i)", (raw, decimals) => { + expect(formatAmount(raw, decimals)).toBe("0.000001"); + }); + + it("still renders an actual zero as 0", () => { + expect(formatAmount("0", 18)).toBe("0"); + }); + + // §1.4: every integer part in text output is grouped, amounts included. + it("groups the integer part with thousands separators", () => { + expect(formatAmount("41004350000", 6)).toBe("41,004.35"); + expect(formatAmount("1234567000000000000000000", 18)).toBe("1,234,567"); + }); + + it("keeps the family helpers as thin wrappers", () => { + expect(formatSun("1204560000")).toBe(formatAmount("1204560000", 6)); + expect(formatWei("250000000000000000")).toBe(formatAmount("250000000000000000", 18)); + }); +}); + +// §1.4: valuations get 2 decimals, UNIT PRICES get 4. A stablecoin at $0.9998 shown as "$1.00" +// hides a depeg, and a sub-cent token would collapse to "$0.00". +describe("USD formatting", () => { + it("renders a valuation with two decimals and thousands separators", () => { + expect(formatUsd("41004.35")).toBe("41,004.35"); + expect(formatUsd("2500")).toBe("2,500.00"); + }); + + it("renders a unit price with four decimals", () => { + expect(formatUsdPrice("0.9998")).toBe("0.9998"); + expect(formatUsdPrice("2500")).toBe("2,500.0000"); + }); + + it("keeps a sub-cent price visible instead of collapsing it to zero", () => { + expect(formatUsdPrice("0.0001")).toBe("0.0001"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/scalars.ts b/ts/src/adapters/inbound/cli/render/scalars.ts index 21f131350..2e7d91777 100644 --- a/ts/src/adapters/inbound/cli/render/scalars.ts +++ b/ts/src/adapters/inbound/cli/render/scalars.ts @@ -29,15 +29,57 @@ export function formatDecimal(v: unknown): string { return `${sign}${integer!.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}${fraction}`; } +/** A USD *valuation* — always 2 decimals, per §1.4. */ export function formatUsd(v: unknown): string { + return usd(v, 2); +} + +/** + * A USD *unit price* — 4 decimals, per §1.4. Prices need the extra precision valuations do not: + * a stablecoin at $0.9998 rendered as "$1.00" hides a depeg, and a sub-cent token collapses to + * "$0.00" entirely. + */ +export function formatUsdPrice(v: unknown): string { + return usd(v, 4); +} + +function usd(v: unknown, digits: number): string { const n = Number(v); return Number.isFinite(n) - ? n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ? n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits }) : String(v ?? ""); } +/** §1.4: text output shows at most this many fractional digits, whatever the asset's precision. */ +const DISPLAY_DECIMALS = 6; +const SMALLEST_SHOWN = `0.${"0".repeat(DISPLAY_DECIMALS - 1)}1`; // 0.000001 + +/** + * Base-unit integer → the human amount text output shows (§1.4). + * + * Three rules, each protecting against a specific way of misleading a reader: + * - **Truncate, never round.** Rounding 1.9999999 to "2" OVERSTATES a balance, which is the + * dangerous direction for a wallet. Truncation only ever understates. + * - **Never print a bare "0" for a non-zero amount.** A balance of 1 wei shown as "0 ETH" + * reads as an empty account; `<0.000001` says "small", not "nothing". + * - **Group the integer part.** json keeps the exact base-unit integer; this is display only. + */ +export function formatAmount(v: unknown, decimals: number): string { + const exact = fromBaseUnits(String(v ?? "0"), decimals); + const [integer = "0", fraction = ""] = exact.split("."); + const shown = fraction.slice(0, DISPLAY_DECIMALS).replace(/0+$/, ""); + if (shown === "" && integer === "0" && /[1-9]/.test(fraction)) { + return `<${SMALLEST_SHOWN}`; + } + return formatDecimal(shown === "" ? integer : `${integer}.${shown}`); +} + export function formatSun(v: unknown): string { - return fromBaseUnits(String(v ?? "0"), 6); + return formatAmount(v, 6); +} + +export function formatWei(v: unknown): string { + return formatAmount(v, 18); } export function formatTime(v: unknown): string { diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index 681f6c7bf..9e1050a2a 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -20,7 +20,7 @@ import { methodName, } from "./scalars.js"; import { type Pair, asObj, query, receipt, ok, fail, pending, unknown } from "./layout.js"; -import { FAMILY_RENDER, renderFamily } from "./family.js"; +import { FAMILY_RENDER, renderFamily, renderSymbol } from "./family.js"; export const TxFormatters = { txReceipt: ((r, ctx?: TextRenderContext) => @@ -40,7 +40,7 @@ export const TxFormatters = { ]); }) satisfies TextFormatter, txInfo: ((r, ctx) => { - return query(FAMILY_RENDER[renderFamily(ctx)].txInfoRows(r)); + return query(FAMILY_RENDER[renderFamily(ctx)].txInfoRows(r, renderSymbol(ctx))); }) satisfies TextFormatter, }; @@ -49,11 +49,12 @@ export const TxFormatters = { * `family` in the payload, no stringly command-id matching, no alias probing. */ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { const family = renderFamily(ctx); + const symbol = renderSymbol(ctx); if (r.mode === "dry-run") { // receiptRows already states a multi-sign fee; only estimated fees need their own row here. const body = receipt(pending(), `Dry run ${actionLabel(r.kind)}`, [ ...receiptRows(r), - ...(r.multiSignFeeSun === undefined ? [["Fee", formatFee(r.fee, family)] as Pair] : []), + ...(r.multiSignFeeSun === undefined ? [["Fee", formatFee(r.fee, family, symbol)] as Pair] : []), ["Tx", summarizeTx(r.tx ?? r.transaction)], ]); // `tx broadcast --dry-run` resolves the full approval state to decide broadcastability; show @@ -64,7 +65,7 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { return ( r.hex ?? receipt(pending(), `Built ${actionLabel(r.kind)}`, [ - ["Fee", formatFee(r.fee, family)], + ["Fee", formatFee(r.fee, family, symbol)], ["Tx", summarizeTx(r.tx)], ]) ); @@ -75,13 +76,13 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { return receipt(ok(), `Signed ${actionLabel(r.kind)}`, [ ["Address", r.address ?? ""], ["TxID", String(r.txId ?? "")], - ["Fee", r.fee ? formatFee(r.fee, family) : ""], + ["Fee", r.fee ? formatFee(r.fee, family, symbol) : ""], ...signatureRows(r.signed), ]); } const txid = String(r.txId ?? r.hash ?? ""); const stage = r.stage ?? "submitted"; - const summary = receiptSummary(r, family); + const summary = receiptSummary(r, family, symbol); const pairs: Pair[] = [...receiptRows(r)]; if (txid) pairs.push(["TxID", txid]); @@ -134,7 +135,7 @@ function successStatus(kind: TxReceiptKind): string { } /** the verb-phrase summary for a broadcast receipt, by action kind. */ -function receiptSummary(r: TxReceiptView, family: ChainFamily): string { +function receiptSummary(r: TxReceiptView, family: ChainFamily, symbol: string): string { const stakeAmt = r.amountSun !== undefined ? `${formatSun(r.amountSun)} TRX` : "TRX"; const resource = r.resource ? String(r.resource) : ""; switch (r.kind) { @@ -184,7 +185,7 @@ function receiptSummary(r: TxReceiptView, family: ChainFamily): string { case "reward-withdraw": return "Withdrew voting/block rewards"; case "send": { - const amount = receiptAmount(r, family); + const amount = receiptAmount(r, family, symbol); return amount ? `Sent ${amount}` : "Sent"; } case "broadcast": @@ -387,7 +388,7 @@ function receiptRows(r: TxReceiptView): Pair[] { /** broadcast-receipt amount: token-aware (symbol/decimals when known, else the contract/asset-id * identifier for raw-amount sends), native smallest-unit → coin only when no token is involved. */ -function receiptAmount(r: TxReceiptView, family: ChainFamily): string { +function receiptAmount(r: TxReceiptView, family: ChainFamily, symbol: string): string { if (r.rawAmount !== undefined && r.rawAmount !== null && r.rawAmount !== "") { const raw = String(r.rawAmount); const isToken = r.token !== undefined || r.contract !== undefined || r.assetId !== undefined; @@ -400,7 +401,7 @@ function receiptAmount(r: TxReceiptView, family: ChainFamily): string { r.token ?? r.contract ?? (r.assetId !== undefined ? `asset ${String(r.assetId)}` : ""); return label ? `${human} ${String(label)}` : human; } - return FAMILY_RENDER[family].nativeAmount(raw); + return FAMILY_RENDER[family].nativeAmount(raw, symbol); } if (r.amountSun) return `${formatSun(r.amountSun)} TRX`; return ""; @@ -479,7 +480,7 @@ function actionLabel(kind: TxReceiptKind): string { } } -function formatFee(fee: unknown, family: ChainFamily): string { +function formatFee(fee: unknown, family: ChainFamily, symbol: string): string { if (!fee) return "unknown"; if (typeof fee === "object") { const f = asObj(fee); @@ -503,7 +504,7 @@ function formatFee(fee: unknown, family: ChainFamily): string { // a fee shape added later from silently rendering as garbage instead of failing visibly. return "unknown"; } - return FAMILY_RENDER[family].feeFallback(fee); + return FAMILY_RENDER[family].feeFallback(fee, symbol); } /** Signatures are the whole point of a sign-only receipt and the user has to copy them somewhere, diff --git a/ts/src/adapters/inbound/cli/render/wallet.ts b/ts/src/adapters/inbound/cli/render/wallet.ts index 549ebc1c7..da53f1c4b 100644 --- a/ts/src/adapters/inbound/cli/render/wallet.ts +++ b/ts/src/adapters/inbound/cli/render/wallet.ts @@ -17,8 +17,11 @@ export const WalletFormatters = { ]); }) satisfies TextFormatter, walletLedger: ((data) => renderLedgerImported(asObj(data))) satisfies TextFormatter, - walletList: ((data) => - renderWalletList(Array.isArray(data) ? data.map(asObj) : [])) satisfies TextFormatter, + walletList: ((data, ctx) => + renderWalletList( + Array.isArray(data) ? data.map(asObj) : [], + ctx?.net?.family, + )) satisfies TextFormatter, walletUse: ((data) => { const d = asObj(data); return receipt(ok(), `Active account: ${displayName(d)}`, addressPairs(d)); @@ -66,6 +69,10 @@ export const WalletFormatters = { return [ receipt(warn(), `${keystore ? "Keystore" : "Backup"} written ${String(d.out ?? "")}`, [ ["Account ID", String(d.accountId ?? "")], + // Only for a keystore: it holds ONE key, and a seed account has one per family, so the + // receipt must say which was written — the choice may have come from defaultNetwork. + // A mnemonic covers every family, so a row there would imply a choice never made. + ...(keystore && d.family ? [["Family", String(d.family)] as Pair] : []), ["Secret", secretLabel(d.secretType)], ["File mode", String(d.fileMode ?? "0600")], ["Bytes", String(d.bytes ?? "?")], @@ -125,8 +132,15 @@ function renderLedgerImported(d: Obj): string { * its accounts listed under `├─/└─` connectors as `[index] label`. Non-HD accounts group by type. * Plain text only — the text-mode frame is control-byte-stripped (CLI-OUT-001) so ANSI colour * can't survive here anyway; the active account is marked with a trailing `(active)`. */ -function renderWalletList(items: Obj[]): string { - if (items.length === 0) return "No wallets found."; +function renderWalletList(items: Obj[], family?: string): string { + // One family at a time (§3.7): showing both side by side doubles the table's width, and the + // user only cares about the chain they are on. An account with no address in this family is + // dropped rather than given an empty row — json still carries every family. + const shown = family + ? items.filter((d) => addressFor(d, family) !== undefined) + : items; + if (shown.length === 0) return "No wallets found."; + items = shown; // group seeds by their seed id (wlt_x); non-HD accounts by type. Insertion order preserved. const groups = new Map(); for (const d of items) { @@ -139,9 +153,10 @@ function renderWalletList(items: Obj[]): string { const leftOf = (d: Obj): string => d.type === "seed" ? `[${d.index ?? "?"}] ${displayName(d)}` : displayName(d); const leftW = Math.max(...items.map((d) => leftOf(d).length)); - const addrW = Math.max(...items.map((d) => firstAddress(d).length)); + const addressOf = (d: Obj): string => (family ? (addressFor(d, family) ?? "") : firstAddress(d)); + const addrW = Math.max(...items.map((d) => addressOf(d).length)); const row = (d: Obj, last: boolean): string => - `${last ? "└─ " : "├─ "}${leftOf(d).padEnd(leftW)} ${firstAddress(d).padEnd(addrW)} ${d.active ? "(active)" : ""}`.replace( + `${last ? "└─ " : "├─ "}${leftOf(d).padEnd(leftW)} ${addressOf(d).padEnd(addrW)} ${d.active ? "(active)" : ""}`.replace( /\s+$/, "", ); @@ -176,6 +191,13 @@ function addressPairs(d: Obj): Pair[] { ); } +/** this account's address in one family, or undefined when it has none there. */ +function addressFor(d: Obj, family: string): string | undefined { + const addresses = d.addresses as Record | undefined; + const value = addresses?.[family]; + return typeof value === "string" && value !== "" ? value : undefined; +} + function typeLabel(v: unknown): string { return sourceLabel(v); } diff --git a/ts/src/adapters/inbound/cli/schemas/index.ts b/ts/src/adapters/inbound/cli/schemas/index.ts index 2c8137937..9ec009d73 100644 --- a/ts/src/adapters/inbound/cli/schemas/index.ts +++ b/ts/src/adapters/inbound/cli/schemas/index.ts @@ -14,6 +14,11 @@ export const Schemas = { z .string() .refine((v) => addressCodec(family).validate(v), { message: `invalid ${family} address` }), + /** A family-neutral address flag: shape only, no format check. For a flag every family has + * (`--contract`), so it stays ONE flag in `baseFields` — help and catalog merge same-named + * family fields last-writer-wins, which would show one family's text for both. The owning + * family validates the format via `addressFieldsFor` in its binding's `refine`. */ + address: () => z.string().min(1), /** non-negative big integer as a string (wei/sun are always safe as strings). */ uintString: () => z.string().regex(/^\d+$/, "must be a non-negative integer string"), /** positive big integer as a string (rejects 0); for fee limits, lock periods, etc. */ @@ -27,3 +32,31 @@ export const Schemas = { amount: () => z.string().regex(/^\d+$/, "amount must be a non-negative integer string"), label: () => z.string().trim().min(1).max(64), }; + +/** + * Refine that validates `names` as `family` addresses — the family half of a `Schemas.address()` + * flag. Message and issue path match what `Schemas.addressFor` produced when the check lived on + * the field itself, so the error a user sees does not change with the move. + */ +export function addressFieldsFor( + family: ChainFamily, + ...names: string[] +): (value: Record, ctx: z.RefinementCtx) => void { + return (value, ctx) => { + for (const name of names) { + const candidate = value[name]; + if (typeof candidate === "string" && !addressCodec(family).validate(candidate)) { + ctx.addIssue({ code: "custom", path: [name], message: `invalid ${family} address` }); + } + } + }; +} + +/** Run several refines as one — a FamilyBinding carries a single `refine`. */ +export function allRefines( + ...refines: Array<(value: T, ctx: z.RefinementCtx) => void> +): (value: T, ctx: z.RefinementCtx) => void { + return (value, ctx) => { + for (const refine of refines) refine(value, ctx); + }; +} diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts index a5f0785d5..bbc515926 100644 --- a/ts/src/adapters/inbound/cli/shell/index.ts +++ b/ts/src/adapters/inbound/cli/shell/index.ts @@ -253,7 +253,7 @@ async function executeChainCommand( if (!binding) { const families = Object.keys(def.families).join(", "); throw new UsageError( - "network_family_mismatch", + "family_mismatch", `command ${spec.path.join(" ")} supports ${families} but selected network ${net.id} is ${net.family}`, ); } @@ -348,7 +348,7 @@ async function executeCommand( const ctx = buildExecutionContext(globals, deps); if (cmd.wallet !== "none") void ctx.activeAccount; // resolve account (default active) up front; throws missing_wallet_address if none exists - const data = await cmd.run(ctx, undefined, input); + const data = await cmd.run(ctx, net, input); // A mode-switching command may report a more precise semantic id than its path (backup.records). const resultId = cmd.commandIdFor?.(input) ?? commandId(cmd); session.current = { commandId: resultId, net }; @@ -531,7 +531,7 @@ function withFields(spec: ChainSpec, fields: ZodObject): CommandExe return { ...spec, fields }; } -function composeRefines( +export function composeRefines( fields: ZodObject, baseRefine?: (value: any, ctx: RefinementCtx) => void, familyRefine?: (value: any, ctx: RefinementCtx) => void, diff --git a/ts/src/adapters/outbound/chain/evm/evm.test.ts b/ts/src/adapters/outbound/chain/evm/evm.test.ts new file mode 100644 index 000000000..9970613cb --- /dev/null +++ b/ts/src/adapters/outbound/chain/evm/evm.test.ts @@ -0,0 +1,685 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { Transaction } from "ethers"; +import { EvmRpcClient } from "./evm.js"; + +const ADDR = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** captures the outgoing JSON-RPC request and replies with `result` */ +function stubRpc(result: unknown) { + const seen: unknown[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init: { body: string }) => { + seen.push(JSON.parse(init.body)); + return { + ok: true, + text: async () => JSON.stringify({ jsonrpc: "2.0", id: 1, result }), + }; + }), + ); + return seen; +} + +describe("EvmRpcClient.getNativeBalance", () => { + it("asks eth_getBalance for the latest block", async () => { + const seen = stubRpc("0x0"); + await new EvmRpcClient("https://node.example", 5_000).getNativeBalance(ADDR); + + expect(seen[0]).toMatchObject({ + jsonrpc: "2.0", + method: "eth_getBalance", + params: [ADDR, "latest"], + }); + }); + + // JSON-RPC speaks hex; every amount downstream is a decimal base-unit STRING, and a balance + // in wei overflows Number, so this must go through BigInt and never through parseInt. + it.each([ + ["0x0", "0"], + ["0xde0b6b3a7640000", "1000000000000000000"], + ["0xffffffffffffffffffffffff", "79228162514264337593543950335"], + ])("converts %s to the decimal wei string %s", async (hex, expected) => { + stubRpc(hex); + const balance = await new EvmRpcClient("https://node.example", 5_000).getNativeBalance(ADDR); + + expect(balance).toBe(expected); + }); + + it("surfaces a JSON-RPC error object as rpc_error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + text: async () => + JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code: -32000, message: "boom" } }), + })), + ); + + await expect( + new EvmRpcClient("https://node.example", 5_000).getNativeBalance(ADDR), + ).rejects.toMatchObject({ code: "rpc_error" }); + }); + + it("surfaces a non-200 response as rpc_error", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 429, text: async () => "" }))); + + 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", + vi.fn( + (_url: string, init: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => reject(new Error("aborted"))); + }), + ), + ); + + await expect( + new EvmRpcClient("https://node.example", 20).getNativeBalance(ADDR), + ).rejects.toThrow(); + }); +}); + +/** + * JSON-RPC carries two hex kinds (EIP-1474) and they must NOT be treated alike: + * QUANTITY — `0x1a`, a minimally-encoded number → decimal string, via BigInt. + * DATA — `0x6080…`, a byte string (hashes, addresses, code) → kept verbatim. + * Converting DATA would silently destroy it, so the conversion is per-field against each + * method's known shape, never a "looks like hex" sweep over the response. + */ +describe("EvmRpcClient QUANTITY vs DATA", () => { + it("returns a nonce as a decimal string", async () => { + const seen = stubRpc("0x2a"); + const nonce = await new EvmRpcClient("https://node.example", 5_000).getTransactionCount(ADDR); + + expect(nonce).toBe("42"); + expect(seen[0]).toMatchObject({ method: "eth_getTransactionCount", params: [ADDR, "latest"] }); + }); + + it("returns contract code as hex, untouched", async () => { + stubRpc("0x60806040"); + const code = await new EvmRpcClient("https://node.example", 5_000).getCode(ADDR); + + expect(code).toBe("0x60806040"); + }); + + it("reports an account with no code as 0x, not as the number zero", async () => { + stubRpc("0x"); + expect(await new EvmRpcClient("https://node.example", 5_000).getCode(ADDR)).toBe("0x"); + }); + + it("returns the head block height as a decimal string", async () => { + stubRpc("0x12d687"); + expect(await new EvmRpcClient("https://node.example", 5_000).getBlockNumber()).toBe("1234567"); + }); +}); + +const RPC_BLOCK = { + number: "0x12d687", + // seconds since the epoch, hex — reported as-is, unlike TRON's millisecond number. + timestamp: "0x66b1c0d0", + hash: "0xaabbccdd00000000000000000000000000000000000000000000000000000001", + parentHash: "0xaabbccdd00000000000000000000000000000000000000000000000000000000", + transactions: ["0xdead", "0xbeef"], +}; + +describe("EvmRpcClient.getBlock", () => { + it("asks for the latest block, without full transaction objects", async () => { + const seen = stubRpc(RPC_BLOCK); + await new EvmRpcClient("https://node.example", 5_000).getBlock(); + + expect(seen[0]).toMatchObject({ method: "eth_getBlockByNumber", params: ["latest", false] }); + }); + + it("asks for a specific height as a QUANTITY, not a decimal string", async () => { + const seen = stubRpc(RPC_BLOCK); + await new EvmRpcClient("https://node.example", 5_000).getBlock("1234567"); + + expect(seen[0]).toMatchObject({ params: ["0x12d687", false] }); + }); + + it("passes a block tag through unchanged", async () => { + const seen = stubRpc(RPC_BLOCK); + await new EvmRpcClient("https://node.example", 5_000).getBlock("finalized"); + + expect(seen[0]).toMatchObject({ params: ["finalized", false] }); + }); + + it("returns the node's object verbatim — hex quantities and all", async () => { + // `block` is an inspection command: the JSON contract here is "what the node said". The + // families are deliberately not aligned, so nothing is converted, renamed or dropped. + stubRpc(RPC_BLOCK); + const block = await new EvmRpcClient("https://node.example", 5_000).getBlock(); + + expect(block).toEqual(RPC_BLOCK); + }); + + it("returns null for a height the chain does not have", async () => { + stubRpc(null); + expect(await new EvmRpcClient("https://node.example", 5_000).getBlock("999999999")).toBeNull(); + }); +}); + +const TOKEN = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; +/** ABI fixtures, produced with ethers' coder rather than written from memory. */ +const ENC = { + stringUSDT: + "0x0000000000000000000000000000000000000000000000000000000000000020" + + "0000000000000000000000000000000000000000000000000000000000000004" + + "5553445400000000000000000000000000000000000000000000000000000000", + // Byte-for-byte what MKR (0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2) returns for symbol() on + // Ethereum mainnet: 32 bytes, not the 96-byte offset/length/data layout a `string` return uses. + bytes32MKR: "0x4d4b520000000000000000000000000000000000000000000000000000000000", + uint8_6: "0x0000000000000000000000000000000000000000000000000000000000000006", + uint256_1e18: `0x${(10n ** 18n).toString(16).padStart(64, "0")}`, +}; + +describe("EvmRpcClient.call", () => { + it("sends eth_call against the latest block", async () => { + const seen = stubRpc("0x"); + await new EvmRpcClient("https://node.example", 5_000).call(TOKEN, "0xdeadbeef"); + + expect(seen[0]).toMatchObject({ + method: "eth_call", + params: [{ to: TOKEN, data: "0xdeadbeef" }, "latest"], + }); + }); +}); + +describe("EvmRpcClient.getErc20Balance", () => { + it("encodes balanceOf(address) and decodes the uint256 to a decimal string", async () => { + const seen = stubRpc(ENC.uint256_1e18); + const balance = await new EvmRpcClient("https://node.example", 5_000).getErc20Balance( + TOKEN, + ADDR, + ); + + // 0x70a08231 is the balanceOf(address) selector, followed by the left-padded owner. + expect((seen[0] as { params: [{ data: string }] }).params[0].data).toBe( + "0x70a08231000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266", + ); + expect(balance).toBe("1000000000000000000"); + }); + + it("reports a non-token address as an unreadable balance rather than decoding 0x", async () => { + stubRpc("0x"); + await expect( + new EvmRpcClient("https://node.example", 5_000).getErc20Balance(TOKEN, ADDR), + ).rejects.toMatchObject({ code: "token_metadata_unavailable" }); + }); +}); + +describe("EvmRpcClient.getErc20Metadata", () => { + /** replies per selector, so one stub can serve symbol/decimals/name in one call. */ + function stubBySelector(map: Record) { + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init: { body: string }) => { + const body = JSON.parse(init.body) as { params: [{ data: string }] }; + const selector = body.params[0].data.slice(0, 10); + const hit = map[selector]; + return { + ok: true, + text: async () => + hit === undefined + ? JSON.stringify({ id: 1, error: { code: -32000, message: "execution reverted" } }) + : JSON.stringify({ id: 1, result: hit }), + }; + }), + ); + } + + it("reads a string symbol, decimals and name", async () => { + stubBySelector({ + "0x95d89b41": ENC.stringUSDT, + "0x313ce567": ENC.uint8_6, + "0x06fdde03": ENC.stringUSDT, + }); + const meta = await new EvmRpcClient("https://node.example", 5_000).getErc20Metadata(TOKEN); + + expect(meta).toMatchObject({ symbol: "USDT", decimals: 6 }); + }); + + // MKR and other early tokens declare `symbol()` as bytes32, which the string decoder rejects. + // The symbol is a label, so a legacy encoding must not cost the user the whole entry. + it("falls back to bytes32 for a legacy symbol", async () => { + stubBySelector({ "0x95d89b41": ENC.bytes32MKR, "0x313ce567": ENC.uint8_6 }); + const meta = await new EvmRpcClient("https://node.example", 5_000).getErc20Metadata(TOKEN); + + expect(meta.symbol).toBe("MKR"); + }); + + // decimals scales every human amount, so an unreadable one is reported as absent, never + // defaulted — the caller decides, and for `token add` that decision is to refuse. + it("leaves decimals undefined when the contract does not answer", async () => { + stubBySelector({ "0x95d89b41": ENC.stringUSDT }); + const meta = await new EvmRpcClient("https://node.example", 5_000).getErc20Metadata(TOKEN); + + expect(meta.symbol).toBe("USDT"); + expect(meta.decimals).toBeUndefined(); + }); + + it("never guesses a default of 18", async () => { + stubBySelector({}); + const meta = await new EvmRpcClient("https://node.example", 5_000).getErc20Metadata(TOKEN); + + expect(meta.decimals).toBeUndefined(); + expect(meta.symbol).toBeUndefined(); + }); +}); + +describe("EvmRpcClient.callFunction", () => { + it("encodes a signature and its typed parameters into calldata", async () => { + const seen = stubRpc("0x"); + await new EvmRpcClient("https://node.example", 5_000).callFunction( + TOKEN, + "balanceOf(address)", + [{ type: "address", value: ADDR }], + ); + + expect((seen[0] as { params: [{ data: string }] }).params[0].data).toBe( + `0x70a08231${"0".repeat(24)}${ADDR.slice(2).toLowerCase()}`, + ); + }); + + it("encodes a no-argument call as the bare selector", async () => { + const seen = stubRpc("0x"); + await new EvmRpcClient("https://node.example", 5_000).callFunction(TOKEN, "decimals()", []); + + expect((seen[0] as { params: [{ data: string }] }).params[0].data).toBe("0x313ce567"); + }); + + it("returns the result untouched", async () => { + const raw = `0x${(7n).toString(16).padStart(64, "0")}`; + stubRpc(raw); + + expect( + await new EvmRpcClient("https://node.example", 5_000).callFunction(TOKEN, "decimals()", []), + ).toBe(raw); + }); + + // A malformed signature or a value that does not fit its declared type must fail as bad input, + // before any request leaves the process — not as an opaque node error afterwards. + it("rejects an unparsable signature without calling the node", async () => { + const seen = stubRpc("0x"); + + await expect( + new EvmRpcClient("https://node.example", 5_000).callFunction(TOKEN, "not a signature", []), + ).rejects.toMatchObject({ code: "invalid_value" }); + expect(seen).toEqual([]); + }); + + it("rejects a value that does not fit its declared ABI type", async () => { + const seen = stubRpc("0x"); + + await expect( + new EvmRpcClient("https://node.example", 5_000).callFunction(TOKEN, "balanceOf(address)", [ + { type: "address", value: "not-an-address" }, + ]), + ).rejects.toMatchObject({ code: "invalid_value" }); + expect(seen).toEqual([]); + }); +}); + +describe("EvmRpcClient.feeData", () => { + /** replies per JSON-RPC method, so one stub serves the three reads feeData makes. */ + function stubByMethod(map: Record) { + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init: { body: string }) => { + const { method } = JSON.parse(init.body) as { method: string }; + const hit = map[method]; + return { + ok: true, + text: async () => + hit === undefined + ? JSON.stringify({ id: 1, error: { code: -32601, message: "not supported" } }) + : JSON.stringify({ id: 1, result: hit }), + }; + }), + ); + } + + it("reports base fee, gas price and the suggested tip as decimal wei", async () => { + stubByMethod({ + eth_getBlockByNumber: { baseFeePerGas: "0x940cfe0" }, + eth_gasPrice: "0x9425680", + eth_maxPriorityFeePerGas: "0x186a0", + }); + const fee = await new EvmRpcClient("https://node.example", 5_000).feeData(); + + expect(fee).toEqual({ + baseFeeWei: String(0x940cfe0), + gasPriceWei: String(0x9425680), + suggestedPriorityWei: String(0x186a0), + }); + }); + + // BSC reports a base fee of exactly zero. It must survive as "0", not collapse to undefined, + // or the fee model would read the chain as legacy. + it("keeps a zero base fee distinct from a missing one", async () => { + stubByMethod({ + eth_getBlockByNumber: { baseFeePerGas: "0x0" }, + eth_gasPrice: "0x2faf080", + eth_maxPriorityFeePerGas: "0x2faf080", + }); + + expect((await new EvmRpcClient("https://node.example", 5_000).feeData()).baseFeeWei).toBe("0"); + }); + + it("omits the base fee on a chain whose blocks carry none", async () => { + stubByMethod({ eth_getBlockByNumber: { number: "0x1" }, eth_gasPrice: "0x1" }); + const fee = await new EvmRpcClient("https://node.example", 5_000).feeData(); + + expect(fee.baseFeeWei).toBeUndefined(); + expect(fee.gasPriceWei).toBe("1"); + }); + + it("degrades the suggested tip when the endpoint does not implement it", async () => { + stubByMethod({ eth_getBlockByNumber: { baseFeePerGas: "0x10" }, eth_gasPrice: "0x20" }); + const fee = await new EvmRpcClient("https://node.example", 5_000).feeData(); + + expect(fee.suggestedPriorityWei).toBeUndefined(); + expect(fee.baseFeeWei).toBe("16"); + }); +}); + +describe("EvmRpcClient.estimateGas", () => { + it("asks eth_estimateGas and returns a decimal string", async () => { + const seen = stubRpc("0x5208"); + const gas = await new EvmRpcClient("https://node.example", 5_000).estimateGas({ + from: ADDR, + to: TOKEN, + value: "0x0", + }); + + expect(gas).toBe("21000"); + expect(seen[0]).toMatchObject({ method: "eth_estimateGas" }); + }); +}); + +/** + * Broadcasting. + * + * Acceptance is WHITE-LISTED: `eth_sendRawTransaction` answers with a transaction hash, so a + * result that is not one is a rejection. The TRON adapter learned this the expensive way — a + * blacklist test (`result === false`) never fired against error responses that simply omit the + * field, and every rejected transaction was reported as submitted. + */ +describe("EvmRpcClient.sendRawTransaction", () => { + const RAW = "0x02f8b1"; + const HASH = `0x${"ab".repeat(32)}`; + + function stubResponse(body: unknown) { + const seen: unknown[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init: { body: string }) => { + seen.push(JSON.parse(init.body)); + return { ok: true, text: async () => JSON.stringify({ id: 1, ...(body as object) }) }; + }), + ); + return seen; + } + + it("submits the raw transaction and returns the node's hash", async () => { + const seen = stubResponse({ result: HASH }); + const out = await new EvmRpcClient("https://node.example", 5_000).sendRawTransaction(RAW); + + expect(seen[0]).toMatchObject({ method: "eth_sendRawTransaction", params: [RAW] }); + expect(out).toEqual({ hash: HASH }); + }); + + it("treats a result that is not a transaction hash as a rejection", async () => { + stubResponse({ result: "ok" }); + + await expect( + new EvmRpcClient("https://node.example", 5_000).sendRawTransaction(RAW), + ).rejects.toMatchObject({ code: "transaction_rejected" }); + }); + + it("treats a missing result as a rejection rather than a success", async () => { + stubResponse({}); + + await expect( + new EvmRpcClient("https://node.example", 5_000).sendRawTransaction(RAW), + ).rejects.toMatchObject({ code: "transaction_rejected" }); + }); + + it.each([ + ["nonce too low", "nonce_too_low"], + ["insufficient funds for gas * price + value", "insufficient_balance"], + ["replacement transaction underpriced", "replacement_underpriced"], + ["intrinsic gas too low", "gas_too_low"], + ])("classifies %s as %s", async (message, code) => { + stubResponse({ error: { code: -32000, message } }); + + await expect( + new EvmRpcClient("https://node.example", 5_000).sendRawTransaction(RAW), + ).rejects.toMatchObject({ code }); + }); + + it("keeps an unrecognised rejection under transaction_rejected with the node's words", async () => { + stubResponse({ error: { code: -32000, message: "some new validator rule" } }); + + await expect( + new EvmRpcClient("https://node.example", 5_000).sendRawTransaction(RAW), + ).rejects.toMatchObject({ code: "transaction_rejected" }); + }); + + // "already known" means the transaction is ALREADY in the mempool: the user's intent is + // satisfied, and reporting a failure would deny a fact that already holds. Re-running the same + // command must not turn a submitted transaction into an error. + it.each(["already known", "ALREADY KNOWN", "transaction already exists"])( + "treats %s as an accepted submission", + async (message) => { + stubResponse({ error: { code: -32000, message } }); + const out = await new EvmRpcClient("https://node.example", 5_000).sendRawTransaction(RAW); + + expect(out.alreadyKnown).toBe(true); + expect(out.hash).toBeUndefined(); + }, + ); +}); + +describe("EvmRpcClient.getTransactionReceipt", () => { + it("returns null while the transaction is still pending", async () => { + stubRpc(null); + expect( + await new EvmRpcClient("https://node.example", 5_000).getTransactionReceipt("0xabc"), + ).toBeNull(); + }); + + // A receipt is NOT proof of success: status 0x0 is a transaction that was mined, paid gas, and + // reverted. Reporting that as confirmed would be the worst lie this CLI could tell. + it("reports a reverted transaction as failed, not confirmed", async () => { + stubRpc({ status: "0x0", gasUsed: "0x5208", effectiveGasPrice: "0x3b9aca00", blockNumber: "0x10" }); + const r = await new EvmRpcClient("https://node.example", 5_000).getTransactionReceipt("0xabc"); + + expect(r).toMatchObject({ success: false, gasUsed: "21000", blockNumber: 16 }); + }); + + it("reports a successful transaction with its realised fee", async () => { + stubRpc({ status: "0x1", gasUsed: "0x5208", effectiveGasPrice: "0x3b9aca00", blockNumber: "0x10" }); + const r = await new EvmRpcClient("https://node.example", 5_000).getTransactionReceipt("0xabc"); + + // feeWei is gasUsed × effectiveGasPrice — what was actually paid, not the ceiling. + expect(r).toMatchObject({ success: true, feeWei: String(21000n * 1000000000n) }); + }); + + it("carries the deployed contract address when the receipt names one", async () => { + stubRpc({ status: "0x1", gasUsed: "0x1", contractAddress: "0xdead", blockNumber: "0x1" }); + const r = await new EvmRpcClient("https://node.example", 5_000).getTransactionReceipt("0xabc"); + + expect(r?.contractAddress).toBe("0xdead"); + }); +}); + +describe("EvmRpcClient.encodeErc20Transfer", () => { + it("encodes transfer(address,uint256) with the recipient and base-unit amount", () => { + const data = new EvmRpcClient("https://node.example", 5_000).encodeErc20Transfer( + ADDR, + "5000000", + ); + + // 0xa9059cbb = transfer(address,uint256); then the padded recipient, then the amount. + expect(data).toBe( + `0xa9059cbb${"0".repeat(24)}${ADDR.slice(2).toLowerCase()}${(5000000n) + .toString(16) + .padStart(64, "0")}`, + ); + }); + + it("rejects a recipient that is not an address rather than encoding nonsense", () => { + expect(() => + new EvmRpcClient("https://node.example", 5_000).encodeErc20Transfer("nope", "1"), + ).toThrow(); + }); +}); + +describe("EvmRpcClient.broadcast (Broadcaster port)", () => { + it("submits the raw half of a signed transaction and echoes its hash", async () => { + const HASH = `0x${"cd".repeat(32)}`; + const seen = stubRpc(HASH); + const out = await new EvmRpcClient("https://node.example", 5_000).broadcast({ + raw: "0x02f8b1", + hash: HASH, + }); + + expect(seen[0]).toMatchObject({ method: "eth_sendRawTransaction", params: ["0x02f8b1"] }); + expect(out).toMatchObject({ hash: HASH }); + }); + + it("reports an already-known submission without inventing a hash", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + text: async () => JSON.stringify({ id: 1, error: { code: -32000, message: "already known" } }), + })), + ); + const out = await new EvmRpcClient("https://node.example", 5_000).broadcast({ + raw: "0x02f8b1", + hash: `0x${"11".repeat(32)}`, + }); + + // The locally derived hash still identifies the transaction; the node just had it already. + expect(out.alreadyKnown).toBe(true); + expect(out.hash).toBeUndefined(); + }); + + it("refuses a signed transaction that carries no raw serialisation", async () => { + await expect( + new EvmRpcClient("https://node.example", 5_000).broadcast("0x02f8b1" as never), + ).rejects.toMatchObject({ code: "invalid_transaction" }); + }); +}); + +/** + * `tx send --build-only` produces the artifact `tx sign --hex` consumes, so the two must agree on + * one serialisation: unsigned in, signed out. + */ +describe("EvmRpcClient.encodeTransactionHex", () => { + const client = () => new EvmRpcClient("https://node.example", 5_000); + const UNSIGNED_TX = { + type: 2, + chainId: 11155111, + nonce: 0, + to: "0x000000000000000000000000000000000000dEaD", + value: "1000000000000000", + gasLimit: "21000", + maxFeePerGas: "2034533506", + maxPriorityFeePerGas: "1000000", + }; + + it("serialises an unsigned transaction so tx sign can read it back", () => { + const hex = client().encodeTransactionHex(UNSIGNED_TX); + + expect(hex.startsWith("0x02")).toBe(true); + // round-trips through the same parser tx sign uses + expect(Transaction.from(hex).signature).toBeNull(); + expect(Transaction.from(hex).nonce).toBe(0); + }); + + it("serialises an already-signed transaction as its signed form", () => { + const signed = { + raw: "0x02f87383aa36a780830f424084793b5e8282520894000000000000000000000000000000000000dead87038d7ea4c6800080c001a02958ee6a65975b5f6c2067d08704bc367375ee3fd54f1a0b4cbbc2643ab6b95ca0044e8cb5dea54b08c8b43b68a842e75e4f6627caa3911e4f9e5119ca12c01fc9", + hash: "0x6bfa290e4749ac903192c155d9b0f534ec9a8c8ab9dbb55bd155a91e3c0d7026", + }; + + expect(client().encodeTransactionHex(signed)).toBe(signed.raw); + }); + + it("refuses something that is not a transaction", () => { + expect(() => client().encodeTransactionHex({ to: "not-an-address" })).toThrow(); + }); +}); + +describe("EvmRpcClient contract-write encoding", () => { + const client = () => new EvmRpcClient("https://node.example", 5_000); + + it("encodes a call without sending it", () => { + const data = client().encodeFunctionCall("transfer(address,uint256)", [ + { type: "address", value: ADDR }, + { type: "uint256", value: "5" }, + ]); + + expect(data.startsWith("0xa9059cbb")).toBe(true); + expect(data).toHaveLength(2 + 8 + 128); + }); + + it("appends ABI-encoded constructor arguments to the bytecode", () => { + const abi = JSON.stringify([{ type: "constructor", inputs: [{ type: "uint256", name: "x" }] }]); + const data = client().encodeDeploy("0x6080", abi, [7]); + + expect(data).toBe(`0x6080${(7n).toString(16).padStart(64, "0")}`); + }); + + it("accepts bare bytecode without a 0x prefix", () => { + const abi = JSON.stringify([{ type: "constructor", inputs: [] }]); + expect(client().encodeDeploy("6080", abi, [])).toBe("0x6080"); + }); + + it("rejects constructor arguments that do not match the ABI", () => { + const abi = JSON.stringify([{ type: "constructor", inputs: [{ type: "address", name: "a" }] }]); + expect(() => client().encodeDeploy("0x6080", abi, ["not-an-address"])).toThrow(); + }); + + // CREATE derives the address from the sender and nonce alone, so it is known the moment the + // transaction is signed — no need to wait for a receipt to tell the user where it landed. + it("derives the CREATE address from sender and nonce", () => { + // ethers' own getCreateAddress is the reference; this asserts the wiring, not the algorithm. + const addr = client().contractAddressFor(ADDR, "0"); + + expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(client().contractAddressFor(ADDR, "1")).not.toBe(addr); + }); +}); + +describe("EvmRpcClient.getTransactionByHash", () => { + it("returns the node's transaction object", async () => { + const seen = stubRpc({ hash: "0xabc", input: "0x", value: "0x0" }); + const tx = await new EvmRpcClient("https://node.example", 5_000).getTransactionByHash("0xabc"); + + expect(seen[0]).toMatchObject({ method: "eth_getTransactionByHash", params: ["0xabc"] }); + expect(tx).toMatchObject({ hash: "0xabc" }); + }); + + // null means "this node has no record of it" — which is NOT the same as "it never existed", + // and the two are told apart by the caller, not here. + it("returns null when the node has no record of the hash", async () => { + stubRpc(null); + expect( + await new EvmRpcClient("https://node.example", 5_000).getTransactionByHash("0xabc"), + ).toBeNull(); + }); +}); diff --git a/ts/src/adapters/outbound/chain/evm/evm.ts b/ts/src/adapters/outbound/chain/evm/evm.ts new file mode 100644 index 000000000..dbd5617fa --- /dev/null +++ b/ts/src/adapters/outbound/chain/evm/evm.ts @@ -0,0 +1,478 @@ +/** + * EvmRpcClient — the EVM family's gateway, speaking JSON-RPC over HTTP. + * + * Deliberately a thin client rather than an ethers Provider: a CLI makes one-shot calls and + * exits, so the polling, network auto-detection and event machinery a Provider brings would be + * cost without benefit. This mirrors the TRON adapter's plain `fetch` + `AbortSignal.timeout`. + */ +import { + Interface, + Transaction, + getCreateAddress, + toUtf8String, + type TransactionLike, +} from "ethers"; +import { ChainError } from "../../../../domain/errors/index.js"; +import { classifyEvmRejection, isAlreadyKnown } from "./node-errors.js"; +import type { EvmGateway } from "../../../../application/ports/chain/gateway-provider.js"; + +interface JsonRpcResponse { + result?: unknown; + error?: { code: number; message: string }; +} + +export class EvmRpcClient implements EvmGateway { + #id = 0; + + constructor( + private readonly endpoint: string, + private readonly timeoutMs = 60_000, + ) {} + + async getNativeBalance(address: string): Promise { + return toDecimalString(await this.#call("eth_getBalance", [address, "latest"])); + } + + /** the account's nonce — a QUANTITY. */ + async getTransactionCount(address: string, block: "latest" | "pending" = "latest"): Promise { + return toDecimalString(await this.#call("eth_getTransactionCount", [address, block])); + } + + /** deployed bytecode — DATA, so it stays hex. `0x` means "no code": an ordinary account. */ + async getCode(address: string): Promise { + return toData(await this.#call("eth_getCode", [address, "latest"])); + } + + async getBlockNumber(): Promise { + return toDecimalString(await this.#call("eth_blockNumber", [])); + } + + /** + * The node's block object, verbatim — hex QUANTITY values, second-resolution timestamp and + * all. `block` is an inspection command, so fidelity to what the node said beats a tidier + * shape; the families are deliberately NOT aligned here, and the text renderer is what makes + * each one readable. + * + * `numberOrTag` is the one thing that is translated, because the RPC will not accept anything + * else: a decimal height becomes a QUANTITY, while a tag ("latest", "finalized", "safe") goes + * through untouched. Resolves to null when the chain has no such block rather than throwing — + * callers asking for "finalized" on a chain that does not serve it need a value to degrade on. + */ + async getBlock(numberOrTag?: string): Promise { + const target = + numberOrTag === undefined + ? "latest" + : /^\d+$/.test(numberOrTag) + ? `0x${BigInt(numberOrTag).toString(16)}` + : numberOrTag; + return (await this.#call("eth_getBlockByNumber", [target, false])) ?? null; + } + + /** false when the node is in sync; an object of progress counters while it catches up. */ + async syncing(): Promise { + return this.#call("eth_syncing", []); + } + + /** connected peers — a QUANTITY. Most hosted endpoints do not expose this and will error. */ + async peerCount(): Promise { + return toDecimalString(await this.#call("net_peerCount", [])); + } + + /** + * The three numbers the fee model needs, as decimal wei. + * + * `baseFeeWei` is absent only when the block genuinely carries no `baseFeePerGas`. A base fee of + * ZERO must survive as "0": BSC reports exactly that, and collapsing it to undefined would make + * the fee model read the chain as legacy. + * + * The suggested tip is optional — not every endpoint implements `eth_maxPriorityFeePerGas` — + * so a refusal degrades that one field instead of failing the read. + */ + async feeData(): Promise<{ + baseFeeWei?: string; + gasPriceWei: string; + suggestedPriorityWei?: string; + }> { + const [head, gasPrice, priority] = await Promise.all([ + this.#call("eth_getBlockByNumber", ["latest", false]), + this.#call("eth_gasPrice", []), + this.#call("eth_maxPriorityFeePerGas", []).catch(() => undefined), + ]); + const baseFee = (head as Record | null)?.baseFeePerGas; + return { + ...(baseFee === undefined || baseFee === null + ? {} + : { baseFeeWei: toDecimalString(baseFee) }), + gasPriceWei: toDecimalString(gasPrice), + ...(priority === undefined ? {} : { suggestedPriorityWei: toDecimalString(priority) }), + }; + } + + /** the node's gas estimate for a transaction, as a decimal string. */ + async estimateGas(tx: Record): Promise { + return toDecimalString(await this.#call("eth_estimateGas", [tx])); + } + + /** + * Submit a signed transaction. + * + * Acceptance is WHITE-LISTED: `eth_sendRawTransaction` answers with a 32-byte transaction hash, + * so anything else — a different shape, a missing result, an error object — is a rejection. + * The TRON adapter learned this the hard way: a blacklist test never fired against responses + * that simply omit the field, and every rejected transaction was reported as submitted. + * + * The one rejection that is not a failure is "already known": the transaction is already in the + * mempool, so the submission succeeded earlier and re-running the command must not turn a + * standing fact into an error. + */ + async sendRawTransaction(raw: string): Promise<{ hash?: string; alreadyKnown?: boolean }> { + const body = await this.#send("eth_sendRawTransaction", [raw]); + if (body.error) { + const message = body.error.message ?? ""; + if (isAlreadyKnown(message)) return { alreadyKnown: true }; + const known = classifyEvmRejection(message); + throw new ChainError( + known?.code ?? "transaction_rejected", + known?.message ?? `EVM broadcast rejected: ${message}`, + { nodeMessage: message }, + ); + } + if (typeof body.result !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(body.result)) { + throw new ChainError( + "transaction_rejected", + `EVM broadcast returned no transaction hash: ${JSON.stringify(body.result ?? null)}`, + ); + } + return { hash: body.result }; + } + + /** + * The mined receipt, or null while the transaction is still pending. + * + * `success` comes from `status`, NOT from the receipt existing: `status: "0x0"` is a transaction + * that was mined, paid for its gas, and reverted. `feeWei` is what was actually paid + * (gasUsed × effectiveGasPrice), not the ceiling the transaction authorised. + */ + async getTransactionReceipt(hash: string): Promise | null> { + const raw = await this.#call("eth_getTransactionReceipt", [hash]); + if (raw === null || typeof raw !== "object") return null; + const r = raw as Record; + const gasUsed = r.gasUsed === undefined ? undefined : BigInt(String(r.gasUsed)); + const price = + r.effectiveGasPrice === undefined ? undefined : BigInt(String(r.effectiveGasPrice)); + return { + success: r.status === "0x1", + ...(gasUsed === undefined ? {} : { gasUsed: gasUsed.toString(10) }), + ...(gasUsed !== undefined && price !== undefined + ? { feeWei: (gasUsed * price).toString(10) } + : {}), + ...(r.blockNumber === undefined ? {} : { blockNumber: Number(BigInt(String(r.blockNumber))) }), + ...(r.contractAddress === undefined || r.contractAddress === null + ? {} + : { contractAddress: r.contractAddress }), + raw, + }; + } + + /** the JSON-RPC envelope, unthrown — callers that classify errors themselves need to see it. */ + async #send(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}`); + } + return JSON.parse(await response.text()) as JsonRpcResponse; + } + + /** calldata for `transfer(address,uint256)`; the amount is already in the token's base units. */ + encodeErc20Transfer(to: string, rawAmount: string): string { + try { + return ERC20_WRITE.encodeFunctionData("transfer", [to, BigInt(rawAmount)]); + } catch (e) { + throw new ChainError( + "invalid_value", + `could not encode an ERC-20 transfer: ${(e as Error).message}`, + ); + } + } + + /** + * The Broadcaster port. A signed EVM transaction is `{ raw, hash }`; only `raw` goes on the + * wire. The hash is not read back from here — the pipeline prefers the locally derived one + * (see `authoritativeTxId`), which is the whole reason the signer carries it. + */ + async broadcast(signed: unknown): Promise> { + const raw = (signed as { raw?: unknown })?.raw; + if (typeof raw !== "string" || raw === "") { + throw new ChainError( + "invalid_transaction", + "a signed EVM transaction must carry its raw serialisation", + ); + } + return this.sendRawTransaction(raw); + } + + /** + * Serialise a transaction to the hex `tx sign --hex` and `tx broadcast --hex` exchange. + * + * An unsigned transaction serialises to its unsigned form and a signed one to its signed form, + * so `tx send --build-only` produces exactly what `tx sign` reads back. A signed transaction + * arrives as `{ raw, hash }` and its `raw` is already that serialisation. + */ + encodeTransactionHex(tx: unknown): string { + const raw = (tx as { raw?: unknown })?.raw; + if (typeof raw === "string" && raw !== "") return raw; + try { + const transaction = Transaction.from(tx as TransactionLike); + return transaction.signature ? transaction.serialized : transaction.unsignedSerialized; + } catch (e) { + throw new ChainError( + "invalid_transaction", + `EVM transaction could not be serialised: ${(e as Error).message}`, + ); + } + } + + /** calldata for a `{type, value}` call, without sending it — the write half of callFunction. */ + encodeFunctionCall( + signature: string, + params: Array<{ type: string; value: unknown }>, + ): string { + try { + const iface = new Interface([`function ${signature}`]); + return 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}`, + ); + } + } + + /** deployment calldata: the creation bytecode with the constructor's ABI-encoded arguments. */ + encodeDeploy(bytecode: string, abiJson: string, params: unknown[]): string { + let encodedArgs = ""; + try { + const iface = new Interface(JSON.parse(abiJson)); + encodedArgs = iface.encodeDeploy(params).replace(/^0x/, ""); + } catch (e) { + throw new ChainError( + "invalid_value", + `could not encode the constructor arguments: ${(e as Error).message}`, + ); + } + return `0x${bytecode.replace(/^0x/, "")}${encodedArgs}`; + } + + /** + * Where a CREATE deployment will land. Derived from the sender and nonce alone, so it is known + * the moment the transaction is signed — the user does not have to wait for a receipt to learn + * the address, and when a receipt does arrive the two can be compared. + */ + contractAddressFor(from: string, nonce: string): string { + try { + return getCreateAddress({ from, nonce: Number(nonce) }); + } catch (e) { + throw new ChainError( + "invalid_value", + `could not derive the contract address: ${(e as Error).message}`, + ); + } + } + + /** + * The node's transaction object, or null when this node has no record of the hash. + * + * Null is deliberately ambiguous here: it covers "never existed", "still propagating" and + * "this node pruned it". Distinguishing those is the caller's job, because only the caller + * knows what other evidence it has. + */ + async getTransactionByHash(hash: string): Promise | null> { + const raw = await this.#call("eth_getTransactionByHash", [hash]); + return raw === null || typeof raw !== "object" ? null : (raw as Record); + } + + async clientVersion(): Promise { + return String(await this.#call("web3_clientVersion", [])); + } + + /** + * A read-only call named by its signature, with `{type, value}` parameters — the same input + * shape the TRON family takes, encoded here rather than in a use case because ABI encoding is + * a wire-format concern (TronWeb does the same job inside the TRON adapter). + * + * A bad signature or a value that does not fit its declared type fails as `invalid_value` + * before any request is sent, rather than as an opaque node error afterwards. + */ + async callFunction( + contract: string, + 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); + } + + /** a read-only contract call; `data` and the result are both DATA, so both stay hex. */ + async call(to: string, data: string): Promise { + return toData(await this.#call("eth_call", [{ to, data }, "latest"])); + } + + async getErc20Balance(contract: string, owner: string): Promise { + const raw = await this.call(contract, ERC20.encodeFunctionData("balanceOf", [owner])); + // An address with no code returns empty rather than reverting, so "0x" here means "this is + // not a token contract", not "the balance is zero". + if (raw === "0x" || raw === "") { + throw new ChainError( + "token_metadata_unavailable", + `${contract} did not answer balanceOf — it may not be a token contract`, + ); + } + return (ERC20.decodeFunctionResult("balanceOf", raw)[0] as bigint).toString(10); + } + + /** + * Best-effort ERC-20 metadata. Each field is read independently and a field the contract does + * not answer comes back undefined — never defaulted. `decimals` in particular scales every + * human-entered amount, so inventing 18 for a contract that stayed silent would quietly + * misprice transfers; the caller decides what to do about the gap. + */ + async getErc20Metadata( + contract: string, + ): Promise<{ symbol?: string; decimals?: number; name?: string }> { + const [symbol, decimals, name] = await Promise.all([ + this.#text(contract, "symbol"), + this.#decimals(contract), + this.#text(contract, "name"), + ]); + return { + ...(symbol === undefined ? {} : { symbol }), + ...(decimals === undefined ? {} : { decimals }), + ...(name === undefined ? {} : { name }), + }; + } + + /** `symbol()`/`name()` as string, falling back to the bytes32 form early tokens (MKR) use. */ + async #text(contract: string, fn: "symbol" | "name"): Promise { + let raw: string; + try { + raw = await this.call(contract, ERC20.encodeFunctionData(fn, [])); + } catch { + return undefined; + } + if (raw === "0x" || raw === "") return undefined; + try { + return ERC20.decodeFunctionResult(fn, raw)[0] as string; + } catch { + try { + return decodeBytes32(raw); + } catch { + return undefined; + } + } + } + + async #decimals(contract: string): Promise { + try { + const raw = await this.call(contract, ERC20.encodeFunctionData("decimals", [])); + if (raw === "0x" || raw === "") return undefined; + return Number(ERC20.decodeFunctionResult("decimals", raw)[0]); + } catch { + return undefined; + } + } + + 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; + if (body.error) { + throw new ChainError("rpc_error", `${method} failed: ${body.error.message}`); + } + return body.result; + } +} + +/** + * 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. + */ +function toDecimalString(hex: unknown): string { + if (typeof hex !== "string") { + throw new ChainError("rpc_error", `expected a hex quantity, got ${typeof hex}`); + } + return BigInt(hex).toString(10); +} + +/** + * The other half of the EIP-1474 split. DATA is a byte string — a hash, an address, bytecode — + * so it is carried through verbatim. Running it through `toDecimalString` would turn a 32-byte + * hash into a meaningless integer, which is why the conversion is chosen per field rather than + * inferred from the value looking hex-ish. + */ +function toData(value: unknown): string { + if (typeof value !== "string") { + throw new ChainError("rpc_error", `expected hex data, got ${typeof value}`); + } + return value; +} + +/** + * The minimal ERC-20 read surface. ethers owns the ABI encoding here for the same reason it owns + * the transaction and typed-data encoding elsewhere: it is specification-heavy work that never + * touches a private key. Offsets, dynamic types and selectors are exactly what not to hand-roll. + */ +const ERC20 = new Interface([ + "function balanceOf(address) view returns (uint256)", + "function symbol() view returns (string)", + "function decimals() view returns (uint8)", + "function name() view returns (string)", +]); + +/** the pre-standard `bytes32` spelling of symbol()/name(): fixed width, NUL-padded on the right. */ +function decodeBytes32(raw: string): string { + const text = toUtf8String(`0x${raw.replace(/^0x/, "").slice(0, 64).replace(/(00)+$/, "")}`); + if (text === "") throw new ChainError("rpc_error", "empty bytes32 text"); + return text; +} + +/** the write half of the ERC-20 surface; kept separate so the read interface stays read-only. */ +const ERC20_WRITE = new Interface(["function transfer(address,uint256) returns (bool)"]); diff --git a/ts/src/adapters/outbound/chain/evm/node-errors.ts b/ts/src/adapters/outbound/chain/evm/node-errors.ts new file mode 100644 index 000000000..73a6112e8 --- /dev/null +++ b/ts/src/adapters/outbound/chain/evm/node-errors.ts @@ -0,0 +1,50 @@ +/** + * Mapping an EVM node's rejection text to a stable error code. + * + * The JSON-RPC spec fixes no codes for these, and clients word them differently, so the match is + * on substrings of the message. An unmatched rejection keeps the node's own words rather than + * being forced into a category that might be wrong. + */ +export interface EvmRejection { + code: string; + message: string; +} + +const PATTERNS: Array<[RegExp, string, string]> = [ + [/nonce too low|nonce is too low/i, "nonce_too_low", "nonce already used; the account has moved on"], + [/nonce too high/i, "nonce_too_high", "nonce is ahead of the account; an earlier transaction is missing"], + [ + /insufficient funds/i, + "insufficient_balance", + "the account cannot cover the transaction value plus its maximum fee", + ], + [ + /replacement transaction underpriced|replacement fee too low/i, + "replacement_underpriced", + "replacing a pending transaction needs a higher fee than the one it replaces", + ], + [ + /intrinsic gas too low|gas limit (is )?too low|out of gas/i, + "gas_too_low", + "the gas limit is below what this transaction needs", + ], + [ + /transaction underpriced|fee cap less than block base fee|max fee per gas less than block base fee/i, + "fee_too_low", + "the fee is below what the network is currently accepting", + ], + [/exceeds block gas limit/i, "gas_limit_exceeded", "the gas limit exceeds the block gas limit"], +]; + +/** `already known` / `known transaction`: the transaction is ALREADY in the mempool, so the + * submission succeeded earlier. Reporting a failure would deny something that already holds. */ +export function isAlreadyKnown(message: string): boolean { + return /already known|known transaction|already exists|transaction already in pool/i.test(message); +} + +export function classifyEvmRejection(message: string): EvmRejection | undefined { + for (const [pattern, code, text] of PATTERNS) { + if (pattern.test(message)) return { code, message: text }; + } + return undefined; +} diff --git a/ts/src/adapters/outbound/chain/evm/signing-strategy.test.ts b/ts/src/adapters/outbound/chain/evm/signing-strategy.test.ts new file mode 100644 index 000000000..2222134f0 --- /dev/null +++ b/ts/src/adapters/outbound/chain/evm/signing-strategy.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from "vitest"; +import { Transaction, TypedDataEncoder, keccak256, verifyMessage, verifyTypedData } from "ethers"; +import { localTxId } from "../../../../application/services/broadcast-identity.js"; +import { evmSignStrategy } from "./signing-strategy.js"; + +// Anvil / Hardhat account #0 — a published key/address pair. +const PK = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +describe("evmSignStrategy.signMessage (EIP-191)", () => { + // Verified by RECOVERING the signer with an independent implementation, rather than against a + // signature string copied from somewhere — that catches a wrong digest, a wrong v, and a + // malleable s all at once. + it.each([ + ["ascii", "hello world"], + ["empty", ""], + ["unicode", "日本語 🎉"], + ["multiline", "line one\nline two"], + ])("produces a signature recoverable to the signer (%s)", async (_label, message) => { + const signature = await evmSignStrategy.signMessage(PK, message); + + expect(verifyMessage(message, signature)).toBe(ADDRESS); + }); + + it("returns a 65-byte 0x signature", async () => { + const signature = await evmSignStrategy.signMessage(PK, "hello world"); + expect(signature).toMatch(/^0x[0-9a-f]{130}$/); + }); +}); + +// The canonical EIP-712 example from the specification itself. +const DOMAIN = { + name: "Ether Mail", + version: "1", + chainId: 1, + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", +}; +const MAIL_TYPES = { + Person: [ + { name: "name", type: "string" }, + { name: "wallet", type: "address" }, + ], + Mail: [ + { name: "from", type: "Person" }, + { name: "to", type: "Person" }, + { name: "contents", type: "string" }, + ], +}; +const MAIL = { + from: { name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" }, + to: { name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" }, + contents: "Hello, Bob!", +}; + +describe("evmSignStrategy.signTypedData (EIP-712)", () => { + it("produces a signature recoverable to the signer", async () => { + const { signature } = await evmSignStrategy.signTypedData(PK, { + domain: DOMAIN, + types: MAIL_TYPES, + message: MAIL, + }); + + expect(verifyTypedData(DOMAIN, MAIL_TYPES, MAIL, signature)).toBe(ADDRESS); + }); + + it("reports the digest that was actually signed", async () => { + const { digest } = await evmSignStrategy.signTypedData(PK, { + domain: DOMAIN, + types: MAIL_TYPES, + message: MAIL, + }); + + expect(digest).toBe(TypedDataEncoder.hash(DOMAIN, MAIL_TYPES, MAIL)); + }); + + it("infers the primary type when the caller omits it", async () => { + const result = await evmSignStrategy.signTypedData(PK, { + domain: DOMAIN, + types: MAIL_TYPES, + message: MAIL, + }); + + expect(result.primaryType).toBe("Mail"); + }); + + // Wallets are routinely handed the full JSON-RPC payload, which DOES carry EIP712Domain in + // `types`. ethers computes the domain separator itself and rejects the redundant entry, so a + // strategy that forwards types verbatim would fail on the most common real-world input. + it("accepts a payload that includes EIP712Domain in its types", async () => { + const types = { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + ...MAIL_TYPES, + }; + + const { signature, primaryType } = await evmSignStrategy.signTypedData(PK, { + domain: DOMAIN, + types, + message: MAIL, + primaryType: "Mail", + }); + + expect(primaryType).toBe("Mail"); + expect(verifyTypedData(DOMAIN, MAIL_TYPES, MAIL, signature)).toBe(ADDRESS); + }); +}); + +describe("evmSignStrategy.sign (transactions)", () => { + const eip1559 = { + type: 2, + chainId: 11155111, + nonce: 7, + to: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + value: 1_000_000_000_000_000_000n, + gasLimit: 21_000n, + maxFeePerGas: 30_000_000_000n, + maxPriorityFeePerGas: 1_500_000_000n, + data: "0x", + }; + + it("signs an EIP-1559 transaction recoverable to the signer", async () => { + const { raw } = (await evmSignStrategy.sign(PK, eip1559)) as { raw: string }; + + expect(Transaction.from(raw).from).toBe(ADDRESS); + }); + + it("preserves every field it was given", async () => { + const { raw } = (await evmSignStrategy.sign(PK, eip1559)) as { raw: string }; + const parsed = Transaction.from(raw); + + expect(parsed.type).toBe(2); + expect(parsed.chainId).toBe(11155111n); + expect(parsed.nonce).toBe(7); + expect(parsed.to).toBe("0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"); + expect(parsed.value).toBe(1_000_000_000_000_000_000n); + expect(parsed.maxFeePerGas).toBe(30_000_000_000n); + }); + + // EIP-155 replay protection: a legacy transaction must carry the chain id in v, so a signature + // for Sepolia cannot be replayed on mainnet. + it("signs a legacy transaction with EIP-155 replay protection", async () => { + const { raw } = (await evmSignStrategy.sign(PK, { + type: 0, + chainId: 11155111, + nonce: 0, + to: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + value: 0n, + gasLimit: 21_000n, + gasPrice: 20_000_000_000n, + })) as { raw: string }; + + const parsed = Transaction.from(raw); + expect(parsed.from).toBe(ADDRESS); + expect(parsed.chainId).toBe(11155111n); + }); + + it("rejects a transaction it cannot encode instead of returning something unsigned", async () => { + await expect(evmSignStrategy.sign(PK, { to: "not-an-address" })).rejects.toThrow(); + }); +}); + +/** + * A signed EVM transaction is carried as `{ raw, hash }`, not as a bare serialised string. + * + * The hash is keccak256 of the signed bytes, so it is derivable from what we signed rather than + * assigned by a node — exactly the property `authoritativeTxId` relies on to refuse a node's + * word about which transaction it just accepted. Naming the field `hash` is what lets the + * existing `localTxId` find it, with no family branch: TRON supplies `txID`, EVM supplies `hash`. + */ +describe("evmSignStrategy signed-transaction identity", () => { + const tx = { + type: 2, + chainId: 11155111, + nonce: 7, + to: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + value: 1_000_000_000_000_000_000n, + gasLimit: 21_000n, + maxFeePerGas: 30_000_000_000n, + maxPriorityFeePerGas: 1_500_000_000n, + data: "0x", + }; + + it("returns the raw serialisation alongside its hash", async () => { + const signed = (await evmSignStrategy.sign(PK, tx)) as { raw: string; hash: string }; + + expect(signed.raw.startsWith("0x02")).toBe(true); + expect(signed.hash).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it("derives the hash from the signed bytes, not from anywhere else", async () => { + const signed = (await evmSignStrategy.sign(PK, tx)) as { raw: string; hash: string }; + + expect(signed.hash).toBe(keccak256(signed.raw)); + expect(Transaction.from(signed.raw).hash).toBe(signed.hash); + }); + + it("exposes the hash under a key localTxId already understands", async () => { + const signed = await evmSignStrategy.sign(PK, tx); + const found = localTxId(signed); + + // Asserting equality alone would pass on undefined === undefined, which is precisely the + // broken state this exists to catch: a bare string carries no id for localTxId to find. + expect(found).toMatch(/^0x[0-9a-f]{64}$/); + expect(found).toBe((signed as { hash: string }).hash); + }); +}); diff --git a/ts/src/adapters/outbound/chain/evm/signing-strategy.ts b/ts/src/adapters/outbound/chain/evm/signing-strategy.ts new file mode 100644 index 000000000..9871563f9 --- /dev/null +++ b/ts/src/adapters/outbound/chain/evm/signing-strategy.ts @@ -0,0 +1,101 @@ +/** + * EVM SignStrategy — the concrete signing behaviour SoftwareSigner delegates to for the `evm` + * family, mirroring `tron/signing-strategy.ts`. + * + * The split is deliberate and is the whole reason ethers is a direct dependency: + * - **@noble/curves does every operation that touches the private key.** It is the same audited + * primitive the TRON path and all key derivation already use, so the key never enters a + * larger library. + * - **ethers computes digests and encodings only** — EIP-191 prefixing, typed-transaction + * serialisation, EIP-712 struct hashing. None of that sees the key, and all of it is the + * kind of specification-heavy encoding that is easy to get subtly wrong by hand. + */ +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { Transaction, TypedDataEncoder, hashMessage, type TransactionLike } from "ethers"; +import type { TypedDataPayload, TypedDataSignature } from "../../../../domain/types/index.js"; +import type { SignStrategy } from "../../../../domain/types/index.js"; +import { ChainError } from "../../../../domain/errors/index.js"; + +const strip0x = (hex: string): string => (hex.startsWith("0x") ? hex.slice(2) : hex); + +/** + * Sign a 32-byte digest and return Ethereum's 65-byte `r || s || v` form. + * + * noble emits `[recovery, r, s]` and, by default, a canonical low-s signature — which is what + * Ethereum requires (EIP-2); a high-s signature is a second valid signature for the same message + * and nodes reject it. `v` is `27 + recovery`. + */ +function signDigest(pkHex: string, digestHex: string): string { + const recovered = secp256k1.sign(hexToBytes(strip0x(digestHex)), hexToBytes(strip0x(pkHex)), { + prehash: false, + format: "recovered", + }); + const v = (27 + recovered[0]!).toString(16).padStart(2, "0"); + return `0x${bytesToHex(recovered.slice(1))}${v}`; +} + +export const evmSignStrategy: SignStrategy = { + /** + * Returns `{ raw, hash }`: the serialisation `eth_sendRawTransaction` takes, plus the + * transaction's own hash. + * + * The hash is carried rather than left to the node because it is DERIVABLE from the bytes we + * just signed — keccak256 of the serialisation — and `authoritativeTxId` exists to prefer a + * locally derived id over a node's claim about which transaction it accepted. A bare string + * would carry no id at all, so `--wait` would poll whatever hash the node named. The key is + * `hash` because `localTxId` already reads `txID ?? hash`, so TRON and EVM need no branch. + * + * ethers owns the typed-envelope encoding (legacy / EIP-2930 / EIP-1559) and, for a legacy + * transaction, folds the chain id into `v` for EIP-155 replay protection. + */ + async sign(pkHex, tx) { + let transaction: Transaction; + try { + transaction = Transaction.from(tx as TransactionLike); + } catch (e) { + throw new ChainError( + "invalid_payload", + `EVM transaction could not be encoded: ${(e as Error).message}`, + ); + } + try { + transaction.signature = signDigest(pkHex, transaction.unsignedHash); + return { raw: transaction.serialized, hash: transaction.hash! }; + } catch (e) { + throw new ChainError("signing_rejected", `EVM sign failed: ${(e as Error).message}`); + } + }, + + async signMessage(pkHex, message) { + try { + return signDigest(pkHex, hashMessage(message)); + } catch (e) { + throw new ChainError("signing_rejected", `EVM message sign failed: ${(e as Error).message}`); + } + }, + + async signTypedData(pkHex, payload: TypedDataPayload): Promise { + const { domain, types, message } = payload; + // A JSON-RPC eth_signTypedData payload carries EIP712Domain in `types`, but ethers derives + // the domain separator from `domain` itself and rejects the redundant entry. Dropping it is + // what lets the wallet accept the payload shape dApps actually send. + const structTypes = Object.fromEntries( + Object.entries(types as Record).filter(([name]) => name !== "EIP712Domain"), + ) as Record>; + try { + const digest = TypedDataEncoder.hash(domain as never, structTypes, message); + return { + signature: signDigest(pkHex, digest), + digest, + primaryType: + payload.primaryType ?? TypedDataEncoder.from(structTypes).primaryType, + }; + } catch (e) { + throw new ChainError( + "signing_rejected", + `EVM typed-data sign failed: ${(e as Error).message}`, + ); + } + }, +}; diff --git a/ts/src/adapters/outbound/chain/tron/provider.test.ts b/ts/src/adapters/outbound/chain/tron/provider.test.ts index 1f92c585f..b6724548f 100644 --- a/ts/src/adapters/outbound/chain/tron/provider.test.ts +++ b/ts/src/adapters/outbound/chain/tron/provider.test.ts @@ -17,6 +17,10 @@ describe("ChainGatewayRegistry injected factories", () => { const p = new ChainGatewayRegistry( { tron: (n, timeoutMs) => new TronRpcClient(n.httpEndpoint ?? "", timeoutMs), + // no EVM adapter yet — this suite only exercises the TRON factory + evm: () => { + throw new Error("evm gateway not wired"); + }, }, 60_000, ); diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index fb5342870..05ed43e84 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -51,9 +51,9 @@ export const CAP_SUMMARIES: Record = { export const BUILTIN_NETWORKS: Record = { "tron:mainnet": { id: "tron:mainnet", + nativeSymbol: "TRX", family: "tron", chainId: "mainnet", - aliases: ["tron"], httpEndpoint: "https://api.trongrid.io", tronlinkHttpEndpoint: "https://api.walletadapter.org", gasfree: { @@ -67,9 +67,9 @@ export const BUILTIN_NETWORKS: Record = { }, "tron:nile": { id: "tron:nile", + nativeSymbol: "TRX", family: "tron", chainId: "nile", - aliases: ["nile"], httpEndpoint: "https://nile.trongrid.io", tronlinkHttpEndpoint: "https://apinile.walletadapter.org", gasfree: { @@ -83,14 +83,65 @@ export const BUILTIN_NETWORKS: Record = { }, "tron:shasta": { id: "tron:shasta", + nativeSymbol: "TRX", family: "tron", chainId: "shasta", - aliases: ["shasta"], httpEndpoint: "https://api.shasta.trongrid.io", tronlinkHttpEndpoint: "https://apishasta.walletadapter.org", feeModel: "tron-resource", capabilities: [], }, + // §2.2 — one L1 pair per chain. Endpoints are third-party public RPC: rate-limited, no SLA, + // and they see the addresses queried. Production use should point these at a private gateway. + "evm:1": { + id: "evm:1", + nativeSymbol: "ETH", + family: "evm", + chainId: "1", + httpEndpoint: "https://ethereum-rpc.publicnode.com", + feeModel: "evm-gas", + capabilities: [], + }, + "evm:11155111": { + id: "evm:11155111", + nativeSymbol: "ETH", + family: "evm", + chainId: "11155111", + httpEndpoint: "https://ethereum-sepolia-rpc.publicnode.com", + feeModel: "evm-gas", + capabilities: [], + }, + "evm:56": { + id: "evm:56", + nativeSymbol: "BNB", + family: "evm", + chainId: "56", + httpEndpoint: "https://bsc-dataseed.bnbchain.org", + feeModel: "evm-gas", + capabilities: [], + }, + "evm:97": { + id: "evm:97", + nativeSymbol: "BNB", + family: "evm", + chainId: "97", + httpEndpoint: "https://bsc-testnet-dataseed.bnbchain.org", + feeModel: "evm-gas", + capabilities: [], + }, +}; + +/** §2.1 — one short name per builtin network. A flat map, so global uniqueness is structural: + * a duplicate key cannot exist. There is deliberately no `evm` entry — EVM is a family, not a + * chain, so it has no mainnet to claim the bare family name. */ +export const BUILTIN_ALIASES: Record = { + tron: "tron:mainnet", + nile: "tron:nile", + shasta: "tron:shasta", + ethereum: "evm:1", + sepolia: "evm:11155111", + bsc: "evm:56", + "bsc-testnet": "evm:97", }; export const DEFAULT_CONFIG = { diff --git a/ts/src/adapters/outbound/config/config.test.ts b/ts/src/adapters/outbound/config/config.test.ts index 51d6609b0..379ff5f0b 100644 --- a/ts/src/adapters/outbound/config/config.test.ts +++ b/ts/src/adapters/outbound/config/config.test.ts @@ -46,11 +46,6 @@ describe("ConfigLoader waitTimeoutMs validation", () => { describe("NetworkRegistry.resolve case-insensitivity", () => { const registry = () => new NetworkRegistry(ConfigLoader.load(envWithConfig(""))); - it("rejects network aliases", () => { - expect(() => registry().resolve("nile")).toThrow(/unknown network/); - expect(() => registry().resolve("tron")).toThrow(/unknown network/); - }); - it("resolves a canonical id regardless of input casing", () => { expect(registry().resolve("TRON:NILE").id).toBe("tron:nile"); }); @@ -140,3 +135,229 @@ describe("ConfigLoader unreadable/malformed config", () => { ); }); }); + +describe("builtin EVM networks", () => { + const registry = () => new NetworkRegistry(ConfigLoader.load(envWithConfig(""))); + + // §2.2: one L1 pair per chain. L2s are deliberately excluded — the evm-gas fee model computes + // gasLimit x gasPrice and would systematically under-report cost on rollups. + it.each([ + ["evm:1", "1"], + ["evm:11155111", "11155111"], + ["evm:56", "56"], + ["evm:97", "97"], + ])("resolves %s as an evm-gas network", (id, chainId) => { + const net = registry().resolve(id); + expect(net).toMatchObject({ id, family: "evm", chainId, feeModel: "evm-gas" }); + }); + + it("ships every EVM network with a usable endpoint", () => { + for (const id of ["evm:1", "evm:11155111", "evm:56", "evm:97"]) { + expect(registry().resolve(id).httpEndpoint).toMatch(/^https:\/\//); + } + }); + + it("keeps the TRON networks unchanged", () => { + expect(registry().resolve("tron:nile")).toMatchObject({ + family: "tron", nativeSymbol: "TRX", + feeModel: "tron-resource", + }); + }); +}); + +// ADR-0010 supersedes architecture-source-of-truth.md:499 ("aliases are not accepted as network +// selectors"). Aliases now resolve, but ONLY here — everything downstream carries the canonical id. +describe("network alias book", () => { + const registry = (yaml = "") => new NetworkRegistry(ConfigLoader.load(envWithConfig(yaml))); + + it.each([ + ["tron", "tron:mainnet"], + ["nile", "tron:nile"], + ["shasta", "tron:shasta"], + ["ethereum", "evm:1"], + ["sepolia", "evm:11155111"], + ["bsc", "evm:56"], + ["bsc-testnet", "evm:97"], + ])("resolves the builtin alias %s to %s", (alias, id) => { + expect(registry().resolve(alias).id).toBe(id); + }); + + it("resolves an alias regardless of casing, like a canonical id", () => { + expect(registry().resolve("SEPOLIA").id).toBe("evm:11155111"); + }); + + it("has no `evm` alias — EVM is not a chain, so it has no mainnet to claim the family name", () => { + expect(() => registry().resolve("evm")).toThrow(/unknown network/); + }); + + it("lets a user add an alias for a network they configured", () => { + const yaml = [ + "networks:", + " evm:137:", + " family: evm", + ' chainId: "137"', + " nativeSymbol: MATIC", + " httpEndpoint: https://polygon.example", + "aliases:", + " polygon: evm:137", + ].join("\n"); + expect(registry(yaml).resolve("polygon").id).toBe("evm:137"); + }); + + // The hazard is structural, not validated against: a canonical id can never be shadowed. + it("prefers a canonical id over a book entry that shadows it", () => { + const yaml = ["aliases:", " evm:1: tron:nile"].join("\n"); + expect(registry(yaml).resolve("evm:1").id).toBe("evm:1"); + }); + + it("still rejects an unknown alias", () => { + expect(() => registry().resolve("dogechain")).toThrow(/unknown network/); + }); +}); + +// §2.4: config.yaml has always been edited by hand, and TRON-era users wrote endpoints under the +// short name. Not recognising an alias key is the worst failure mode available here — the file +// looks configured, the setting silently does nothing, and `--network sepolia` would resolve to +// the bogus network the alias key created instead of the real one. +describe("network keys in config.yaml are normalised to canonical ids", () => { + const load = (yaml: string) => ConfigLoader.load(envWithConfig(yaml)); + + it("applies an alias-keyed entry to the canonical network", () => { + const config = load( + ["networks:", " sepolia:", " httpEndpoint: https://mine.example"].join("\n"), + ); + + expect(config.networks["evm:11155111"]!.httpEndpoint).toBe("https://mine.example"); + expect(config.networks["sepolia"]).toBeUndefined(); + }); + + it("keeps the rest of the builtin descriptor when merging an alias-keyed entry", () => { + const config = load(["networks:", " nile:", " httpEndpoint: https://mine.example"].join("\n")); + + expect(config.networks["tron:nile"]).toMatchObject({ + id: "tron:nile", + family: "tron", nativeSymbol: "TRX", + httpEndpoint: "https://mine.example", + }); + }); + + it("refuses a file that configures one network under both names", () => { + const yaml = [ + "networks:", + " sepolia:", + " httpEndpoint: https://one.example", + " evm:11155111:", + " httpEndpoint: https://two.example", + ].join("\n"); + + expect(() => load(yaml)).toThrow(/sepolia.*evm:11155111|evm:11155111.*sepolia/); + }); + + it("leaves an unrecognised key alone so a user-defined network still works", () => { + const config = load( + ["networks:", " evm:137:", " family: evm", ' chainId: "137"', " nativeSymbol: MATIC"].join( + "\n", + ), + ); + + expect(config.networks["evm:137"]).toMatchObject({ id: "evm:137", family: "evm" }); + }); +}); + +describe("a dangling alias reports what it points at", () => { + // Aliases are hand-edited (there is no `config set aliases.*`), so the only way a typo'd target + // surfaces is at resolution. "unknown network: polygon" would send the user hunting for a + // network they never asked for, instead of at the alias entry they got wrong. + it("names the alias AND its unresolvable target", () => { + const registry = new NetworkRegistry( + ConfigLoader.load(envWithConfig(["aliases:", " polygon: evm:99999"].join("\n"))), + ); + + expect(() => registry.resolve("polygon")).toThrow(/polygon.*evm:99999/); + }); + + it("still reports a plain unknown name without inventing a target", () => { + const registry = new NetworkRegistry(ConfigLoader.load(envWithConfig(""))); + expect(() => registry.resolve("dogechain")).toThrow(/unknown network: dogechain/); + }); +}); + +describe("the effective config exposes the alias book", () => { + it("lists aliases so a user can see what a short name resolves to", () => { + const config = ConfigLoader.load(envWithConfig("")); + expect(config.aliases).toMatchObject({ sepolia: "evm:11155111", nile: "tron:nile" }); + }); +}); + +// The native coin's NAME belongs to the chain, not the family: evm:1 is ETH and evm:56 is BNB, +// yet both are family `evm`. Reading it off the family table renders BNB as ETH — a wallet +// naming the wrong currency. +describe("each network declares its own native coin", () => { + const registry = () => new NetworkRegistry(ConfigLoader.load(envWithConfig(""))); + + it.each([ + ["tron:mainnet", "TRX"], + ["tron:nile", "TRX"], + ["tron:shasta", "TRX"], + ["evm:1", "ETH"], + ["evm:11155111", "ETH"], + ["evm:56", "BNB"], + ["evm:97", "BNB"], + ])("%s uses %s", (id, symbol) => { + expect(registry().resolve(id).nativeSymbol).toBe(symbol); + }); + + it("distinguishes two networks of the SAME family", () => { + const r = registry(); + expect(r.resolve("evm:1").family).toBe(r.resolve("evm:56").family); + expect(r.resolve("evm:1").nativeSymbol).not.toBe(r.resolve("evm:56").nativeSymbol); + }); +}); + +// A user-added network is merged with a bare `as NetworkDescriptor` cast, so a missing required +// field used to travel until something dereferenced it — `capabilities` crashed composition with +// "Cannot read properties of undefined (reading 'map')" before any command ran, reported as a +// bare internal_error. Config problems must be reported as config problems, naming the field. +describe("a hand-added network is validated at load", () => { + const load = (yaml: string) => ConfigLoader.load(envWithConfig(yaml)); + const custom = (extra: string[]) => + ["networks:", " evm:137:", ...extra.map((l) => ` ${l}`)].join("\n"); + + it("accepts a complete definition", () => { + const net = load(custom(['family: evm', 'chainId: "137"', 'nativeSymbol: MATIC'])).networks[ + "evm:137" + ]!; + expect(net).toMatchObject({ family: "evm", chainId: "137", nativeSymbol: "MATIC" }); + }); + + // Traits are a list of extras; having none is the normal case, not an error. + it("defaults capabilities to none rather than leaving it undefined", () => { + expect( + load(custom(['family: evm', 'chainId: "137"', 'nativeSymbol: MATIC'])).networks["evm:137"]! + .capabilities, + ).toEqual([]); + }); + + it.each([ + ["family", ['chainId: "137"', "nativeSymbol: MATIC"]], + ["chainId", ["family: evm", "nativeSymbol: MATIC"]], + // without this a MATIC balance would silently render as ETH, the family table's value + ["nativeSymbol", ["family: evm", 'chainId: "137"']], + ])("refuses a definition missing %s, naming the field", (field, present) => { + expect(() => load(custom(present))).toThrow(new RegExp(`evm:137[\\s\\S]*${field}`)); + }); + + it("refuses a family it does not implement", () => { + expect(() => load(custom(["family: solana", 'chainId: "1"', "nativeSymbol: SOL"]))).toThrow( + /solana/, + ); + }); + + // Overriding one field of a builtin must not demand the rest be restated. + it("lets a builtin be partially overridden", () => { + const net = load( + ["networks:", " evm:11155111:", " httpEndpoint: https://mine.example"].join("\n"), + ).networks["evm:11155111"]!; + expect(net).toMatchObject({ nativeSymbol: "ETH", httpEndpoint: "https://mine.example" }); + }); +}); diff --git a/ts/src/adapters/outbound/config/index.ts b/ts/src/adapters/outbound/config/index.ts index bfc470c34..957069db2 100644 --- a/ts/src/adapters/outbound/config/index.ts +++ b/ts/src/adapters/outbound/config/index.ts @@ -10,7 +10,9 @@ import { parse as parseYaml } from "yaml"; import type { Config, NetworkDescriptor, OutputMode } from "../../../domain/types/index.js"; import type { NetworkRegistry as INetworkRegistry } from "../../../application/ports/network-registry.js"; import { UsageError } from "../../../domain/errors/index.js"; -import { BUILTIN_NETWORKS, DEFAULT_CONFIG } from "./builtins.js"; +import { BUILTIN_ALIASES, BUILTIN_NETWORKS, DEFAULT_CONFIG } from "./builtins.js"; +import { CHAIN_FAMILIES } from "../../../domain/family/index.js"; +import type { ChainFamily } from "../../../domain/family/index.js"; export class ConfigLoader { /** bootstrap: must run before locating config.yaml. */ @@ -28,6 +30,7 @@ export class ConfigLoader { static load(env: NodeJS.ProcessEnv = process.env): Config { const networks: Record = {}; for (const [id, d] of Object.entries(BUILTIN_NETWORKS)) networks[id] = { ...d }; + const aliases: Record = { ...BUILTIN_ALIASES }; let defaultNetwork: string | undefined = DEFAULT_CONFIG.defaultNetwork; let defaultOutput: OutputMode = DEFAULT_CONFIG.defaultOutput; @@ -70,11 +73,31 @@ export class ConfigLoader { if (validCredential(raw.tronlinkChannel)) tronlinkChannel = raw.tronlinkChannel; if (validCredential(raw.gasfreeApiKey)) gasfreeApiKey = raw.gasfreeApiKey; if (validCredential(raw.gasfreeApiSecret)) gasfreeApiSecret = raw.gasfreeApiSecret; + // aliases first: a network key may be written as an alias, and normalising it needs the + // book the same file may have just extended. + if (raw.aliases && typeof raw.aliases === "object" && !Array.isArray(raw.aliases)) { + for (const [alias, target] of Object.entries(raw.aliases as Record)) { + if (typeof target === "string") aliases[alias.toLowerCase()] = target; + } + } if (raw.networks && typeof raw.networks === "object") { - for (const [id, d] of Object.entries( + const seen = new Map(); // canonical id -> the key that claimed it + for (const [key, d] of Object.entries( raw.networks as Record>, )) { - networks[id] = { ...(networks[id] ?? {}), ...d, id } as NetworkDescriptor; + // A hand-edited alias key must configure the network it names, not create a new one. + // Silently ignoring it is the failure §2.4 calls out: the file looks configured and + // does nothing. + const id = aliases[key.toLowerCase()] ?? key; + const claimedBy = seen.get(id); + if (claimedBy !== undefined) { + throw new UsageError( + "invalid_value", + `config.yaml configures ${id} twice, under "${claimedBy}" and "${key}"; keep one`, + ); + } + seen.set(id, key); + networks[id] = validNetwork(id, { ...(networks[id] ?? {}), ...d, id }); } } } @@ -84,6 +107,7 @@ export class ConfigLoader { timeoutMs, waitTimeoutMs, networks, + aliases, price, tronlinkSecretId, tronlinkSecretKey, @@ -111,6 +135,35 @@ function validCredential(value: unknown): value is string { * its message. Classifying here also keeps the user out of the generic `internal_error` they would * otherwise get from the bootstrap boundary for what is simply a broken file. */ +/** + * A network from config.yaml, checked before it can travel. + * + * The merge is a bare cast, so anything missing used to survive until something dereferenced it: + * an absent `capabilities` crashed composition with "Cannot read properties of undefined" before + * any command ran, surfacing as a bare internal_error. A config mistake has to be reported as a + * config mistake, naming the network and the field, at the moment the file is read. + */ +function validNetwork(id: string, merged: Record): NetworkDescriptor { + const require = (field: string): unknown => { + const value = merged[field]; + if (typeof value !== "string" || value === "") { + throw new UsageError("invalid_value", `network ${id} in config.yaml is missing ${field}`); + } + return value; + }; + require("chainId"); + require("nativeSymbol"); + const family = require("family"); + if (!CHAIN_FAMILIES.includes(family as ChainFamily)) { + throw new UsageError( + "invalid_value", + `network ${id} in config.yaml has an unsupported family: ${String(family)}`, + ); + } + // Traits are extras; having none is the normal case, not an error. + return { capabilities: [], ...merged } as unknown as NetworkDescriptor; +} + function readConfigDocument(path: string) { let text: string; try { @@ -151,6 +204,10 @@ export class NetworkRegistry implements INetworkRegistry { } } + aliasOf(id: string): string | undefined { + return Object.entries(this.config.aliases).find(([, target]) => target === id)?.[0]; + } + all(): NetworkDescriptor[] { return [...this.#byId.values()]; } @@ -160,11 +217,25 @@ export class NetworkRegistry implements INetworkRegistry { throw new UsageError("missing_network", "this command requires --network "); } const key = id.toLowerCase(); - const network = this.#byId.get(key); - if (!network) { + // Canonical FIRST, book second (ADR-0010): an alias can never shadow a real network id, + // whatever a hand-edited config.yaml contains. + const direct = this.#byId.get(key); + if (direct) return { ...direct }; + + const target = this.config.aliases[key]; + if (target === undefined) { throw new UsageError("unsupported_network", `unknown network: ${id}`); } - return { ...network }; + const aliased = this.#byId.get(target.toLowerCase()); + if (!aliased) { + // Aliases are hand-edited, so name the entry AND its target — otherwise the user hunts for + // a network they never asked for instead of the alias line they mistyped. + throw new UsageError( + "unsupported_network", + `alias "${id}" points at unknown network ${target}`, + ); + } + return { ...aliased }; } /** default target for all chain commands when --network is omitted. */ diff --git a/ts/src/adapters/outbound/contactbook/contactbook.test.ts b/ts/src/adapters/outbound/contactbook/contactbook.test.ts index 00f4c0367..f1e49e080 100644 --- a/ts/src/adapters/outbound/contactbook/contactbook.test.ts +++ b/ts/src/adapters/outbound/contactbook/contactbook.test.ts @@ -81,7 +81,7 @@ describe("ContactBook", () => { entries: { tron: [ { - family: "tron", + family: "tron", nativeSymbol: "TRX", name: "Alice", nameKey: "bob", address: ADDRESS, @@ -98,3 +98,53 @@ describe("ContactBook", () => { ); }); }); + +describe("ContactBook holds every family", () => { + const TRON = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; + const EVM = "0xe2E1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; + + // The on-disk shape was ALREADY family-keyed (`entries` is Partial> and + // every entry carries its own `family`), so nothing here is a migration — the loader simply + // stopped refusing anything that was not tron. + it("round-trips contacts from both families", () => { + const book = new ContactBook(root(), new AtomicFileStore()); + book.add(createContact("tron", "tron-friend", TRON)); + book.add(createContact("evm", "evm-friend", EVM)); + + expect(book.list("tron").map((c) => c.address)).toEqual([TRON]); + expect(book.list("evm").map((c) => c.address)).toEqual([EVM]); + }); + + it("keeps the two families' name spaces separate", () => { + const book = new ContactBook(root(), new AtomicFileStore()); + book.add(createContact("tron", "friend", TRON)); + book.add(createContact("evm", "friend", EVM)); + + expect(book.find("tron", "friend")?.address).toBe(TRON); + expect(book.find("evm", "friend")?.address).toBe(EVM); + }); + + it("rejects a file whose entry sits under the wrong family key", () => { + const dir = root(); + const book = new ContactBook(dir, new AtomicFileStore()); + book.add(createContact("evm", "evm-friend", EVM)); + const path = join(dir, "contacts.json"); + const doc = JSON.parse(readFileSync(path, "utf8")); + doc.entries.tron = doc.entries.evm; // an EVM address filed under tron + delete doc.entries.evm; + writeFileSync(path, JSON.stringify(doc)); + + expect(() => new ContactBook(dir, new AtomicFileStore()).list("tron")).toThrow(); + }); + + it("rejects an unknown family key", () => { + const dir = root(); + writeFileSync( + join(dir, "contacts.json"), + JSON.stringify({ version: 1, entries: { solana: [] } }), + { mode: 0o600 }, + ); + + expect(() => new ContactBook(dir, new AtomicFileStore()).list("tron")).toThrow(); + }); +}); diff --git a/ts/src/adapters/outbound/contactbook/index.ts b/ts/src/adapters/outbound/contactbook/index.ts index 04307e756..d1eec9af2 100644 --- a/ts/src/adapters/outbound/contactbook/index.ts +++ b/ts/src/adapters/outbound/contactbook/index.ts @@ -4,6 +4,7 @@ import type { ContactRepository } from "../../../application/ports/contact-repos 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 { CHAIN_FAMILIES } from "../../../domain/family/index.js"; import { AtomicFileStore } from "../persistence/fs/index.js"; const MAX_CONTACT_FILE_BYTES = 4 * 1024 * 1024; @@ -50,6 +51,16 @@ export class ContactBook implements ContactRepository { ); } + /** Names are unique book-wide, so a scan across buckets has exactly one answer. */ + findAnywhere(nameKey: string): ContactEntry | undefined { + const document = this.#read(); + for (const family of CHAIN_FAMILIES) { + const hit = document.entries[family]?.find((e) => e.nameKey === nameKey); + if (hit) return hit; + } + return undefined; + } + find(family: ChainFamily, nameKey: string): ContactEntry | undefined { return this.list(family).find((entry) => entry.nameKey === nameKey); } @@ -85,12 +96,13 @@ export class ContactBook implements ContactRepository { 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) { + for (const [key, items] of Object.entries(root.entries as Record)) { + const family = key as ChainFamily; + if (!CHAIN_FAMILIES.includes(family) || !Array.isArray(items) || items.length > MAX_CONTACTS) { throw corrupt(); } const seen = new Set(); - result.entries.tron = items.map((value) => { + result.entries[family] = items.map((value) => { if (!value || typeof value !== "object" || Array.isArray(value)) { throw corrupt(); } @@ -102,10 +114,12 @@ export class ContactBook implements ContactRepository { ) { throw corrupt(); } - const validated = createContact("tron", item.name, item.address, item.note ?? undefined); + // Re-validated against the family whose bucket it was found in, so an address filed + // under the wrong key is caught here rather than surfacing as an unusable recipient. + const validated = createContact(family, item.name, item.address, item.note ?? undefined); if ( item.nameKey !== validated.nameKey || - item.family !== "tron" || + item.family !== family || seen.has(validated.nameKey) ) { throw corrupt(); diff --git a/ts/src/adapters/outbound/gasfree/client.test.ts b/ts/src/adapters/outbound/gasfree/client.test.ts index 8cd7ec540..7b64a8cac 100644 --- a/ts/src/adapters/outbound/gasfree/client.test.ts +++ b/ts/src/adapters/outbound/gasfree/client.test.ts @@ -5,8 +5,8 @@ import { GasFreeClient } from "./client.js"; const NETWORK = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: ["nile"], capabilities: [], gasfree: { baseUrl: "https://open-test.gasfree.io", diff --git a/ts/src/adapters/outbound/gasfree/client.ts b/ts/src/adapters/outbound/gasfree/client.ts index 5e731ffd0..23d72e35b 100644 --- a/ts/src/adapters/outbound/gasfree/client.ts +++ b/ts/src/adapters/outbound/gasfree/client.ts @@ -1,3 +1,4 @@ +import { isTronNetwork } from "../../../domain/types/network.js"; import { createHmac } from "node:crypto"; import { isLosslessNumber, @@ -188,7 +189,7 @@ export class GasFreeClient implements GasFreeProvider { } function endpoint(network: NetworkDescriptor): { baseUrl: string; apiPrefix: string } { - const value = network.gasfree; + const value = isTronNetwork(network) ? network.gasfree : undefined; if (!value) { throw new UsageError("unsupported_network", `network ${network.id} does not support GasFree`); } diff --git a/ts/src/adapters/outbound/keystore/index.ts b/ts/src/adapters/outbound/keystore/index.ts index ad79c57ff..2cefae3ba 100644 --- a/ts/src/adapters/outbound/keystore/index.ts +++ b/ts/src/adapters/outbound/keystore/index.ts @@ -3,6 +3,7 @@ * registry + root labels + selection (--account/--wallet). Atomic writes under lock. * BIP39 passphrase plumbed. Data shapes live in SharedTypes. */ +import { WALLETS_VERSION } from "../../../domain/migration/wallets-v2.js"; import { existsSync, mkdirSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { randomBytes, hexToBytes } from "@noble/hashes/utils.js"; @@ -71,7 +72,9 @@ export class Keystore { // ── registry IO ─────────────────────────────────────────────────────────── #read(): WalletsFile { const f = this.store.readJson(this.walletsPath); - return f ?? { version: 1, activeAccount: null, wallets: [], labels: {} }; + // Absent = a fresh keystore, so it is born CURRENT. This default is persisted on the first + // write, so a literal version here would stamp every new keystore stale (ADR-0008). + return f ?? { version: WALLETS_VERSION, activeAccount: null, wallets: [], labels: {} }; } /** caller must already hold the wallets.json lock (mutators wrap in withLock). */ #write(f: WalletsFile): void { @@ -338,6 +341,7 @@ export class Keystore { if (s.type === "seed") { d.seedId = w.id; // the seed id `derive --seed` takes; also the `list` HD group header. } + d.derivationPath = derivationPathsOf(s, index); return d; } @@ -644,3 +648,18 @@ export class Keystore { return `wallet-${n}`; } } + +/** + * The BIP44 path behind each of an account's addresses. + * - seed: computed per family from the index — the templates differ (§1.2), which is exactly + * what a caller cannot otherwise see. + * - ledger: the single path the user picked on the device, for its one family. + * - watch / privateKey: never derived, so `null` rather than an empty object. + */ +function derivationPathsOf(source: Source, index: number | null): Record | null { + if (source.type === "seed" && index !== null) { + return Object.fromEntries(CHAIN_FAMILIES.map((f) => [f, Derivation.path(f, index)])); + } + if (source.type === "ledger") return { [source.family]: source.path }; + return null; +} diff --git a/ts/src/adapters/outbound/keystore/keystore.test.ts b/ts/src/adapters/outbound/keystore/keystore.test.ts index 4687c702b..f69a6ad2f 100644 --- a/ts/src/adapters/outbound/keystore/keystore.test.ts +++ b/ts/src/adapters/outbound/keystore/keystore.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import { WALLETS_VERSION } from "../../../domain/migration/wallets-v2.js"; // Swap real scrypt (n=2^18, hundreds of ms/call) for a cheap deterministic KDF: this suite // exercises keystore *logic* over dozens of encrypt/decrypt cycles, not the KDF, which @@ -7,7 +8,7 @@ vi.mock( "@noble/hashes/scrypt.js", async () => import("../persistence/crypto/__test-support__/cheap-scrypt.js"), ); -import { mkdtempSync, readdirSync, renameSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, renameSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { bytesToHex } from "@noble/hashes/utils.js"; @@ -502,3 +503,54 @@ describe("changePassword", () => { expect(residue(root)).toEqual([]); }, 15_000); }); + +describe("wallets.json schema version", () => { + // The synthesised default is not just read, it is PERSISTED on first write. A literal 1 here + // would stamp every freshly created keystore as stale and send it straight to the migration + // gate on its very next run (ADR-0008). + it("stamps a newly created keystore at the current version", () => { + const root = mkdtempSync(join(tmpdir(), "ks-")); + const ks = new Keystore(root, new AtomicFileStore(), () => "masterpw123A"); + ks.registerWatch({ family: "tron", address: "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6" }); + + const doc = JSON.parse(readFileSync(join(root, "wallets.json"), "utf8")); + + expect(doc.version).toBe(WALLETS_VERSION); + }); +}); + +describe("descriptor carries each family's derivation path", () => { + // §3.7: json had no path at all, so a user could not tell WHICH template an account used — + // and the two families deliberately use different ones (§1.2). + it("gives a seed account one path per family", () => { + const root = mkdtempSync(join(tmpdir(), "ks-")); + const ks = new Keystore(root, new AtomicFileStore(), () => "masterpw123A"); + ks.import({ secret: MNEMONIC, type: "seed", label: "main" }); + ks.addAccount(ks.list()[0]!.seedId!, 2); + + const account2 = ks.list().find((a) => a.index === 2)!; + expect(account2.derivationPath).toEqual({ + tron: "m/44'/195'/2'/0/0", + evm: "m/44'/60'/0'/0/2", + }); + }); + + // watch and private-key accounts were never derived from a template, so there is no path to + // report — null says that, where an omitted field would just look like a gap. + it("reports null for an account that was not derived", () => { + const root = mkdtempSync(join(tmpdir(), "ks-")); + const ks = new Keystore(root, new AtomicFileStore(), () => "masterpw123A"); + ks.registerWatch({ family: "evm", address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }); + + expect(ks.list()[0]!.derivationPath).toBeNull(); + }); + + // A Ledger account IS derived, at a path the user chose on the device — and it is single-family. + it("gives a ledger account only its own family's path", () => { + const root = mkdtempSync(join(tmpdir(), "ks-")); + const ks = new Keystore(root, new AtomicFileStore(), () => "masterpw123A"); + ks.registerLedger({ family: "tron", path: "m/44'/195'/5'/0/0", address: "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6" }); + + expect(ks.list()[0]!.derivationPath).toEqual({ tron: "m/44'/195'/5'/0/0" }); + }); +}); diff --git a/ts/src/adapters/outbound/ledger/evm.test.ts b/ts/src/adapters/outbound/ledger/evm.test.ts new file mode 100644 index 000000000..b3160cd19 --- /dev/null +++ b/ts/src/adapters/outbound/ledger/evm.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi } from "vitest"; +import { keccak256 } from "ethers"; +import { localTxId } from "../../../application/services/broadcast-identity.js"; +import { Ledger } from "./index.js"; +import { Transaction, TypedDataEncoder } from "ethers"; + +// Both app modules are imported lazily inside the adapter, so hoisted vi.mock applies. Mocking +// BOTH is the point: the adapter must reach for the ethereum app, and a regression that keeps +// loading hw-app-trx would otherwise pass silently. +const { calls, highS, legacyV } = vi.hoisted(() => ({ + calls: [] as Array<{ app: string; method: string; args: unknown[] }>, + highS: { on: false }, + // hw-app-eth returns v ALREADY EIP-155-encoded for a legacy tx: chainId*2 + 35 + parity. + legacyV: { value: "1c" }, +})); + +vi.mock("@ledgerhq/hw-transport-node-hid-noevents", () => ({ + default: { open: async () => ({ close: async () => {} }) }, +})); + +vi.mock("@ledgerhq/hw-app-trx", () => ({ + default: class { + async getAddress(...args: unknown[]) { + calls.push({ app: "trx", method: "getAddress", args }); + return { publicKey: "", address: "TWrongApp" }; + } + }, +})); + +vi.mock("@ledgerhq/hw-app-eth", () => ({ + default: class { + async getAddress(...args: unknown[]) { + calls.push({ app: "eth", method: "getAddress", args }); + return { publicKey: "04ab", address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }; + } + async signPersonalMessage(...args: unknown[]) { + calls.push({ app: "eth", method: "signPersonalMessage", args }); + return { v: 28, r: "aa".repeat(32), s: "bb".repeat(32) }; + } + async signTransaction(...args: unknown[]) { + calls.push({ app: "eth", method: "signTransaction", args }); + return { v: legacyV.value, r: "cc".repeat(32), s: (highS.on ? "dd" : "22").repeat(32) }; + } + async signEIP712HashedMessage(...args: unknown[]) { + calls.push({ app: "eth", method: "signEIP712HashedMessage", args }); + return { v: 28, r: "ee".repeat(32), s: "ff".repeat(32) }; + } + }, +})); + +const PATH = "m/44'/60'/0'/0/0"; +const ledger = () => new Ledger(5_000); + +describe("Ledger reaches the ethereum app for the evm family", () => { + it("derives an address through hw-app-eth, not hw-app-trx", async () => { + calls.length = 0; + + const address = await ledger().getAddress("evm", PATH); + + expect(address).toBe("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + expect(calls.map((c) => c.app)).toEqual(["eth"]); + }); + + it("strips the leading m/ before handing the path to the device", async () => { + calls.length = 0; + await ledger().getAddress("evm", PATH); + + expect(calls[0]!.args[0]).toBe("44'/60'/0'/0/0"); + }); + + // hw-app-eth returns {v, r, s} where hw-app-trx returns a hex string, so the adapter has to + // assemble Ethereum's r||s||v itself rather than forwarding whatever came back. + it("assembles a 65-byte r||s||v signature from the app's {v,r,s}", async () => { + calls.length = 0; + + const signature = await ledger().signMessage("evm", PATH, "hello world"); + + expect(signature).toBe(`0x${"aa".repeat(32)}${"bb".repeat(32)}1c`); + }); + + it("hands the message to the device as hex", async () => { + calls.length = 0; + await ledger().signMessage("evm", PATH, "hi"); + + expect(calls[0]!.args[1]).toBe(Buffer.from("hi", "utf8").toString("hex")); + }); +}); + +const TX = { + type: 2, + chainId: 11155111, + nonce: 3, + to: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + value: 1_000_000_000_000_000_000n, + gasLimit: 21_000n, + maxFeePerGas: 30_000_000_000n, + maxPriorityFeePerGas: 1_500_000_000n, +}; + +describe("Ledger signs EVM transactions", () => { + it("returns a serialised raw transaction carrying the device's signature", async () => { + calls.length = 0; + + const { raw } = (await ledger().signTransaction("evm", PATH, TX)) as { raw: string }; + + expect(raw).toMatch(/^0x02/); // typed envelope, EIP-1559 + expect(raw.toLowerCase()).toContain("cc".repeat(32)); + }); + + it("hands the device the UNSIGNED serialisation, without its 0x prefix", async () => { + calls.length = 0; + await ledger().signTransaction("evm", PATH, TX); + + const [path, rawTxHex] = calls[0]!.args as [string, string]; + expect(path).toBe("44'/60'/0'/0/0"); + expect(rawTxHex.startsWith("0x")).toBe(false); + expect(rawTxHex.startsWith("02")).toBe(true); + }); + + // Passing a resolution would make hw-app-eth fetch clear-signing descriptors from Ledger's CDN + // mid-signature. We deliberately pass null: the CLI must not phone out while signing. + // ethers enforces EIP-2 when the signature is attached, so a device returning a high-s value + // is rejected here rather than producing a transaction the network would refuse. + it("refuses a non-canonical high-s signature from the device", async () => { + calls.length = 0; + highS.on = true; + try { + await expect(ledger().signTransaction("evm", PATH, TX)).rejects.toThrow(); + } finally { + highS.on = false; + } + }); + + it("passes a null resolution so signing performs no network lookup", async () => { + calls.length = 0; + await ledger().signTransaction("evm", PATH, TX); + + expect((calls[0]!.args as unknown[])[2]).toBeNull(); + }); +}); + +describe("Ledger signs EVM typed data", () => { + const DOMAIN = { + name: "Ether Mail", + version: "1", + chainId: 1, + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + }; + const TYPES = { Mail: [{ name: "contents", type: "string" }] }; + const MESSAGE = { contents: "Hello, Bob!" }; + + it("signs via the EIP-712 APDU and returns r||s||v", async () => { + calls.length = 0; + + const result = await ledger().signTypedData("evm", PATH, { + domain: DOMAIN, + types: TYPES, + message: MESSAGE, + }); + + expect(calls[0]!.method).toBe("signEIP712HashedMessage"); + expect(result.signature).toBe(`0x${"ee".repeat(32)}${"ff".repeat(32)}1c`); + expect(result.primaryType).toBe("Mail"); + }); + + // Asserts the digests the device is shown match ethers' EIP-712 encoder exactly. Note this + // does NOT discriminate against tronweb's TIP-712 encoder: that is a fork of the same ethers + // code which merely ALSO accepts TRON base58 addresses, so for EVM input the two agree today. + // The reason to use ethers here is coupling, not output — tronweb's fork is free to diverge, + // and EVM signing should not depend on a TRON SDK's typed-data implementation. + it("hashes exactly as the EIP-712 encoder does", async () => { + calls.length = 0; + await ledger().signTypedData("evm", PATH, { + domain: DOMAIN, + types: TYPES, + message: MESSAGE, + }); + + const [, domainHash, structHash] = calls[0]!.args as [string, string, string]; + expect(domainHash).toBe(TypedDataEncoder.hashDomain(DOMAIN).replace(/^0x/, "")); + expect(structHash).toBe( + TypedDataEncoder.hashStruct("Mail", TYPES, MESSAGE).replace(/^0x/, ""), + ); + }); +}); + +// For a legacy (type-0) transaction the ethereum app returns v already EIP-155-encoded +// (chainId*2 + 35 + parity), which needs three bytes on Sepolia — 11155111*2+35 = 0x1546b71. +// padStart(2,"0") cannot truncate, so the assembled signature is longer than 65 bytes and +// ethers rejects it. Typed transactions hide this: their v is a bare parity bit. +describe("Ledger signs a legacy EVM transaction", () => { + const legacy = { + type: 0, + chainId: 11155111, + nonce: 1, + to: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + value: 0n, + gasLimit: 21_000n, + gasPrice: 20_000_000_000n, + }; + + it("accepts an EIP-155-encoded v from the device", async () => { + calls.length = 0; + legacyV.value = (11155111 * 2 + 35).toString(16); // 0x1546b71 — three bytes + try { + const { raw } = (await ledger().signTransaction("evm", PATH, legacy)) as { raw: string }; + expect(Transaction.from(raw).chainId).toBe(11155111n); + } finally { + legacyV.value = "1c"; + } + }); +}); + +/** + * A device-signed transaction carries the same `{ raw, hash }` shape a software-signed one does. + * + * The pipeline does not know which signer produced a transaction, so a Ledger-signed one that + * returned a bare string would silently lose its locally derived id — and with it the protection + * `authoritativeTxId` gives against a node naming the wrong transaction. + */ +describe("Ledger EVM signed-transaction identity", () => { + it("returns raw and hash, like the software strategy", async () => { + calls.length = 0; + const signed = (await ledger().signTransaction("evm", PATH, TX)) as { + raw: string; + hash: string; + }; + + expect(signed.raw).toMatch(/^0x02/); + expect(signed.hash).toBe(keccak256(signed.raw)); + }); + + it("exposes the hash where localTxId finds it", async () => { + calls.length = 0; + const found = localTxId(await ledger().signTransaction("evm", PATH, TX)); + + expect(found).toMatch(/^0x[0-9a-f]{64}$/); + }); +}); diff --git a/ts/src/adapters/outbound/ledger/index.ts b/ts/src/adapters/outbound/ledger/index.ts index 08854ed73..1fee4894a 100644 --- a/ts/src/adapters/outbound/ledger/index.ts +++ b/ts/src/adapters/outbound/ledger/index.ts @@ -13,6 +13,7 @@ * This module never prints; callers print waiting prompts via StreamManager. */ import { utils as tronUtils } from "tronweb"; +import { Transaction, TypedDataEncoder, type TransactionLike } from "ethers"; import { assertTronTxIntegrity } from "../chain/tron/tx-integrity.js"; import type { SignedTx, @@ -49,6 +50,60 @@ interface TrxApp { ): Promise; } +/** Minimal shape of @ledgerhq/hw-app-eth's Eth we depend on. Unlike the TRON app it returns + * {v, r, s} components rather than a hex string, so the adapter assembles r||s||v itself. */ +interface EthApp { + getAddress(path: string, display?: boolean): Promise<{ publicKey: string; address: string }>; + getAppConfiguration(): Promise<{ version: string }>; + signTransaction( + path: string, + rawTxHex: string, + resolution: null, + ): Promise<{ v: string; r: string; s: string }>; + signPersonalMessage( + path: string, + messageHex: string, + ): Promise<{ v: number; r: string; s: string }>; + signEIP712HashedMessage?( + path: string, + domainSeparatorHex: string, + hashStructMessageHex: string, + ): Promise<{ v: number; r: string; s: string }>; +} + +type LedgerApp = TrxApp | EthApp; + +/** + * Which @ledgerhq app module backs each family. Adding a family = one entry (plus its shape). + * + * Thunks with LITERAL specifiers, not `import(variable)`: a dynamic specifier cannot be + * statically resolved, so the module load moves into the timed region (and `vi.mock`, which keys + * off the specifier, may not apply at all). Both cost real behaviour — a slow first import ate + * into the device timeout. + */ +const APP_LOADER: Record Promise> = { + tron: () => import("@ledgerhq/hw-app-trx"), + evm: () => import("@ledgerhq/hw-app-eth"), +}; + +/** + * {v, r, s} from the ethereum app -> Ethereum's 65-byte `r || s || v` hex. + * + * `v` is reduced to its PARITY BIT, because the app reports it differently per transaction type: + * a typed transaction gives a bare parity (0/1), but a legacy one gives it already EIP-155 + * encoded — `chainId * 2 + 35 + parity`, which needs three bytes on Sepolia and cannot fit the + * one byte a 65-byte signature has. Passing that through produced an over-long signature that + * ethers rejected outright. Parity is the only part that is not recoverable from the + * transaction itself, so ethers re-derives the rest from the chain id it already holds. + */ +function joinVrs(sig: { v: number | string; r: string; s: string }): string { + const raw = typeof sig.v === "number" ? BigInt(sig.v) : BigInt(`0x${sig.v.replace(/^0x/, "")}`); + // 0/1 and 27/28 are already bare; anything larger is EIP-155 encoded (odd chainId*2+35+parity). + const parity = raw < 27n ? raw & 1n : raw >= 35n ? (raw - 35n) & 1n : (raw - 27n) & 1n; + const v = (27n + parity).toString(16); + return `0x${sig.r.replace(/^0x/, "")}${sig.s.replace(/^0x/, "")}${v.padStart(2, "0")}`; +} + /** hw-app-trx wants a BIP32 path WITHOUT the leading "m/" (e.g. 44'/195'/0'/0/0). */ function ledgerPath(path: string): string { return path.replace(/^m\//, ""); @@ -134,7 +189,7 @@ export class Ledger { if (!FAMILIES[family].ledger) { throw new ExecutionError( "auth_required", - `Ledger ${family} app is not wired yet (only tron is supported)`, + `Ledger ${family} app is not wired yet`, ); } } @@ -151,7 +206,11 @@ export class Ledger { // An optional `signal` gives callers the same lever the timeout uses: aborting closes the // transport, which rejects the pending APDU and frees the native handle immediately instead of // leaving it open until this method's own timeout expires. - #bound(fn: (trx: TrxApp) => Promise, signal?: AbortSignal): Promise { + #bound( + family: ChainFamily, + fn: (app: A) => Promise, + signal?: AbortSignal, + ): Promise { let handle: { transport: unknown; close: () => Promise } | undefined; // `cancelled` matters because the abort can land before openTransport() resolves: at that // moment there is no handle to close, and a fire-once listener will not run again. Recording @@ -162,7 +221,7 @@ export class Ledger { handle?.close().catch(() => {}); }; const run = (async () => { - const Trx = unwrap TrxApp>(await import("@ledgerhq/hw-app-trx")); + const App = unwrap LedgerApp>(await APP_LOADER[family]()); try { handle = await openTransport(); } catch (e) { @@ -177,7 +236,7 @@ export class Ledger { ); } try { - return await fn(new Trx(handle.transport)); + return await fn(new App(handle.transport) as A); } finally { await handle.close().catch(() => {}); } @@ -195,8 +254,9 @@ export class Ledger { opts?.onWait?.(); this.assertWired(family); try { - return await this.#bound( - async (trx) => (await trx.getAddress(ledgerPath(path), opts?.display ?? false)).address, + return await this.#bound( + family, + async (app) => (await app.getAddress(ledgerPath(path), opts?.display ?? false)).address, ); } catch (e) { throw classifyDeviceError(e); @@ -210,9 +270,10 @@ export class Ledger { signal?: AbortSignal, ): Promise { this.assertWired(family); + if (family === "evm") return this.#signEvmTransaction(path, tx, signal); // The device signs raw_data_hex, so the same integrity rules the software strategy enforces // apply here — a Ledger account must not be the weaker signer. See tx-integrity.ts. - if (family === "tron") assertTronTxIntegrity(tx); + assertTronTxIntegrity(tx); const rawTxHex = (tx as { raw_data_hex?: string }).raw_data_hex; if (!rawTxHex) throw new ChainError( @@ -224,7 +285,7 @@ export class Ledger { const existing = (tx as { signature?: unknown }).signature; const prior = Array.isArray(existing) ? existing : []; try { - return await this.#bound(async (trx) => { + return await this.#bound(family, async (trx) => { const signature = await trx.signTransaction(ledgerPath(path), rawTxHex, []); return { ...(tx as object), @@ -245,8 +306,10 @@ export class Ledger { this.assertWired(family); const messageHex = Buffer.from(message, "utf8").toString("hex"); try { - return await this.#bound( - async (trx) => `0x${await trx.signPersonalMessage(ledgerPath(path), messageHex)}`, + return await this.#bound(family, async (app) => { + const signed = await app.signPersonalMessage(ledgerPath(path), messageHex); + return typeof signed === "string" ? `0x${signed}` : joinVrs(signed); + }, signal, ); } catch (e) { @@ -268,6 +331,7 @@ export class Ledger { signal?: AbortSignal, ): Promise { this.assertWired(family); + if (family === "evm") return this.#signEvmTypedData(path, payload, signal); const { domain, types, message } = payload; let digest: string; let primaryType: string; @@ -286,7 +350,7 @@ export class Ledger { ); } try { - return await this.#bound(async (trx) => { + return await this.#bound(family, async (trx) => { if (typeof trx.signTIP712HashedMessage !== "function") { throw new WalletError( "ledger_unsupported", @@ -305,10 +369,98 @@ export class Ledger { } } + /** + * The ethereum app signs the UNSIGNED typed-transaction serialisation and returns {v, r, s}; + * ethers reassembles it into the raw transaction `eth_sendRawTransaction` accepts. + */ + async #signEvmTransaction(path: string, tx: UnsignedTx, signal?: AbortSignal): Promise { + let transaction: Transaction; + try { + transaction = Transaction.from(tx as TransactionLike); + } catch (e) { + throw new ChainError( + "invalid_transaction", + `EVM transaction could not be encoded for Ledger signing: ${errMessage(e)}`, + ); + } + const unsignedHex = transaction.unsignedSerialized.replace(/^0x/, ""); + try { + return await this.#bound( + "evm", + async (eth) => { + // `resolution: null` on purpose — a non-null resolution makes hw-app-eth fetch + // clear-signing descriptors from Ledger's CDN mid-signature, and the CLI must not + // phone out while signing. The device shows the raw hash instead. + const signed = await eth.signTransaction(ledgerPath(path), unsignedHex, null); + transaction.signature = joinVrs(signed); + // `{ raw, hash }`, matching the software strategy: the pipeline does not know which + // signer produced a transaction, and a bare string would lose the locally derived id + // that authoritativeTxId uses to refuse a node's claim about which tx it accepted. + return { raw: transaction.serialized, hash: transaction.hash! }; + }, + signal, + ); + } catch (e) { + throw classifyDeviceError(e); + } + } + + async #signEvmTypedData( + path: string, + payload: TypedDataPayload, + signal?: AbortSignal, + ): Promise { + const { domain, types, message } = payload; + // ethers' EIP-712 encoder rather than tronweb's TIP-712 one. The two agree on EVM input + // today (TIP-712 is a fork of this same code that also accepts TRON base58 addresses), so + // this is about coupling, not a current behavioural difference: EVM signing must not depend + // on a TRON SDK's typed-data implementation, which is free to diverge. + const structTypes = Object.fromEntries( + Object.entries(types as Record).filter(([name]) => name !== "EIP712Domain"), + ) as Record>; + let digest: string; + let primaryType: string; + let domainHash: string; + let messageHash: string; + try { + primaryType = payload.primaryType ?? TypedDataEncoder.from(structTypes).primaryType; + digest = TypedDataEncoder.hash(domain as never, structTypes, message); + domainHash = TypedDataEncoder.hashDomain(domain as never).replace(/^0x/, ""); + messageHash = TypedDataEncoder.hashStruct(primaryType, structTypes, message).replace( + /^0x/, + "", + ); + } catch (e) { + throw new ChainError("invalid_transaction", `typed data could not be hashed: ${errMessage(e)}`); + } + try { + return await this.#bound( + "evm", + async (eth) => { + if (typeof eth.signEIP712HashedMessage !== "function") { + throw new WalletError( + "ledger_unsupported", + "this Ledger Ethereum app version cannot sign EIP-712 typed data; update the app", + ); + } + const signed = await eth.signEIP712HashedMessage( + ledgerPath(path), + domainHash, + messageHash, + ); + return { signature: joinVrs(signed), digest, primaryType }; + }, + signal, + ); + } catch (e) { + throw classifyDeviceError(e); + } + } + async appConfig(family: ChainFamily): Promise { this.assertWired(family); try { - return await this.#bound(async (trx) => ({ + return await this.#bound(family, async (trx) => ({ version: (await trx.getAppConfiguration()).version, ready: true, })); diff --git a/ts/src/adapters/outbound/persistence/migration.test.ts b/ts/src/adapters/outbound/persistence/migration.test.ts new file mode 100644 index 000000000..a4f875752 --- /dev/null +++ b/ts/src/adapters/outbound/persistence/migration.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AtomicFileStore } from "./fs/index.js"; +import { MigrationRunner, type MigrationStep } from "./migration.js"; + +function root(): string { + return mkdtempSync(join(tmpdir(), "migration-")); +} + +/** a step that bumps version and stamps a marker, so we can see it actually ran */ +function bumpStep(path: string): MigrationStep { + return { + path, + currentVersion: 2, + needsPassword: () => false, + migrate: (doc) => ({ ...(doc as object), version: 2, migrated: true }), + }; +} + +describe("MigrationRunner.plan", () => { + it("reports a file whose stored version lags the binary", () => { + const dir = root(); + const wallets = join(dir, "wallets.json"); + writeFileSync(wallets, JSON.stringify({ version: 1, wallets: [] })); + + const plan = new AtomicFileStore(); + const result = new MigrationRunner(plan).plan([bumpStep(wallets)]); + + expect(result.stale.map((s) => s.step.path)).toEqual([wallets]); + expect(result.needsPassword).toBe(false); + }); +}); + +describe("MigrationRunner.apply", () => { + it("writes each stale file's migrated document", () => { + const dir = root(); + const wallets = join(dir, "wallets.json"); + writeFileSync(wallets, JSON.stringify({ version: 1, wallets: [] })); + + const runner = new MigrationRunner(new AtomicFileStore()); + runner.apply(runner.plan([bumpStep(wallets)]).stale); + + expect(JSON.parse(readFileSync(wallets, "utf8"))).toEqual({ + version: 2, + wallets: [], + migrated: true, + }); + }); +}); + +describe("MigrationRunner pre-migration backup", () => { + // ADR-0008: writeJsonAll is crash-safe but not CHANGE-safe — it deletes its own backups on + // success. A migration that succeeds but is wrong would otherwise destroy the only copy. + it("keeps a copy of each file's pre-migration content", () => { + const dir = root(); + const wallets = join(dir, "wallets.json"); + const before = { version: 1, wallets: [], labels: { "wlt_a.0": "main" } }; + writeFileSync(wallets, JSON.stringify(before)); + + const runner = new MigrationRunner(new AtomicFileStore()); + runner.apply(runner.plan([bumpStep(wallets)]).stale); + + expect(JSON.parse(readFileSync(`${wallets}.v1.bak`, "utf8"))).toEqual(before); + }); +}); + +describe("MigrationRunner atomicity", () => { + it("leaves every file untouched when one step's migration throws", () => { + const dir = root(); + const wallets = join(dir, "wallets.json"); + const contacts = join(dir, "contacts.json"); + writeFileSync(wallets, JSON.stringify({ version: 1, wallets: [] })); + writeFileSync(contacts, JSON.stringify({ version: 1, entries: {} })); + + const exploding: MigrationStep = { + path: contacts, + currentVersion: 2, + needsPassword: () => false, + migrate: () => { + throw new Error("boom"); + }, + }; + + const runner = new MigrationRunner(new AtomicFileStore()); + const plan = runner.plan([bumpStep(wallets), exploding]); + + expect(() => runner.apply(plan.stale)).toThrow(/boom/); + expect(JSON.parse(readFileSync(wallets, "utf8"))).toEqual({ version: 1, wallets: [] }); + expect(existsSync(`${wallets}.v1.bak`)).toBe(false); + }); + + it("writes nothing when no file is stale", () => { + const dir = root(); + const wallets = join(dir, "wallets.json"); + writeFileSync(wallets, JSON.stringify({ version: 2, wallets: [] })); + + const runner = new MigrationRunner(new AtomicFileStore()); + runner.apply(runner.plan([bumpStep(wallets)]).stale); + + expect(readdirSync(dir)).toEqual(["wallets.json"]); + }); +}); + +// Every keystore mutator wraps its read-modify-write in withLock; the migration did not. The +// race it opens is the worst kind: process A reads the v1 document and blocks on an interactive +// password prompt while process B migrates and creates an account under the lock; A then writes +// the migration of its now-stale read, erasing B's account and orphaning its encrypted key blob. +describe("MigrationRunner holds the file lock while it writes", () => { + it("takes the lock for the files it migrates", () => { + const dir = root(); + const wallets = join(dir, "wallets.json"); + writeFileSync(wallets, JSON.stringify({ version: 1, wallets: [] })); + + const locked: string[] = []; + const store = new AtomicFileStore(); + const realLock = store.withLock.bind(store); + store.withLock = ((path: string, fn: () => unknown, opts?: unknown) => { + locked.push(path); + return realLock(path, fn as () => never, opts as never); + }) as typeof store.withLock; + + const runner = new MigrationRunner(store); + runner.apply(runner.plan([bumpStep(wallets)]).stale); + + expect(locked).toContain(wallets); + }); + + it("still writes the migrated document while holding it", () => { + const dir = root(); + const wallets = join(dir, "wallets.json"); + writeFileSync(wallets, JSON.stringify({ version: 1, wallets: [] })); + + const runner = new MigrationRunner(new AtomicFileStore()); + runner.apply(runner.plan([bumpStep(wallets)]).stale); + + expect(JSON.parse(readFileSync(wallets, "utf8")).version).toBe(2); + }); +}); diff --git a/ts/src/adapters/outbound/persistence/migration.ts b/ts/src/adapters/outbound/persistence/migration.ts new file mode 100644 index 000000000..c98a16715 --- /dev/null +++ b/ts/src/adapters/outbound/persistence/migration.ts @@ -0,0 +1,96 @@ +/** + * MigrationRunner — reads each registered file, decides what lags this binary, and applies the + * pending migrations as one transaction. Splitting plan from apply is deliberate: the gate must + * know whether a password will be needed BEFORE it prompts for one. + */ +import { planMigrations, storedVersionOf } from "../../../domain/migration/index.js"; +import type { AtomicFileStore } from "./fs/index.js"; + +export interface MigrationStep { + /** absolute path of the file this step owns. */ + path: string; + /** the version this binary expects the file to be at. */ + currentVersion: number; + /** whether migrating THIS document needs the master password (contents decide, not the file). */ + needsPassword(doc: unknown): boolean; + /** `password` is present only when this step's needsPassword() said so. */ + migrate(doc: unknown, password?: string): unknown; +} + +export interface StaleFile { + step: MigrationStep; + doc: unknown; + storedVersion: number; +} + +export interface RunnerPlan { + stale: StaleFile[]; + needsPassword: boolean; +} + +/** stable, never-pruned name for a file's pre-migration copy. */ +export function backupPathFor(path: string, storedVersion: number): string { + return `${path}.v${storedVersion}.bak`; +} + +export class MigrationRunner { + constructor(private readonly store: AtomicFileStore) {} + + plan(steps: MigrationStep[]): RunnerPlan { + const docs = new Map(); + const candidates = steps.map((step) => { + const doc = this.store.readJson(step.path); + docs.set(step.path, doc); + return { + path: step.path, + currentVersion: step.currentVersion, + storedVersion: storedVersionOf(doc, step.currentVersion, step.path), + needsPassword: doc === null ? false : step.needsPassword(doc), + }; + }); + + const plan = planMigrations(candidates); + const byPath = new Map(steps.map((s) => [s.path, s])); + return { + stale: plan.stale.map((c) => ({ + step: byPath.get(c.path)!, + doc: docs.get(c.path), + storedVersion: c.storedVersion, + })), + needsPassword: plan.needsPassword, + }; + } + + /** + * Applies every pending migration as ONE transaction: either the whole set lands, or none. + * The pre-migration copies ride inside the same transaction, so a rollback removes them too — + * nothing changed, so there is nothing to recover from. + */ + apply(stale: StaleFile[], password?: string): void { + if (stale.length === 0) return; + this.write(stale, password); + } + + /** + * Nested locks, one per migrated file, so the whole read-modify-write sits inside them — the + * same discipline every keystore mutator follows. + * + * Without it the race is the worst kind available here: process A reads the v1 document and + * blocks on an interactive password prompt while process B migrates and creates an account + * under the lock; A then writes the migration of its now-stale read, erasing B's account and + * orphaning its encrypted key blob. + */ + private write(stale: StaleFile[], password: string | undefined, held = 0): void { + if (held === stale.length) return this.commit(stale, password); + this.store.withLock(stale[held]!.step.path, () => this.write(stale, password, held + 1)); + } + + private commit(stale: StaleFile[], password: string | undefined): void { + this.store.writeJsonAll( + stale.flatMap(({ step, doc, storedVersion }) => [ + { path: backupPathFor(step.path, storedVersion), value: doc }, + { path: step.path, value: step.migrate(doc, password) }, + ]), + ); + } +} diff --git a/ts/src/adapters/outbound/price/coingecko.test.ts b/ts/src/adapters/outbound/price/coingecko.test.ts index 2370e11aa..af8fdd7f1 100644 --- a/ts/src/adapters/outbound/price/coingecko.test.ts +++ b/ts/src/adapters/outbound/price/coingecko.test.ts @@ -96,3 +96,81 @@ describe("CoinGeckoPriceProvider", () => { expect((await p.tokenUsd("tron:mainnet", ["TUnknown"])).get("TUnknown")).toBeNull(); }); }); + +describe("CoinGeckoPriceProvider — EVM", () => { + afterEach(() => vi.unstubAllGlobals()); + + function stub(body: unknown) { + const spy = vi.fn(async (..._args: unknown[]) => ({ ok: true, json: async () => body })); + vi.stubGlobal("fetch", spy); + return spy; + } + + // Prefix keying cannot express this: every tron network shares one coin, but evm:1 and evm:56 + // are DIFFERENT native coins, so EVM has to be enumerated per network id. + it.each([ + ["evm:1", "ethereum"], + ["evm:56", "binancecoin"], + ])("asks for %s's own native coin id (%s)", async (networkId, coinId) => { + const spy = stub({ [coinId]: { usd: 1234.5 } }); + + expect(await new CoinGeckoPriceProvider().nativeUsd(networkId)).toBe(1234.5); + expect(String(spy.mock.calls[0]![0])).toContain(`ids=${coinId}`); + }); + + it.each([ + ["evm:1", "ethereum"], + ["evm:56", "binance-smart-chain"], + ])("uses %s's own asset platform for token prices (%s)", async (networkId, platform) => { + const spy = stub({ "0xabc": { usd: 1 } }); + await new CoinGeckoPriceProvider().tokenUsd(networkId, ["0xabc"]); + + expect(String(spy.mock.calls[0]![0])).toContain(`/token_price/${platform}?`); + }); + + /** + * Testnets inherit their mainnet's price, in BOTH families. + * + * TRON already did this (`tron:nile` matches the `tron:` prefix), and the same command was + * reporting USD values on Nile while showing nulls on Sepolia. The valuation is fictional + * either way — testnet coins are not worth money — so the choice is which fiction to tell + * consistently, and the ruling is to tell the same one on both chains. + * + * The mapping is EXPLICIT, never a prefix: `evm:11155111` starts with `evm:1`, so a startsWith + * rule would price Sepolia as Ethereum by accident, and would also price Gnosis (`evm:100`) + * as Ethereum, which is simply wrong. + */ + it.each([ + ["evm:11155111", "ethereum"], + ["evm:97", "binancecoin"], + ])("prices the testnet %s from its mainnet coin (%s)", async (networkId, coinId) => { + const spy = stub({ [coinId]: { usd: 2500 } }); + + expect(await new CoinGeckoPriceProvider().nativeUsd(networkId)).toBe(2500); + expect(String(spy.mock.calls[0]![0])).toContain(`ids=${coinId}`); + }); + + it.each([ + ["evm:11155111", "ethereum"], + ["evm:97", "binance-smart-chain"], + ])("prices %s's tokens against its mainnet platform (%s)", async (networkId, platform) => { + const spy = stub({ "0xabc": { usd: 1 } }); + await new CoinGeckoPriceProvider().tokenUsd(networkId, ["0xabc"]); + + expect(String(spy.mock.calls[0]![0])).toContain(`/token_price/${platform}?`); + }); + + // The counterpart to inheritance: an id that merely SHARES A PREFIX with a known one must not + // inherit from it. Gnosis is not Ethereum, however similar `evm:100` looks to `evm:1`. + it.each(["evm:100", "evm:137", "evm:10"])("still reports no price for %s", async (networkId) => { + const spy = stub({ ethereum: { usd: 2500 } }); + + expect(await new CoinGeckoPriceProvider().nativeUsd(networkId)).toBeNull(); + expect(spy).not.toHaveBeenCalled(); + }); + + it("reports no price for a network it has never heard of", async () => { + stub({}); + expect(await new CoinGeckoPriceProvider().nativeUsd("evm:424242")).toBeNull(); + }); +}); diff --git a/ts/src/adapters/outbound/price/coingecko.ts b/ts/src/adapters/outbound/price/coingecko.ts index ad7a1a73b..58efe3758 100644 --- a/ts/src/adapters/outbound/price/coingecko.ts +++ b/ts/src/adapters/outbound/price/coingecko.ts @@ -7,10 +7,40 @@ import type { PriceProvider } from "../../../application/ports/price-provider.js export class CoinGeckoPriceProvider implements PriceProvider { readonly source = "coingecko"; - // CoinGecko native coin ids keyed by our network-id prefix (only TRON ships in phase 1). - static readonly #NATIVE_IDS: Record = { "tron:": "tron" }; - // CoinGecko asset-platform slugs for token_price lookups, keyed by network-id prefix. - static readonly #PLATFORMS: Record = { "tron:": "tron" }; + /** + * CoinGecko native coin ids. + * + * A testnet inherits its mainnet's price, in every family: TRON does so through the `tron:` + * prefix, and each EVM testnet is listed EXPLICITLY beside its mainnet. The explicit listing is + * the point — a bare `evm:` prefix would price every EVM chain as Ethereum, so an unlisted + * chain like Gnosis (`evm:100`) would be valued in ETH, which is a claim about money that + * nobody made. An unknown chain is worth `null`, not a guess. + * + * The cost of this rule is that testnet coins are valued as if they were real. That is a + * deliberate ruling for consistency with the TRON side, which has always behaved this way. + */ + static readonly #NATIVE_IDS: Record = { + "tron:": "tron", + "evm:1": "ethereum", + "evm:11155111": "ethereum", // Sepolia + "evm:56": "binancecoin", + "evm:97": "binancecoin", // BSC testnet + }; + /** + * CoinGecko asset-platform slugs for token_price lookups; same keying rule as above. + * + * A testnet contract is looked up against its MAINNET platform, which is usually a miss and so + * usually null. It is not guaranteed to be: deterministic deployment can place the same address + * on both chains, in which case a testnet token would take a mainnet token's price. TRON has + * always had this exposure through its prefix; the EVM entries now share it. + */ + static readonly #PLATFORMS: Record = { + "tron:": "tron", + "evm:1": "ethereum", + "evm:11155111": "ethereum", + "evm:56": "binance-smart-chain", + "evm:97": "binance-smart-chain", + }; constructor( private readonly baseUrl = "https://api.coingecko.com/api/v3", @@ -59,9 +89,19 @@ export class CoinGeckoPriceProvider implements PriceProvider { } } + /** + * Exact network id first, then family prefixes (the keys ending in ":"). + * + * The exact-first rule is not a nicety: a bare startsWith would let `evm:11155111` match the + * key `evm:1` BY ACCIDENT, and the same accident would catch every other chain whose id starts + * with those characters. Sepolia does inherit Ethereum's price, but because it is listed, not + * because its digits happen to line up. Prefix keys stay restricted to `family:`. + */ static #prefixed(map: Record, networkId: string): string | undefined { - for (const [prefix, value] of Object.entries(map)) { - if (networkId.startsWith(prefix)) return value; + const exact = map[networkId]; + if (exact) return exact; + for (const [key, value] of Object.entries(map)) { + if (key.endsWith(":") && networkId.startsWith(key)) return value; } return undefined; } diff --git a/ts/src/adapters/outbound/tokenbook/builtins.test.ts b/ts/src/adapters/outbound/tokenbook/builtins.test.ts new file mode 100644 index 000000000..72c52a525 --- /dev/null +++ b/ts/src/adapters/outbound/tokenbook/builtins.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { OFFICIAL_TOKENS } from "./builtins.js"; +import { isEvmAddress } from "../../../domain/address/index.js"; + +// These are constants nobody re-derives at runtime: `tx send --token USDT` trusts the book's +// contract AND decimals without asking the chain. A mistyped address is therefore a fund-loss +// shape — and EIP-55 is what catches it, since altering one character breaks the checksum. +describe("official EVM token entries", () => { + const evmNetworks = Object.entries(OFFICIAL_TOKENS).filter(([id]) => id.startsWith("evm:")); + + it.each(evmNetworks)("%s lists only valid, checksummed contracts", (_id, tokens) => { + for (const token of tokens) { + expect(isEvmAddress(token.id), `${token.symbol}: ${token.id}`).toBe(true); + expect(token.kind).toBe("erc20"); + } + }); + + // USDT is 6 decimals on Ethereum but 18 on BSC. Getting one wrong scales an amount by 10^12. + it.each(evmNetworks)("%s gives every token an explicit decimals", (_id, tokens) => { + for (const token of tokens) { + expect(Number.isInteger(token.decimals), token.symbol).toBe(true); + } + }); + + it("lists no contract twice on one network", () => { + for (const [id, tokens] of evmNetworks) { + const ids = tokens.map((t) => t.id.toLowerCase()); + expect(new Set(ids).size, id).toBe(ids.length); + } + }); + + // §5.4: "official 条目按规范 id 内置(evm:1 填 USDT / USDC;测试网留空)" + it("ships USDT and USDC on ethereum mainnet", () => { + expect(OFFICIAL_TOKENS["evm:1"]?.map((t) => t.symbol)).toEqual(["USDT", "USDC"]); + }); + + it.each(["evm:11155111", "evm:97"])("leaves the testnet %s empty", (id) => { + expect(OFFICIAL_TOKENS[id] ?? []).toEqual([]); + }); +}); diff --git a/ts/src/adapters/outbound/tokenbook/builtins.ts b/ts/src/adapters/outbound/tokenbook/builtins.ts index ae92c5d3d..da019ff33 100644 --- a/ts/src/adapters/outbound/tokenbook/builtins.ts +++ b/ts/src/adapters/outbound/tokenbook/builtins.ts @@ -46,4 +46,35 @@ export const OFFICIAL_TOKENS: Record = { }, ], "tron:shasta": [], + /** + * §5.4 — `evm:1` ships USDT / USDC; testnets stay empty, as `tron:shasta` already is. + * + * Each address, symbol and decimals below was read FROM ETHEREUM MAINNET (eth_call for + * symbol() / decimals() / name()) and cross-checked against Circle's published USDC address + * and Etherscan's USDT token page. That verification matters because `tx send --token USDT` + * takes the contract AND the decimals straight from here without asking the chain — a wrong + * address sends to the wrong contract, and wrong decimals scale the amount by a power of ten. + * Note USDT is 6 decimals here but 18 on BNB Smart Chain: never copy an entry between chains. + */ + "evm:1": [ + { + kind: "erc20", + id: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + symbol: "USDT", + decimals: 6, + name: "Tether USD", + }, + { + kind: "erc20", + id: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + symbol: "USDC", + decimals: 6, + name: "USD Coin", + }, + ], + // Not specified by §5.4, and BSC is where the decimals differ (USDT is 18 there) — left for a + // deliberate, sourced pass rather than filled from memory. + "evm:56": [], + "evm:11155111": [], + "evm:97": [], }; diff --git a/ts/src/adapters/outbound/tronlink/client.test.ts b/ts/src/adapters/outbound/tronlink/client.test.ts index 9ab9530fa..21f276152 100644 --- a/ts/src/adapters/outbound/tronlink/client.test.ts +++ b/ts/src/adapters/outbound/tronlink/client.test.ts @@ -18,7 +18,7 @@ const CONFIG = { } as Config; const NETWORK = { id: "tron:mainnet", - family: "tron", + family: "tron", nativeSymbol: "TRX", chainId: "mainnet", tronlinkHttpEndpoint: "https://api.walletadapter.org", } as NetworkDescriptor; diff --git a/ts/src/adapters/outbound/tronlink/client.ts b/ts/src/adapters/outbound/tronlink/client.ts index 6476b48a6..bdb497734 100644 --- a/ts/src/adapters/outbound/tronlink/client.ts +++ b/ts/src/adapters/outbound/tronlink/client.ts @@ -1,3 +1,4 @@ +import { isTronNetwork } from "../../../domain/types/network.js"; import { randomUUID } from "node:crypto"; import WebSocket, { type ClientOptions, type RawData } from "ws"; import { isLosslessNumber, parse as parseLosslessJson } from "lossless-json"; @@ -254,7 +255,8 @@ function httpError(response: Response): CliError { } function tronLinkEndpoint(network: NetworkDescriptor): string { - if (!network.tronlinkHttpEndpoint) { + const endpoint = isTronNetwork(network) ? network.tronlinkHttpEndpoint : undefined; + if (!endpoint) { throw new UsageError( "unsupported_network", `network ${network.id} has no TronLink collaboration endpoint`, @@ -262,7 +264,7 @@ function tronLinkEndpoint(network: NetworkDescriptor): string { } let parsed: URL; try { - parsed = new URL(network.tronlinkHttpEndpoint); + parsed = new URL(endpoint); } catch { throw new UsageError( "invalid_config", diff --git a/ts/src/application/ports/chain/gateway-provider.ts b/ts/src/application/ports/chain/gateway-provider.ts index 48ee9d72d..6c131fc65 100644 --- a/ts/src/application/ports/chain/gateway-provider.ts +++ b/ts/src/application/ports/chain/gateway-provider.ts @@ -1,14 +1,73 @@ import type { ChainFamily } from "../../../domain/family/index.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { Broadcaster } from "./broadcaster.js"; import type { TronGateway } from "./tron-gateway.js"; export interface NativeBalanceReader { getNativeBalance(address: string): Promise; } +/** + * The EVM gateway — the JSON-RPC reads the family's commands need. + * + * Every method speaks the CLI's vocabulary, not the wire's: QUANTITY values arrive as decimal + * strings and DATA stays hex (EIP-1474). Nothing above this port sees a `0x` quantity. + * Writes (`eth_sendRawTransaction`, gas estimation) land with the transaction commands. + */ +export interface EvmGateway extends NativeBalanceReader, Broadcaster { + /** the account's nonce, as a decimal string; `pending` includes our own unmined txs. */ + getTransactionCount(address: string, block?: "latest" | "pending"): Promise; + /** deployed bytecode as hex; `0x` for an account with no code. */ + getCode(address: string): Promise; + /** head height as a decimal string. */ + getBlockNumber(): Promise; + /** the node's block object verbatim (hex quantities, seconds); null when absent. + * Takes a decimal height or a block tag ("latest", "finalized", "safe"). */ + getBlock(numberOrTag?: string): Promise; + /** false when synced, else the node's progress object. */ + syncing(): Promise; + /** connected peers; hosted endpoints commonly refuse this call. */ + peerCount(): Promise; + clientVersion(): Promise; + /** base fee, gas price and suggested tip as decimal wei; a ZERO base fee is reported as "0", + * which is distinct from an absent one (BSC reports zero and is still EIP-1559). */ + feeData(): Promise<{ baseFeeWei?: string; gasPriceWei: string; suggestedPriorityWei?: string }>; + /** the node's gas estimate for a transaction, as a decimal string. */ + estimateGas(tx: Record): Promise; + /** calldata for a `{type, value}` call, encoded without sending it. */ + encodeFunctionCall(signature: string, params: Array<{ type: string; value: unknown }>): string; + /** deployment calldata: creation bytecode plus the constructor's ABI-encoded arguments. */ + encodeDeploy(bytecode: string, abiJson: string, params: unknown[]): string; + /** where a CREATE deployment will land, from the sender and nonce alone. */ + contractAddressFor(from: string, nonce: string): string; + /** calldata for an ERC-20 `transfer`; the amount is already in the token's base units. */ + encodeErc20Transfer(to: string, rawAmount: string): string; + /** serialise a transaction to the hex `tx sign`/`tx broadcast` exchange. */ + encodeTransactionHex(tx: unknown): string; + /** submit a signed transaction; `alreadyKnown` means it was in the mempool already. */ + sendRawTransaction(raw: string): Promise<{ hash?: string; alreadyKnown?: boolean }>; + /** the node's transaction object, or null when this node has no record of the hash. */ + getTransactionByHash(hash: string): Promise | null>; + /** the mined receipt, or null while pending. `success` comes from status, not from existing. */ + getTransactionReceipt(hash: string): Promise | null>; + /** a read-only contract call; `data` and the result are hex DATA. */ + call(to: string, data: string): Promise; + /** a read-only call named by signature with `{type, value}` params; result is raw hex DATA. */ + callFunction( + contract: string, + signature: string, + params: Array<{ type: string; value: unknown }>, + ): Promise; + /** ERC-20 balance as a decimal base-unit string. */ + getErc20Balance(contract: string, owner: string): Promise; + /** best-effort ERC-20 metadata; a field the contract does not answer is absent, never defaulted. */ + getErc20Metadata(contract: string): Promise<{ symbol?: string; decimals?: number; name?: string }>; +} + /** Family-keyed extension point. Add each new family gateway here without widening other ports. */ export interface ChainGatewayMap { tron: TronGateway; + evm: EvmGateway; } export type AnyChainGateway = ChainGatewayMap[ChainFamily]; diff --git a/ts/src/application/ports/contact-repository.ts b/ts/src/application/ports/contact-repository.ts index 81566a7bf..0d9c25447 100644 --- a/ts/src/application/ports/contact-repository.ts +++ b/ts/src/application/ports/contact-repository.ts @@ -4,5 +4,7 @@ export interface ContactRepository { add(entry: ContactEntry): ContactEntry; list(family: ChainFamily): ContactEntry[]; find(family: ChainFamily, nameKey: string): ContactEntry | undefined; + /** the entry with this name, whichever chain holds it — names are unique across the book. */ + findAnywhere(nameKey: string): ContactEntry | undefined; remove(family: ChainFamily, nameKey: string): ContactEntry; } diff --git a/ts/src/application/ports/network-registry.ts b/ts/src/application/ports/network-registry.ts index 835fb71de..314e7e903 100644 --- a/ts/src/application/ports/network-registry.ts +++ b/ts/src/application/ports/network-registry.ts @@ -5,4 +5,6 @@ export interface NetworkRegistry { /** fallback when no network override is supplied. */ resolveDefault(): NetworkDescriptor; all(): NetworkDescriptor[]; + /** the short name pointing at this id, if the alias book has one (ADR-0010). */ + aliasOf(id: string): string | undefined; } diff --git a/ts/src/application/services/evm-confirmation.test.ts b/ts/src/application/services/evm-confirmation.test.ts new file mode 100644 index 000000000..480326fd6 --- /dev/null +++ b/ts/src/application/services/evm-confirmation.test.ts @@ -0,0 +1,95 @@ +/** + * `--wait` for EVM. + * + * The trap this exists to avoid: a receipt is NOT proof of success. `status: 0x0` is a + * transaction that was mined, paid for its gas, and reverted — reporting that as confirmed would + * be the most damaging thing this CLI could get wrong about a transaction. + */ +import { describe, it, expect, vi } from "vitest"; +import { evmConfirmation } from "./evm-confirmation.js"; +import type { EvmGateway } from "../ports/chain/gateway-provider.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; + +const HASH = `0x${"ab".repeat(32)}`; + +function scope(waitTimeoutMs = 50): TransactionScope { + return { + activeAccount: "wlt_test", + resolveAddress: () => "0xADDR", + timeoutMs: 1000, + wait: true, + waitTimeoutMs, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +const gatewayReturning = (...receipts: Array | null>) => { + const queue = [...receipts]; + return { + getTransactionReceipt: vi.fn(async () => (queue.length > 1 ? queue.shift()! : queue[0]!)), + } as unknown as EvmGateway; +}; + +describe("evmConfirmation", () => { + it("reports a mined, successful transaction as confirmed", async () => { + const out = await evmConfirmation( + gatewayReturning({ success: true, gasUsed: "21000", feeWei: "22436119209000", blockNumber: 11551817 }), + scope(), + )(HASH); + + expect(out).toMatchObject({ + confirmed: true, + failed: false, + blockNumber: 11551817, + gasUsed: "21000", + feeWei: "22436119209000", + }); + }); + + // Mined and reverted. It cost the user real gas and did nothing they asked for. + it("reports a reverted transaction as failed, never as confirmed", async () => { + const out = await evmConfirmation( + gatewayReturning({ success: false, gasUsed: "21000", feeWei: "500", blockNumber: 42 }), + scope(), + )(HASH); + + expect(out).toMatchObject({ confirmed: true, failed: true, blockNumber: 42 }); + // the fee is still reported: a reverted transaction is not a free one. + expect(out!.feeWei).toBe("500"); + }); + + it("keeps polling while the transaction is still pending", async () => { + const gateway = gatewayReturning(null, { success: true, blockNumber: 7 }); + const out = await evmConfirmation(gateway, scope(5_000))(HASH); + + expect(out).toMatchObject({ confirmed: true, blockNumber: 7 }); + expect((gateway.getTransactionReceipt as ReturnType).mock.calls.length) + .toBeGreaterThan(1); + }); + + it("gives up at the wait timeout rather than hanging", async () => { + const out = await evmConfirmation(gatewayReturning(null), scope(30))(HASH); + + expect(out).toBeUndefined(); + }); + + it("treats an RPC failure as not-yet-confirmed rather than throwing", async () => { + const gateway = { + getTransactionReceipt: vi.fn(async () => { + throw new Error("endpoint down"); + }), + } as unknown as EvmGateway; + + await expect(evmConfirmation(gateway, scope(30))(HASH)).resolves.toBeUndefined(); + }); + + it("carries a deployed contract address through when the receipt names one", async () => { + const out = await evmConfirmation( + gatewayReturning({ success: true, blockNumber: 1, contractAddress: "0xdead" }), + scope(), + )(HASH); + + expect(out!.contractAddress).toBe("0xdead"); + }); +}); diff --git a/ts/src/application/services/evm-confirmation.ts b/ts/src/application/services/evm-confirmation.ts new file mode 100644 index 000000000..5fddd5485 --- /dev/null +++ b/ts/src/application/services/evm-confirmation.ts @@ -0,0 +1,44 @@ +import type { EvmGateway } from "../ports/chain/gateway-provider.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; + +const sleep = (milliseconds: number) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +/** + * Poll for an EVM transaction's receipt until it appears or `--wait` runs out. + * + * `confirmed` means "we have a receipt", and `failed` is read from the receipt's status — the two + * are separate on purpose. A transaction with `status: 0x0` was mined, paid for its gas, and + * reverted: it is confirmed AND failed, and collapsing those into one flag would let the CLI + * report a reverted transfer as a successful one. The realised fee is reported either way, + * because a reverted transaction is not a free one. + * + * Best-effort, like the TRON counterpart: an unreachable endpoint means "not confirmed yet", not + * an error — the transaction was already broadcast, and failing here would deny that. + */ +export function evmConfirmation( + gateway: EvmGateway, + scope: TransactionScope, +): (hash: string) => Promise | undefined> { + return async (hash) => { + const deadline = Date.now() + Math.max(0, scope.waitTimeoutMs); + for (;;) { + const receipt = await gateway.getTransactionReceipt(hash).catch(() => null); + if (receipt) { + return { + confirmed: true, + failed: receipt.success !== true, + ...(receipt.blockNumber === undefined ? {} : { blockNumber: receipt.blockNumber }), + ...(receipt.gasUsed === undefined ? {} : { gasUsed: receipt.gasUsed }), + ...(receipt.feeWei === undefined ? {} : { feeWei: receipt.feeWei }), + ...(receipt.contractAddress === undefined + ? {} + : { contractAddress: receipt.contractAddress }), + }; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) return undefined; + await sleep(Math.min(1500, remaining)); + } + }; +} diff --git a/ts/src/application/services/pipeline/pipeline.test.ts b/ts/src/application/services/pipeline/pipeline.test.ts index d77225733..17062729a 100644 --- a/ts/src/application/services/pipeline/pipeline.test.ts +++ b/ts/src/application/services/pipeline/pipeline.test.ts @@ -22,7 +22,7 @@ function scope(over: Partial = {}): TransactionScope { function params(signer: Signer, over: Partial = {}): TxPipelineParams { return { ctx: scope(), - net: { family: "tron" } as never, + net: { family: "tron", nativeSymbol: "TRX" } as never, account: "acct" as never, broadcaster: { broadcast: async () => ({ txId: "tx" }) } as never, build: async () => ({}) as never, diff --git a/ts/src/application/services/pipeline/sign-only.test.ts b/ts/src/application/services/pipeline/sign-only.test.ts index e1a9491b3..48200faa0 100644 --- a/ts/src/application/services/pipeline/sign-only.test.ts +++ b/ts/src/application/services/pipeline/sign-only.test.ts @@ -19,7 +19,7 @@ const scope = { emit: () => {}, warn: () => {}, } as never; -const net = { family: "tron", id: "nile" } as never; +const net = { family: "tron", nativeSymbol: "TRX", id: "nile" } as never; describe("TxPipeline.signOnly", () => { it("signs a caller-supplied transaction without building, estimating or broadcasting", async () => { diff --git a/ts/src/application/services/recipient-resolver.test.ts b/ts/src/application/services/recipient-resolver.test.ts index 90210e731..df87185e0 100644 --- a/ts/src/application/services/recipient-resolver.test.ts +++ b/ts/src/application/services/recipient-resolver.test.ts @@ -7,10 +7,14 @@ const ALICE = "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT"; describe("RecipientResolver", () => { const repository = { + findAnywhere: (key: string) => + key === "alice" + ? { family: "tron", name: "Alice", nameKey: "alice", address: ALICE, note: null } + : undefined, find: (_family: string, key: string) => key === "alice" ? { - family: "tron", + family: "tron", nativeSymbol: "TRX", name: "Alice", nameKey: "alice", address: ALICE, @@ -43,3 +47,139 @@ describe("RecipientResolver", () => { } }); }); + +const TRON = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; +const EVM = "0xe2E1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; + +function repoWith(entries: Array>): ContactRepository { + return { + find: (family: string, key: string) => + entries.find((e) => e.family === family && e.nameKey === key), + findAnywhere: (key: string) => entries.find((e) => e.nameKey === key), + } as unknown as ContactRepository; +} + +describe("RecipientResolver — EVM", () => { + const resolver = new RecipientResolver(repoWith([])); + + it("passes a checksummed EVM address straight through", () => { + expect(resolver.resolve("evm", EVM)).toEqual({ address: EVM }); + }); + + it("accepts an unchecksummed EVM address", () => { + expect(resolver.resolve("evm", EVM.toLowerCase())).toEqual({ address: EVM.toLowerCase() }); + }); + + // The TRON guard, now for EVM: a near-miss must not fall through to a name lookup. + it("never falls back to a contact for a mistyped EVM address", () => { + expect(() => resolver.resolve("evm", "0xe2e1a54926527Fbb4E4420DE4c6BAb82beAEE24D")).toThrow(); + }); + + // The attack this closes: a contact deliberately named like an address. contactName() now + // refuses to create one, but an entry planted before that guard must stay unreachable. + it("does not resolve a contact whose name mimics the mistyped address", () => { + const impostor = "0xe2e1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; + const resolverWithImpostor = new RecipientResolver( + repoWith([ + { family: "evm", nativeSymbol: "ETH", name: impostor, nameKey: impostor.toLowerCase(), address: "0xdead" }, + ]), + ); + + expect(() => resolverWithImpostor.resolve("evm", impostor)).toThrow(); + }); + + it("resolves a contact filed under evm", () => { + const withFriend = new RecipientResolver( + repoWith([{ family: "evm", nativeSymbol: "ETH", name: "Friend", nameKey: "friend", address: EVM }]), + ); + + expect(withFriend.resolve("evm", "friend")).toEqual({ address: EVM, contactName: "Friend" }); + }); + + it("does not see a contact filed under another family", () => { + const tronOnly = new RecipientResolver( + repoWith([{ family: "tron", nativeSymbol: "TRX", name: "Friend", nameKey: "friend", address: TRON }]), + ); + + expect(() => tronOnly.resolve("evm", "friend")).toThrow(); + }); +}); + +// A well-formed address of the WRONG family used to report contact_not_found, sending the user +// hunting for a contact they never created. Pasting a 0x address onto a TRON network is a +// first-day mistake with a two-family wallet. +describe("RecipientResolver reports a wrong-family address as such", () => { + it.each([ + ["evm", TRON], + ["tron", EVM], + ])("rejects a wrong-family address on %s with family_mismatch", (family, address) => { + let code: string | undefined; + try { + new RecipientResolver(repoWith([])).resolve(family as never, address); + } catch (e) { + code = (e as { code?: string }).code; + } + expect(code).toBe("family_mismatch"); + }); +}); + +// familyOf() only recognises a VALID address, so a wrong-family value with a broken checksum +// fell through to the generic branch and was described as the selected network's family — the +// message told a user pasting a mistyped 0x address onto TRON that it "resembles a tron address", +// naming the wrong chain's rules and sending them to check the wrong thing. +describe("RecipientResolver names the family the value actually looks like", () => { + const resolver = new RecipientResolver(repoWith([])); + + it("calls a broken EVM address evm, even on a TRON network", () => { + expect(() => resolver.resolve("tron", "0xe2e1a54926527Fbb4E4420DE4c6BAb82beAEE24D")).toThrow( + /evm/, + ); + }); + + it("calls a broken TRON address tron, even on an EVM network", () => { + expect(() => resolver.resolve("evm", "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL7")).toThrow(/tron/); + }); + + it("still names the selected family when the value looks like that family", () => { + expect(() => resolver.resolve("tron", "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL7")).toThrow(/tron/); + }); +}); + +// With names unique book-wide, a name that exists but belongs to another chain is a distinct +// and diagnosable case. It used to report contact_not_found, sending the user to look for a +// contact they can see in `contact list`. The message describes the ADDRESS, not the family — +// the user never has to learn that word. +describe("RecipientResolver explains a contact from another chain", () => { + it("reports family_mismatch rather than contact_not_found", () => { + const resolver = new RecipientResolver( + repoWith([{ family: "tron", name: "exchange", nameKey: "exchange", address: TRON }]), + ); + + let code: string | undefined; + try { + resolver.resolve("evm", "exchange"); + } catch (e) { + code = (e as { code?: string }).code; + } + expect(code).toBe("family_mismatch"); + }); + + it("names the contact and says the selected network cannot pay it", () => { + const resolver = new RecipientResolver( + repoWith([{ family: "tron", name: "exchange", nameKey: "exchange", address: TRON }]), + ); + + expect(() => resolver.resolve("evm", "exchange")).toThrow(/exchange/); + expect(() => resolver.resolve("evm", "exchange")).toThrow(/cannot pay|another chain/i); + }); + + it("still reports contact_not_found for a name that is nowhere", () => { + let code: string | undefined; + try { + new RecipientResolver(repoWith([])).resolve("evm", "nobody"); + } catch (e) { + code = (e as { code?: string }).code; + } + expect(code).toBe("contact_not_found"); + }); +}); diff --git a/ts/src/application/services/recipient-resolver.ts b/ts/src/application/services/recipient-resolver.ts index 28083c931..2e3c6dafd 100644 --- a/ts/src/application/services/recipient-resolver.ts +++ b/ts/src/application/services/recipient-resolver.ts @@ -1,30 +1,63 @@ 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 { addressCodec, familyOf } from "../../domain/family/index.js"; +import { contactNameKey, resembledFamily } from "../../domain/contact/index.js"; import { UsageError } from "../../domain/errors/index.js"; +/** + * Turns a `--to` value into an address. The ordering is the whole security property: + * + * 1. a valid address of the target family wins outright; + * 2. anything that merely LOOKS like an address is a hard error — never a contact lookup, + * because otherwise a checksum typo silently resolves to whoever registered that name; + * 3. only a value that could not be an address at all is treated as a contact name. + */ 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)) { + + if (addressCodec(family).validate(value)) { return { address: value }; } - // Never let a checksum typo fall through to a same-looking contact alias. - if (family === "tron" && resemblesTronAddress(value)) { + + // The family the value LOOKS like — by shape, so a mistyped address still names its own + // chain rather than the selected one. + const looksLike = resembledFamily(value); + if (looksLike) { + // A WELL-FORMED address of another family is a different mistake from a typo, and saying + // "contact not found" would send the user looking for a contact they never made. + if (looksLike !== family) { + const valid = familyOf(value) !== undefined; + throw new UsageError( + "family_mismatch", + valid + ? `recipient is a ${looksLike} address but the selected network is ${family}` + : `recipient looks like a ${looksLike} address, which the selected ${family} network cannot pay`, + ); + } throw new UsageError( "invalid_value", - "recipient resembles a TRON address but has an invalid length or checksum", + `recipient resembles a ${family} 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}`); + + const key = contactNameKey(value); + const entry = this.contacts.find(family, key); + if (entry) return { address: entry.address, contactName: entry.name }; + + // The name is unique book-wide, so if it exists at all it exists exactly once — and a hit + // here means it belongs to another chain. Reporting contact_not_found would send the user + // hunting for something they can plainly see in `contact list`. The message talks about the + // ADDRESS rather than the family: the user never has to learn that word. + const elsewhere = this.contacts.findAnywhere(key); + if (elsewhere) { + throw new UsageError( + "family_mismatch", + `contact ${elsewhere.name} holds the address ${elsewhere.address}, which the selected network cannot pay`, + ); } - return { address: entry.address, contactName: entry.name }; + throw new UsageError("contact_not_found", `contact not found: ${value}`); } } diff --git a/ts/src/application/services/signer/index.ts b/ts/src/application/services/signer/index.ts index 1694ad898..bcdbb4701 100644 --- a/ts/src/application/services/signer/index.ts +++ b/ts/src/application/services/signer/index.ts @@ -11,6 +11,7 @@ import { LedgerSigner } from "./ledger.js"; import { SoftwareSigner } from "./software.js"; import { Derivation } from "../../../domain/derivation/index.js"; import { WalletError } from "../../../domain/errors/index.js"; +import { FAMILIES } from "../../../domain/family/index.js"; export class SignerResolver { constructor( @@ -26,7 +27,8 @@ export class SignerResolver { * even --dry-run refuses a watch-only account rather than simulating a tx it could never send. * * `requireSoftware` additionally rejects Ledger accounts before any device interaction, for tx - * types the Ledger TRON app firmware cannot sign (e.g. contract deploy, cancel-all-unfreeze). + * types the family's Ledger app firmware cannot sign (e.g. TRON contract deploy, + * cancel-all-unfreeze — see the callers in the tron use cases). */ assertCanSign( refOrLabel: string, @@ -35,8 +37,12 @@ export class SignerResolver { ): void { const { wallet, index } = this.keystore.resolveAccount(refOrLabel); const address = walletAddress(wallet, family, index); - if (!address) - throw new WalletError("missing_wallet_address", `account has no ${family} address`); + if (!address) { + // The account exists but lives on another chain — the same condition resolveAddress + // reports, and the same code. `missing_wallet_address` reads as "you have no account", + // which is a different problem with a different fix. + throw new WalletError("family_mismatch", `account has no ${family} address`); + } if (wallet.source.type === "watch") { throw new WalletError( "watch_only_no_signer", @@ -46,7 +52,7 @@ export class SignerResolver { if (opts?.requireSoftware && wallet.source.type === "ledger") { throw new WalletError( "ledger_unsupported", - "this transaction type cannot be signed by the Ledger TRON app; use a software account", + `this transaction type cannot be signed by the Ledger ${FAMILIES[family].ledger?.app ?? family} app; use a software account`, ); } } @@ -54,8 +60,12 @@ export class SignerResolver { resolve(refOrLabel: string, family: ChainFamily): Signer { const { wallet, index } = this.keystore.resolveAccount(refOrLabel); const address = walletAddress(wallet, family, index); - if (!address) - throw new WalletError("missing_wallet_address", `account has no ${family} address`); + if (!address) { + // The account exists but lives on another chain — the same condition resolveAddress + // reports, and the same code. `missing_wallet_address` reads as "you have no account", + // which is a different problem with a different fix. + throw new WalletError("family_mismatch", `account has no ${family} address`); + } switch (wallet.source.type) { case "privateKey": { diff --git a/ts/src/application/services/signer/resolver.test.ts b/ts/src/application/services/signer/resolver.test.ts index 101d07a90..4faf7d949 100644 --- a/ts/src/application/services/signer/resolver.test.ts +++ b/ts/src/application/services/signer/resolver.test.ts @@ -26,7 +26,10 @@ describe("SignerResolver — watch accounts", () => { beforeEach(() => { ks = freshKeystore(); // ledger never touched for watch; strategies never touched (watch can't sign) - resolver = new SignerResolver(ks, {} as unknown as Ledger, { tron: tronSignStrategy }); + resolver = new SignerResolver(ks, {} as unknown as Ledger, { + tron: tronSignStrategy, + evm: null as never, // never reached: watch accounts cannot sign + }); }); it("refuses to sign for a watch-only account (watch_only_no_signer)", () => { @@ -72,6 +75,38 @@ describe("SignerResolver — watch accounts", () => { expect(err?.code).toBe("ledger_unsupported"); }); + // Same condition as resolveAddress: the account exists but lives on another chain. It reported + // `missing_wallet_address`, which reads as "you have no account" — a different problem. + it("reports family_mismatch for an account that has no address in the target family", () => { + const ref = ks.registerLedger({ + family: "evm", + path: "m/44'/60'/0'/0/0", + address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }).accountId; + + let code: string | undefined; + try { + resolver.assertCanSign(ref, "tron"); + } catch (e) { + code = (e as { code?: string }).code; + } + expect(code).toBe("family_mismatch"); + }); + + // The message named the TRON app unconditionally. With one ledger-wired family that was + // merely redundant; with two it tells an EVM user to blame the wrong application. + it("names the family's own Ledger app when refusing", () => { + const ref = ks.registerLedger({ + family: "evm", + path: "m/44'/60'/0'/0/0", + address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + }).accountId; + + expect(() => resolver.assertCanSign(ref, "evm", { requireSoftware: true })).toThrow( + /ethereum/i, + ); + }); + it("assertCanSign without requireSoftware still allows a Ledger account", () => { const ref = ks.registerLedger({ family: "tron", diff --git a/ts/src/application/services/target/index.ts b/ts/src/application/services/target/index.ts index c6b982214..1e63b38ac 100644 --- a/ts/src/application/services/target/index.ts +++ b/ts/src/application/services/target/index.ts @@ -2,9 +2,7 @@ import type { ChainFamily, NetworkDescriptor } from "../../../domain/types/index import type { ExecutionPolicy, ExecutionSelection } from "../../contracts/index.js"; 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; @@ -15,6 +13,13 @@ export interface ResolvedTarget { network?: NetworkDescriptor; } +/** + * Resolves WHICH network a command runs against. It deliberately does NOT judge the active + * account against that network: a command may resolve a network without ever needing one + * family's address (`current` picks which family's QR to draw), and the check prevented nothing + * — a command that does need the address fails at `resolveAddress`, still before any RPC. The + * guard therefore lives where the address is demanded, not where the network is chosen. + */ export class TargetResolver { constructor(private readonly deps: TargetResolverDeps) {} @@ -38,31 +43,13 @@ export class TargetResolver { if (policy.family && network.family !== policy.family) { throw new UsageError( - "network_family_mismatch", + "family_mismatch", `selected operation is ${policy.family}-only but network ${network.id} is ${network.family}`, ); } - const accountFamily = - policy.wallet !== "none" ? this.#singleFamilyAccount(selection) : undefined; - if (accountFamily && accountFamily !== network.family) { - const source = - reason === "explicit-network" ? `network ${network.id}` : `default network ${network.id}`; - throw new UsageError( - "network_family_mismatch", - `selected account is ${accountFamily}-only but ${source} is ${network.family}; pass --network for a ${accountFamily} network or change defaultNetwork`, - ); - } return { network }; } - #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 79d0b72f9..0a60c0282 100644 --- a/ts/src/application/services/target/target.test.ts +++ b/ts/src/application/services/target/target.test.ts @@ -7,8 +7,8 @@ const networks: Record = { "tron:mainnet": { id: "tron:mainnet", family: "tron", + nativeSymbol: "TRX", chainId: "mainnet", - aliases: ["tron"], capabilities: [], }, // synthetic non-tron network: exercises the cross-family rejection branches even though @@ -16,8 +16,8 @@ const networks: Record = { "evm:1": { id: "evm:1", family: "evm", + nativeSymbol: "ETH", chainId: "1", - aliases: ["eth"], capabilities: [], } as unknown as NetworkDescriptor, }; @@ -49,6 +49,7 @@ function resolver( all() { return Object.values(networks); }, + aliasOf: () => undefined, }; return new TargetResolver({ networkRegistry, @@ -83,8 +84,14 @@ describe("TargetResolver", () => { ); }); - it("rejects a single-family account on a mismatched default network", () => { + // Previously "rejects a single-family account on a mismatched default network". That check + // prevented nothing — without it, any command that actually needs the address fails at + // resolveAddress, still before any RPC — and it fired at the wrong moment: on RESOLVING a + // network rather than on DEMANDING an address. `current` resolves a network (to pick which + // family's QR to draw) but never demands one family's address, so it was refused for a + // condition that did not apply to it. The guard now lives where the address is demanded. + it("does not judge the account against the network — that belongs where an address is demanded", () => { const r = resolver("tron:mainnet", { type: "watch", family: "evm" as any }); - expect(() => r.resolve(policy("tron"), {})).toThrow(/selected account is evm-only/); + expect(r.resolve(policy("tron"), {}).network?.id).toBe("tron:mainnet"); }); }); diff --git a/ts/src/application/use-cases/account-balance-service.test.ts b/ts/src/application/use-cases/account-balance-service.test.ts new file mode 100644 index 000000000..d7d07ad58 --- /dev/null +++ b/ts/src/application/use-cases/account-balance-service.test.ts @@ -0,0 +1,67 @@ +/** + * AccountBalanceService — native balance, for any family. + * + * Nothing here is chain-specific: the gateway's neutral `client()` reads the balance, the family + * table supplies the base unit's decimals, and the SYMBOL comes off the network. That last split + * is the point of the test below — `evm:1` and `evm:56` are one family with two different coins. + */ +import { describe, it, expect } from "vitest"; +import { AccountBalanceService } from "./account-balance-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"; + +const scope: AccountScope = { activeAccount: "wlt_test.0", resolveAddress: () => "0xADDR" }; + +const gateways = (balance: string) => + ({ client: () => ({ getNativeBalance: async () => balance }) }) as unknown as ChainGatewayProvider; + +const network = (over: Partial): NetworkDescriptor => + ({ + id: "evm:1", + family: "evm", + nativeSymbol: "ETH", + chainId: "1", + capabilities: [], + ...over, + }) as NetworkDescriptor; + +describe("AccountBalanceService.balance", () => { + it("reports the raw base-unit balance with the family's decimals", async () => { + const out = await new AccountBalanceService(gateways("1000000000000000000")).balance( + scope, + network({}), + "evm", + ); + + expect(out).toEqual({ + address: "0xADDR", + balance: "1000000000000000000", + decimals: 18, + symbol: "ETH", + }); + }); + + it("uses TRON's 6 decimals for a TRON network", async () => { + const out = await new AccountBalanceService(gateways("1983993000")).balance( + scope, + network({ id: "tron:nile", family: "tron", nativeSymbol: "TRX", chainId: "nile" }), + "tron", + ); + + expect(out).toMatchObject({ decimals: 6, symbol: "TRX" }); + }); + + // The trap this whole split exists for: BNB and ETH are the same FAMILY. A symbol read off the + // family table would label a BNB balance "ETH" — a wallet naming the wrong currency. + it("takes the symbol from the network, not the family", async () => { + const bsc = await new AccountBalanceService(gateways("5")).balance( + scope, + network({ id: "evm:56", nativeSymbol: "BNB", chainId: "56" }), + "evm", + ); + + expect(bsc.symbol).toBe("BNB"); + expect(bsc.decimals).toBe(18); + }); +}); diff --git a/ts/src/application/use-cases/account-balance-service.ts b/ts/src/application/use-cases/account-balance-service.ts new file mode 100644 index 000000000..055813a1f --- /dev/null +++ b/ts/src/application/use-cases/account-balance-service.ts @@ -0,0 +1,29 @@ +import type { ChainFamily, NetworkDescriptor } from "../../domain/types/index.js"; +import { FAMILIES } from "../../domain/family/index.js"; +import type { AccountScope } from "../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../ports/chain/gateway-provider.js"; + +/** + * Native balance, for any family. + * + * Family-neutral by construction: the balance comes through the gateway provider's neutral + * `client()`, which every family's gateway satisfies, so this needs no per-family branch and no + * per-family copy. One implementation also means the symbol rule below cannot drift between + * chains — which is exactly how a wallet ends up naming the wrong currency. + */ +export class AccountBalanceService { + constructor(private readonly gateways: ChainGatewayProvider) {} + + async balance(scope: AccountScope, network: NetworkDescriptor, family: ChainFamily) { + const address = scope.resolveAddress(family); + return { + address, + balance: await this.gateways.client(network).getNativeBalance(address), + // Decimals are a FAMILY fact (sun→TRX is 6, wei→ether is 18) … + decimals: FAMILIES[family].nativeDecimals, + // … but the coin's name is a NETWORK fact. `evm:1` is ETH and `evm:56` is BNB, one family + // with two coins, so a family-level symbol would be right for at most one of them. + symbol: network.nativeSymbol, + }; + } +} diff --git a/ts/src/application/use-cases/config-service.test.ts b/ts/src/application/use-cases/config-service.test.ts index 8527023d9..474bcd425 100644 --- a/ts/src/application/use-cases/config-service.test.ts +++ b/ts/src/application/use-cases/config-service.test.ts @@ -117,3 +117,146 @@ describe("ConfigService GasFree credentials", () => { }); }); }); + +const twoNetworks = { + timeoutMs: 60_000, + waitTimeoutMs: 60_000, + aliases: { nile: "tron:nile", sepolia: "evm:11155111" }, + networks: { + "tron:nile": { id: "tron:nile", httpEndpoint: "https://nile.trongrid.io" }, + "evm:11155111": { id: "evm:11155111", httpEndpoint: "https://sepolia.example/abc123" }, + }, +} as unknown as Config; + +const registry = { + resolve: (id: string) => { + const key = { nile: "tron:nile", sepolia: "evm:11155111" }[id] ?? id; + const net = (twoNetworks.networks as Record)[key]; + if (!net) throw new Error(`unknown network: ${id}`); + return net; + }, +} as unknown as NetworkRegistry; + +// §2.4: `config networks` used to return only ids, so there was no way to confirm an endpoint +// change had taken effect. +describe("ConfigService networks view", () => { + it("maps each canonical id to its endpoint host", () => { + const { svc } = service(); + expect(svc.execute({ key: "networks" }, twoNetworks, registry)).toMatchObject({ + key: "networks", + value: { + "tron:nile": "nile.trongrid.io", + // host only — an endpoint may carry an API key in its path + "evm:11155111": "sepolia.example", + }, + }); + }); +}); + +describe("ConfigService networks..httpEndpoint", () => { + it("writes an endpoint addressed by canonical id", () => { + const { svc, update } = service(); + const result = svc.execute( + { key: "networks.evm:11155111.httpEndpoint", value: "https://my-node.example/key" }, + twoNetworks, + registry, + ); + + expect(result).toMatchObject({ key: "networks.evm:11155111.httpEndpoint" }); + expect(update).toHaveBeenCalled(); + }); + + // §2.4: an alias in the key is normalised to the canonical id ON WRITE, so config.yaml can + // never end up holding both `networks.sepolia` and `networks.evm:11155111`. + it("normalises an alias in the key to the canonical id", () => { + const { svc, update } = service(); + svc.execute( + { key: "networks.sepolia.httpEndpoint", value: "https://my-node.example" }, + twoNetworks, + registry, + ); + + const document = update.mock.calls[0]![0]({}).document as Record; + expect(Object.keys(document.networks)).toEqual(["evm:11155111"]); + }); + + it("rejects an unknown network in the key", () => { + const { svc } = service(); + expect(() => + svc.execute({ key: "networks.dogechain.httpEndpoint", value: "https://x" }, twoNetworks, registry), + ).toThrow(/dogechain/); + }); + + it("rejects a non-https endpoint", () => { + const { svc } = service(); + expect(() => + svc.execute({ key: "networks.nile.httpEndpoint", value: "ftp://nope" }, twoNetworks, registry), + ).toThrow(); + }); + + it("rejects a networks sub-key other than httpEndpoint", () => { + const { svc } = service(); + expect(() => + svc.execute({ key: "networks.nile.chainId", value: "9" }, twoNetworks, registry), + ).toThrow(/httpEndpoint/); + }); +}); + +// The alias book has no other visibility surface: there is no `config set aliases.*`, so without +// this a user must open config.yaml to find out what a short name resolves to. +describe("ConfigService alias book view", () => { + it("exposes the book as a read-only key", () => { + const { svc } = service(); + expect(svc.execute({ key: "aliases" }, twoNetworks, registry)).toMatchObject({ + key: "aliases", + value: { nile: "tron:nile", sepolia: "evm:11155111" }, + }); + }); + + it("includes the book in the whole-config view", () => { + const { svc } = service(); + expect(svc.execute({}, twoNetworks, registry)).toMatchObject({ + aliases: { nile: "tron:nile" }, + }); + }); + + it("refuses to write it", () => { + const { svc, update } = service(); + expect(() => svc.execute({ key: "aliases", value: "x" }, twoNetworks, registry)).toThrow( + /read-only/, + ); + expect(update).not.toHaveBeenCalled(); + }); +}); + +// `config` advertises itself as "read or set", but any nested key was routed unconditionally to +// the write path, so reading one failed with "needs a value". +describe("ConfigService reads a nested network key", () => { + it("returns the endpoint instead of demanding a value", () => { + const { svc } = service(); + expect(svc.execute({ key: "networks.evm:11155111.httpEndpoint" }, twoNetworks, registry)).toEqual( + { key: "networks.evm:11155111.httpEndpoint", value: "https://sepolia.example/abc123" }, + ); + }); + + it("resolves an alias in the key when reading, exactly as when writing", () => { + const { svc } = service(); + expect(svc.execute({ key: "networks.sepolia.httpEndpoint" }, twoNetworks, registry)).toMatchObject( + { key: "networks.evm:11155111.httpEndpoint" }, + ); + }); + + it("reads back what was just written", () => { + const { svc } = service(); + expect( + svc.execute({ key: "networks.nile.httpEndpoint" }, twoNetworks, registry), + ).toMatchObject({ value: "https://nile.trongrid.io" }); + }); + + it("still rejects an unwritable sub-key when reading", () => { + const { svc } = service(); + expect(() => svc.execute({ key: "networks.nile.chainId" }, twoNetworks, registry)).toThrow( + /httpEndpoint/, + ); + }); +}); diff --git a/ts/src/application/use-cases/config-service.ts b/ts/src/application/use-cases/config-service.ts index 7df230854..f79a7d4db 100644 --- a/ts/src/application/use-cases/config-service.ts +++ b/ts/src/application/use-cases/config-service.ts @@ -15,6 +15,7 @@ export const CONFIG_KEYS = [ "timeoutMs", "waitTimeoutMs", "networks", + "aliases", ...TRONLINK_CONFIG_KEYS, ...GASFREE_CONFIG_KEYS, ] as const; @@ -30,10 +31,25 @@ export type ConfigKey = (typeof CONFIG_KEYS)[number]; export type WritableConfigKey = (typeof WRITABLE_CONFIG_KEYS)[number]; export interface ConfigCommandInput { - key?: ConfigKey; + /** a flat key, or the nested `networks..httpEndpoint` path (§2.4). */ + key?: string; value?: string; } +/** `networks..httpEndpoint` — the only nested key. Parsed, not string-matched, so a + * wrong sub-key says which one is supported instead of "read-only". */ +const NETWORK_ENDPOINT_KEY = /^networks\.(.+)\.([^.]+)$/; + +interface NetworkEndpointKey { + networkRef: string; + field: string; +} + +function parseNetworkKey(key: string): NetworkEndpointKey | null { + const match = NETWORK_ENDPOINT_KEY.exec(key); + return match ? { networkRef: match[1]!, field: match[2]! } : null; +} + export class ConfigService { constructor(private readonly documents: ConfigDocumentRepository) {} @@ -47,7 +63,14 @@ export class ConfigService { defaultOutput: effective.defaultOutput, timeoutMs: effective.timeoutMs, waitTimeoutMs: effective.waitTimeoutMs, - networks: Object.keys(effective.networks), + // canonical id -> endpoint HOST. Ids alone gave no way to confirm a change took effect, + // and the full URL may carry an API key this listing has no business echoing. + networks: Object.fromEntries( + Object.entries(effective.networks).map(([id, n]) => [id, endpointHost(n.httpEndpoint)]), + ), + // Read-only, and the book's only visibility surface: there is no `config set aliases.*`, + // so without this the only way to see what a short name resolves to is to open config.yaml. + aliases: effective.aliases, tronlinkSecretId: effective.tronlinkSecretId, tronlinkSecretKey: maskSecret(effective.tronlinkSecretKey), tronlinkChannel: effective.tronlinkChannel, @@ -55,7 +78,18 @@ export class ConfigService { gasfreeApiSecret: maskSecret(effective.gasfreeApiSecret), }; if (input.key === undefined) return view; - if (input.value === undefined) return { key: input.key, value: view[input.key] }; + + const networkKey = parseNetworkKey(input.key); + if (networkKey) { + return input.value === undefined + ? readNetworkField(networkKey, effective, networks) + : this.setNetworkField(networkKey, input.value, networks); + } + + if (!CONFIG_KEYS.includes(input.key as ConfigKey)) { + throw new UsageError("invalid_value", `unknown config key: ${input.key}`); + } + if (input.value === undefined) return { key: input.key, value: view[input.key as ConfigKey] }; if (!WRITABLE_CONFIG_KEYS.includes(input.key as WritableConfigKey)) { throw new UsageError("invalid_value", `${input.key} is read-only`); } @@ -74,6 +108,29 @@ export class ConfigService { })); } + /** `networks..httpEndpoint` — the key's network ref is normalised to its canonical + * id before writing, so config.yaml can never hold the same network under two names (§2.4). */ + private setNetworkField( + { networkRef, field }: NetworkEndpointKey, + value: string, + networks: NetworkRegistry, + ): Record { + assertWritableNetworkField(field); + const id = networks.resolve(networkRef).id; + const key = `networks.${id}.httpEndpoint`; + const endpoint = httpsEndpoint(value, key); + return this.documents.update((current) => { + const existing = (current as { networks?: Record> }).networks; + return { + document: { + ...current, + networks: { ...existing, [id]: { ...existing?.[id], httpEndpoint: endpoint } }, + }, + result: { key, value: endpoint, input: value }, + }; + }); + } + private normalize( key: WritableConfigKey, raw: string, @@ -118,3 +175,47 @@ export class ConfigService { function maskSecret(value: string | undefined): string | undefined { return value ? "********" : undefined; } + +/** Reading the same key that `config set` writes — addressed by alias or canonical id alike, and + * answered with the effective value rather than only what config.yaml happens to hold. */ +function readNetworkField( + { networkRef, field }: NetworkEndpointKey, + effective: Config, + networks: NetworkRegistry, +): Record { + assertWritableNetworkField(field); + const id = networks.resolve(networkRef).id; + return { key: `networks.${id}.httpEndpoint`, value: effective.networks[id]?.httpEndpoint }; +} + +/** the one writable sub-key; named in the error so a typo says which one is supported. */ +function assertWritableNetworkField(field: string): void { + if (field !== "httpEndpoint") { + throw new UsageError( + "invalid_value", + `only networks..httpEndpoint is readable or writable; got networks..${field}`, + ); + } +} + +function endpointHost(url: unknown): string { + if (typeof url !== "string") return ""; + try { + return new URL(url).host; + } catch { + return ""; + } +} + +function httpsEndpoint(value: string, key: string): string { + let parsed: URL; + try { + parsed = new URL(value.trim()); + } catch { + throw new UsageError("invalid_value", `${key} must be an absolute URL`); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new UsageError("invalid_value", `${key} must be an http(s) URL`); + } + return parsed.toString(); +} diff --git a/ts/src/application/use-cases/contact-service.test.ts b/ts/src/application/use-cases/contact-service.test.ts new file mode 100644 index 000000000..dae6b8f71 --- /dev/null +++ b/ts/src/application/use-cases/contact-service.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { ContactService } from "./contact-service.js"; +import type { ContactRepository } from "../ports/contact-repository.js"; +import type { ContactEntry } from "../../domain/types/index.js"; + +const TRON = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; +const EVM = "0xe2E1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; + +function repo() { + const entries: ContactEntry[] = []; + return { + entries, + port: { + add: (e: ContactEntry) => { + entries.push(e); + return e; + }, + list: (family: string) => entries.filter((e) => e.family === family), + find: (family: string, key: string) => + entries.find((e) => e.family === family && e.nameKey === key), + remove: (family: string, key: string) => { + const i = entries.findIndex((e) => e.family === family && e.nameKey === key); + if (i < 0) throw Object.assign(new Error("not found"), { code: "not_found" }); + return entries.splice(i, 1)[0]!; + }, + } as unknown as ContactRepository, + }; +} + +// §3.11: an entry persists its family, and the family is inferred from the address — asking the +// user to restate what the address already says is a chance to get it wrong. +describe("ContactService infers the family from the address", () => { + it.each([ + ["tron", TRON], + ["evm", EVM], + ])("files a %s address under that family", (family, address) => { + const { port, entries } = repo(); + // the view carries no family — but the entry is still bucketed by one internally + expect(new ContactService(port).add("friend", address)).toMatchObject({ address }); + expect(entries).toMatchObject([{ family }]); + }); + + it("refuses an address belonging to no known family", () => { + const { port } = repo(); + expect(() => new ContactService(port).add("friend", "not-an-address")).toThrow(); + }); +}); + +describe("ContactService lists every family", () => { + it("returns contacts from both families, each carrying its own", () => { + const { port } = repo(); + const svc = new ContactService(port); + svc.add("tron-friend", TRON); + svc.add("evm-friend", EVM); + + // family is internal now; the address is what identifies the chain to a reader + expect(svc.list().contacts.map((c) => [c.name, c.address])).toEqual([ + ["tron-friend", TRON], + ["evm-friend", EVM], + ]); + }); +}); + +describe("ContactService removes by name alone", () => { + it("finds the entry whichever family holds it", () => { + const { port, entries } = repo(); + const svc = new ContactService(port); + svc.add("evm-friend", EVM); + + expect(svc.remove("evm-friend")).toMatchObject({ name: "evm-friend" }); + expect(entries).toHaveLength(0); + }); +}); + +// Externally the book is a flat map: one name, one address, both unique. Family is how the JSON +// buckets entries and how `--to` routes them — never something the user has to think about. +// Per-family uniqueness was never a decision; it was inherited from the storage shape, and it is +// what made `remove ` ambiguous and forced a --family flag into the design. +describe("ContactService keeps names and addresses unique across the whole book", () => { + it("refuses a name already used on another chain", () => { + const { port } = repo(); + const svc = new ContactService(port); + svc.add("exchange", TRON); + + expect(() => svc.add("exchange", EVM)).toThrow(); + }); + + it("names the clash rather than reporting a generic failure", () => { + const { port } = repo(); + const svc = new ContactService(port); + svc.add("exchange", TRON); + + let code: string | undefined; + try { + svc.add("exchange", EVM); + } catch (e) { + code = (e as { code?: string }).code; + } + expect(code).toBe("already_exists"); + }); + + // Two names for one address makes `contact list` show the same recipient twice and leaves no + // answer to "what is this address called". + it("refuses an address already stored under another name", () => { + const { port } = repo(); + const svc = new ContactService(port); + svc.add("exchange", EVM); + + expect(() => svc.add("exchange-2", EVM)).toThrow(/already_exists|already stored/); + }); + + it("still accepts a genuinely new name and address", () => { + const { port } = repo(); + const svc = new ContactService(port); + svc.add("exchange-tron", TRON); + + expect(svc.add("exchange-evm", EVM)).toMatchObject({ name: "exchange-evm" }); + }); + + // With names unique, removal is never ambiguous — no --family, no --network, no second + // positional. The disambiguation flag the spec called for stops being needed at all. + it("removes by name with nothing to disambiguate", () => { + const { port, entries } = repo(); + const svc = new ContactService(port); + svc.add("exchange", EVM); + + expect(svc.remove("exchange")).toMatchObject({ name: "exchange" }); + expect(entries).toHaveLength(0); + }); +}); diff --git a/ts/src/application/use-cases/contact-service.ts b/ts/src/application/use-cases/contact-service.ts index 11820e59f..48f8f4325 100644 --- a/ts/src/application/use-cases/contact-service.ts +++ b/ts/src/application/use-cases/contact-service.ts @@ -1,28 +1,62 @@ 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"; +import { CHAIN_FAMILIES, familyOf } from "../../domain/family/index.js"; +import { UsageError } from "../../domain/errors/index.js"; export class ContactService { constructor(private readonly contacts: ContactRepository) {} + /** + * The family comes from the address itself — asking the user to restate what the address + * already says is only a chance to disagree with it. + * + * Names and addresses are unique across the WHOLE book, not per family. Externally this is a + * flat name↔address map; family is only how entries are bucketed on disk and how `--to` routes + * them. Per-family uniqueness was never chosen — it fell out of the storage shape, and it is + * what made `remove ` ambiguous and pulled a `--family` flag into the design. + */ add(name: string, address: string, note?: string): ContactView { - return publicContact(this.contacts.add(createContact("tron", name, address, note))); + const value = address.trim(); + const family = familyOf(value); + if (!family) { + throw new UsageError("invalid_address", `not a recognised chain address: ${address}`); + } + const key = contactNameKey(name); + const clash = this.#entries().find((e) => e.nameKey === key || e.address === value); + if (clash) { + throw new UsageError( + "already_exists", + clash.nameKey === key + ? `a contact named ${clash.name} already exists` + : `that address is already stored as ${clash.name}`, + ); + } + return publicContact(this.contacts.add(createContact(family, name, value, note))); + } + + /** every entry, across families — the book as the user sees it. */ + #entries(): ContactEntry[] { + return CHAIN_FAMILIES.flatMap((family) => this.contacts.list(family)); } list(): ContactListView { - return { contacts: this.contacts.list("tron").map(publicContact) }; + return { contacts: this.#entries().map(publicContact) }; } + /** Addressed by name alone, which is unambiguous because names are unique book-wide. */ remove(name: string): ContactView { - return publicContact(this.contacts.remove("tron", contactNameKey(name))); + const key = contactNameKey(name); + const family = CHAIN_FAMILIES.find((f) => this.contacts.find(f, key)); + if (!family) { + throw new UsageError("contact_not_found", `contact not found: ${name}`); + } + return publicContact(this.contacts.remove(family, key)); } } +/** The user-facing shape: a name, an address, a note. No family — the address already says which + * chain it is, and family is an internal bucketing/routing detail. */ function publicContact(entry: ContactEntry): ContactView { - return { - name: entry.name, - address: entry.address, - note: entry.note, - family: entry.family, - }; + return { name: entry.name, address: entry.address, note: entry.note }; } diff --git a/ts/src/application/use-cases/evm/account-service.test.ts b/ts/src/application/use-cases/evm/account-service.test.ts new file mode 100644 index 000000000..015572a9f --- /dev/null +++ b/ts/src/application/use-cases/evm/account-service.test.ts @@ -0,0 +1,199 @@ +/** + * EvmAccountService — `account info` for the EVM family. + * + * TRON's `account info` returns the node's `getAccount` object plus derived bandwidth/energy + * resources. An EVM node has no equivalent call and no such object, so this reports the three + * facts an EVM account actually has: balance, nonce, and whether the address carries code. + */ +import { describe, it, expect } from "vitest"; +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"; + +const scope: AccountScope = { activeAccount: "wlt_test.0", resolveAddress: () => "0xADDR" }; +const net = { + id: "evm:1", + family: "evm", + nativeSymbol: "ETH", + chainId: "1", + capabilities: [], +} as NetworkDescriptor; + +function service(over: { balance?: string; nonce?: string; code?: string } = {}) { + const gateway = { + getNativeBalance: async () => over.balance ?? "0", + getTransactionCount: async () => over.nonce ?? "0", + getCode: async () => over.code ?? "0x", + }; + return new EvmAccountService({ get: () => gateway } as unknown as ChainGatewayProvider); +} + +describe("EvmAccountService.info", () => { + it("reports address, balance, nonce and symbol", async () => { + const out = await service({ balance: "1000000000000000000", nonce: "7" }).info(scope, net); + + expect(out).toMatchObject({ + address: "0xADDR", + balance: "1000000000000000000", + nonce: "7", + decimals: 18, + symbol: "ETH", + }); + }); + + // `eth_getCode` answers this and nothing else does: "0x" is an externally-owned account. + it("marks an address with no code as not a contract", async () => { + expect((await service({ code: "0x" }).info(scope, net)).isContract).toBe(false); + }); + + it("marks an address carrying bytecode as a contract", async () => { + expect((await service({ code: "0x60806040" }).info(scope, net)).isContract).toBe(true); + }); + + it("keeps the nonce a decimal string, so a large one cannot lose precision", async () => { + const out = await service({ nonce: "9007199254740993" }).info(scope, net); + expect(out.nonce).toBe("9007199254740993"); + }); +}); + +/** + * `account portfolio` on EVM. + * + * Structurally the same as the TRON side, and deliberately so: it is one command, and the row + * shape comes from the shared `portfolio-holdings` helpers rather than a second copy. What is + * EVM-specific is only how a balance is read — `eth_call` per ERC-20, in parallel. + * + * No multicall: that would mean a contract dependency and a per-chain address to verify, for a + * saving of a few round trips. + */ +describe("EvmAccountService.portfolio", () => { + const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; + const BOOK = [ + { kind: "erc20", id: USDT, symbol: "USDT", decimals: 6, source: "official" as const }, + ]; + + function portfolioService(over: { + native?: string; + balances?: Record; + nativePrice?: number | null; + tokenPrices?: Map; + pricesThrow?: boolean; + book?: unknown[]; + } = {}) { + const gateway = { + getNativeBalance: async () => over.native ?? "1000000000000000000", + getErc20Balance: async (contract: string) => { + const hit = (over.balances ?? { [USDT]: "5000000" })[contract]; + if (hit instanceof Error) throw hit; + return hit ?? "0"; + }, + }; + const prices = { + source: "coingecko", + nativeUsd: async () => { + if (over.pricesThrow) throw new Error("price boom"); + return over.nativePrice === undefined ? 1500 : over.nativePrice; + }, + tokenUsd: async () => { + if (over.pricesThrow) throw new Error("price boom"); + return over.tokenPrices ?? new Map([[USDT, 1]]); + }, + }; + const tokens = { effective: () => (over.book ?? BOOK) }; + return new EvmAccountService( + { get: () => gateway } as unknown as ChainGatewayProvider, + tokens as never, + prices as never, + ); + } + + it("lists the native coin first, priced and scaled", async () => { + const out = await portfolioService().portfolio(scope, net); + + expect(out.holdings[0]).toMatchObject({ + kind: "native", + symbol: "ETH", + decimals: 18, + balance: "1", + priceUsd: 1500, + valueUsd: 1500, + }); + }); + + it("lists each book token with its own decimals and price", async () => { + const out = await portfolioService().portfolio(scope, net); + + expect(out.holdings[1]).toMatchObject({ + kind: "erc20", + symbol: "USDT", + id: USDT, + decimals: 6, + balance: "5", + valueUsd: 5, + source: "official", + }); + }); + + it("totals only what it could value", async () => { + expect((await portfolioService().portfolio(scope, net)).totalValueUsd).toBe(1505); + }); + + // The whole point of reading per token: one bad contract must cost one row, not the listing. + it("degrades a single unreadable token without sinking the portfolio", async () => { + const out = await portfolioService({ + balances: { [USDT]: new Error("execution reverted") }, + }).portfolio(scope, net); + + expect(out.holdings[1]).toMatchObject({ + symbol: "USDT", + balanceUnavailable: true, + balance: null, + reason: "rpc_error", + }); + // the native row and the total survive + expect(out.holdings[0]!.valueUsd).toBe(1500); + expect(out.totalValueUsd).toBe(1500); + }); + + it("reports a stable reason when the price provider fails, and still lists balances", async () => { + const out = await portfolioService({ pricesThrow: true }).portfolio(scope, net); + + expect(out).toMatchObject({ priceUnavailable: true, priceReason: "price_provider_error" }); + expect(out.holdings[0]).toMatchObject({ balance: "1", priceUsd: null, valueUsd: null }); + expect(out.totalValueUsd).toBeNull(); + }); + + it("names the price source it used", async () => { + expect((await portfolioService().portfolio(scope, net)).priceSource).toBe("coingecko"); + }); + + it("reads every token in parallel rather than one after another", async () => { + const order: string[] = []; + const many = ["0xaa", "0xbb", "0xcc"].map((id, i) => ({ + kind: "erc20", + id, + symbol: `T${i}`, + decimals: 18, + source: "user" as const, + })); + const gateway = { + getNativeBalance: async () => "0", + getErc20Balance: async (contract: string) => { + order.push(`start:${contract}`); + await new Promise((r) => setTimeout(r, 5)); + order.push(`end:${contract}`); + return "0"; + }, + }; + const svc = new EvmAccountService( + { get: () => gateway } as unknown as ChainGatewayProvider, + { effective: () => many } as never, + { source: "x", nativeUsd: async () => null, tokenUsd: async () => new Map() } as never, + ); + await svc.portfolio(scope, net); + + // all three start before any finishes; a sequential loop would interleave start/end pairs. + expect(order.slice(0, 3).every((entry) => entry.startsWith("start:"))).toBe(true); + }); +}); diff --git a/ts/src/application/use-cases/evm/account-service.ts b/ts/src/application/use-cases/evm/account-service.ts new file mode 100644 index 000000000..20474dee8 --- /dev/null +++ b/ts/src/application/use-cases/evm/account-service.ts @@ -0,0 +1,122 @@ +import type { EffectiveTokenEntry, NetworkDescriptor } from "../../../domain/types/index.js"; +import type { TokenRepository } from "../../ports/token-repository.js"; +import type { PriceProvider } from "../../ports/price-provider.js"; +import { holding, portfolioTotal, unavailableHolding } from "../portfolio-holdings.js"; +import { FAMILIES } from "../../../domain/family/index.js"; +import type { AccountScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; + +/** + * EVM account reads. + * + * `info` deliberately does not mirror TRON's shape. TRON returns the node's `getAccount` object + * plus derived bandwidth/energy; an EVM node exposes no such call and no such object, so there + * is nothing to pass through. What an EVM account actually has is a balance, a nonce, and either + * code or no code — and those are what a user asks `account info` for. + */ +export class EvmAccountService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly tokens?: TokenRepository, + private readonly prices?: PriceProvider, + ) {} + + /** + * Every holding, valued. + * + * Balances are read PER TOKEN and in parallel, each degrading on its own: a delisted contract, + * a reverting `balanceOf` or an RPC hiccup costs that one row, not the listing. Deliberately + * not a multicall — that would add a contract dependency and a per-chain address to verify, to + * save a few round trips. + * + * The row shape comes from the shared helpers, so this listing and TRON's report the same + * fields for the same thing. + */ + 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 [nativeRaw, balances] = await Promise.all([ + gateway.getNativeBalance(address), + Promise.all( + tokens.map((token) => + gateway + .getErc20Balance(token.id, address) + .then((raw) => ({ raw }) as const) + // Swallow the underlying error rather than surfacing it: it can carry the endpoint + // (and any key in it) into a success payload. A stable reason goes on the row instead. + .catch(() => ({ unavailable: true }) as const), + ), + ), + ]); + + let priceUnavailable = false; + let nativePrice: number | null = null; + let tokenPrices = new Map(); + try { + [nativePrice, tokenPrices] = await Promise.all([ + this.prices!.nativeUsd(network.id), + this.prices!.tokenUsd( + network.id, + tokens.map((token) => token.id), + ), + ]); + } catch { + priceUnavailable = true; + } + + const holdings: Array> = [ + holding( + "native", + network.nativeSymbol, + FAMILIES.evm.nativeDecimals, + nativeRaw, + nativePrice, + ), + ...tokens.map((token: EffectiveTokenEntry, index) => { + const result = balances[index]!; + const extra = { id: token.id, name: token.name, source: token.source }; + return "unavailable" in result + ? unavailableHolding(token.kind, token.symbol, token.decimals, extra) + : holding( + token.kind, + token.symbol, + token.decimals, + result.raw, + tokenPrices.get(token.id) ?? null, + extra, + ); + }), + ]; + + return { + network: network.id, + account: scope.activeAccount, + address, + priceSource: this.prices!.source, + ...(priceUnavailable ? { priceUnavailable: true, priceReason: "price_provider_error" } : {}), + holdings, + totalValueUsd: portfolioTotal(holdings), + }; + } + + async info(scope: AccountScope, network: NetworkDescriptor) { + const address = scope.resolveAddress("evm"); + const gateway = this.gateways.get(network, "evm"); + const [balance, nonce, code] = await Promise.all([ + gateway.getNativeBalance(address), + gateway.getTransactionCount(address), + gateway.getCode(address), + ]); + return { + address, + balance, + // a decimal string, not a number: nonces are small today but the carrier stays lossless. + nonce, + decimals: FAMILIES.evm.nativeDecimals, + symbol: network.nativeSymbol, + // "0x" is the empty-code answer, i.e. an externally-owned account. + isContract: code !== "0x" && code !== "", + }; + } +} diff --git a/ts/src/application/use-cases/evm/block-service.ts b/ts/src/application/use-cases/evm/block-service.ts new file mode 100644 index 000000000..3485f00ea --- /dev/null +++ b/ts/src/application/use-cases/evm/block-service.ts @@ -0,0 +1,11 @@ +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; + +/** The node's block object, passed through unchanged — the sibling of TronBlockService. */ +export class EvmBlockService { + constructor(private readonly gateways: ChainGatewayProvider) {} + + async get(network: NetworkDescriptor, number?: string) { + return { block: await this.gateways.get(network, "evm").getBlock(number) }; + } +} diff --git a/ts/src/application/use-cases/evm/chain-service.test.ts b/ts/src/application/use-cases/evm/chain-service.test.ts new file mode 100644 index 000000000..9e6c5c00f --- /dev/null +++ b/ts/src/application/use-cases/evm/chain-service.test.ts @@ -0,0 +1,143 @@ +/** + * EvmChainService — `chain node`, the EVM counterpart of TRON's node status. + * + * Unlike `block` this is a computed view, not a passthrough: TRON's version already derives lag + * and sync state, and the same questions ("is this node behind?") need answering on EVM. + * + * Two mappings carry the design: + * - solid block → the `finalized` tag. Both mean "irreversible"; TRON calls it solid, EVM has + * called it finalized since the merge. + * - inSync → `eth_syncing`, which answers directly instead of TRON's head-timestamp heuristic. + * + * Hosted endpoints routinely refuse `net_peerCount`, and not every chain serves `finalized`. + * Neither may take the whole command down — they degrade to null, as §10 already specifies for + * fields an endpoint does not expose. + */ +import { describe, it, expect } from "vitest"; +import { EvmChainService } from "./chain-service.js"; +import { ChainError } from "../../../domain/errors/index.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", + chainId: "1", + httpEndpoint: "https://node.example", + capabilities: [], +} as NetworkDescriptor; + +const HEAD = { number: "0x12d687", timestamp: "0x66b1c0d0" }; +const FINALIZED = { number: "0x12d600" }; + +function service(over: Partial> = {}) { + const gateway = { + clientVersion: async () => over.clientVersion ?? "Geth/v1.14.0", + syncing: async () => (over.syncing === undefined ? false : over.syncing), + peerCount: async () => { + if (over.peerCount instanceof Error) throw over.peerCount; + return over.peerCount ?? "25"; + }, + getBlock: async (tag?: string) => { + if (tag === "finalized") { + if (over.finalized instanceof Error) throw over.finalized; + return over.finalized === undefined ? FINALIZED : over.finalized; + } + return over.head === undefined ? HEAD : over.head; + }, + }; + return new EvmChainService({ get: () => gateway } as unknown as ChainGatewayProvider); +} + +describe("EvmChainService.node", () => { + it("reports endpoint, version, head and peers", async () => { + const out = await service().node(net); + + expect(out).toMatchObject({ + endpoint: "https://node.example", + version: "Geth/v1.14.0", + headBlock: { number: 1234567 }, + peers: { connected: 25 }, + }); + }); + + it("maps the finalized block to the solid block and derives the lag", async () => { + const out = await service().node(net); + + expect(out.solidBlock).toEqual({ number: 1234432 }); + expect(out.lagBlocks).toBe(1234567 - 1234432); + }); + + it("reads sync state from eth_syncing rather than a timestamp heuristic", async () => { + expect((await service({ syncing: false }).node(net)).inSync).toBe(true); + expect((await service({ syncing: { currentBlock: "0x1" } }).node(net)).inSync).toBe(false); + }); + + it("degrades peers to null when the endpoint refuses net_peerCount", async () => { + const out = await service({ + peerCount: new ChainError("rpc_error", "method not supported"), + }).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); + + expect(out.solidBlock).toBeNull(); + expect(out.lagBlocks).toBeNull(); + }); + + it("still reports the head when the optional calls all fail", async () => { + const out = await service({ + peerCount: new ChainError("rpc_error", "no"), + finalized: new ChainError("rpc_error", "no"), + }).node(net); + + expect(out.headBlock.number).toBe(1234567); + }); +}); + +/** + * `chain prices` is family-shaped in the same way `account info` is: TRON reports energy and + * bandwidth unit prices, an EVM chain reports gas pricing. There is no shared field to align. + */ +describe("EvmChainService.prices", () => { + function priced(fee: Record, declared?: string) { + const gateway = { feeData: async () => fee }; + const svc = new EvmChainService({ get: () => gateway } as unknown as ChainGatewayProvider); + return svc.prices({ ...net, ...(declared ? { feeModel: declared } : {}) } as NetworkDescriptor); + } + + it("reports the 1559 fee fields on a chain with a base fee", async () => { + await expect( + priced({ baseFeeWei: "155315168", gasPriceWei: "155353216", suggestedPriorityWei: "100000" }), + ).resolves.toEqual({ + feeModel: "eip1559", + baseFeeWei: "155315168", + priorityFeeWei: "100000", + gasPriceWei: "155353216", + }); + }); + + // BSC: base fee zero is still EIP-1559, and the reported model must say so. + it("calls a zero base fee EIP-1559, not legacy", async () => { + await expect( + priced({ baseFeeWei: "0", gasPriceWei: "50000000", suggestedPriorityWei: "50000000" }), + ).resolves.toMatchObject({ feeModel: "eip1559", baseFeeWei: "0" }); + }); + + it("reports legacy pricing when the chain carries no base fee", async () => { + const out = await priced({ gasPriceWei: "3000000000" }); + + expect(out).toMatchObject({ feeModel: "legacy", gasPriceWei: "3000000000" }); + expect(out.baseFeeWei).toBeUndefined(); + }); + + it("honours a network that pins itself to legacy", async () => { + await expect(priced({ baseFeeWei: "100", gasPriceWei: "110" }, "legacy")).resolves.toMatchObject( + { feeModel: "legacy" }, + ); + }); +}); diff --git a/ts/src/application/use-cases/evm/chain-service.ts b/ts/src/application/use-cases/evm/chain-service.ts new file mode 100644 index 000000000..75d9e7e28 --- /dev/null +++ b/ts/src/application/use-cases/evm/chain-service.ts @@ -0,0 +1,87 @@ +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import { evmFeeMode } from "../../../domain/fees/evm-gas.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; + +/** 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)); + } catch { + return null; + } +} + +/** run an optional read, degrading to null instead of failing the whole command. */ +async function optional(read: () => Promise): Promise { + try { + return await read(); + } catch { + return null; + } +} + +export class EvmChainService { + constructor(private readonly gateways: ChainGatewayProvider) {} + + /** + * Gas pricing. Family-shaped, like `account info`: TRON reports energy and bandwidth unit + * prices, and an EVM chain has neither — there is no common field to align, so the two report + * different sets rather than a lowest common denominator that describes neither. + */ + async prices(network: NetworkDescriptor) { + const fee = await this.gateways.get(network, "evm").feeData(); + const mode = evmFeeMode(fee.baseFeeWei, network.feeModel); + return { + feeModel: mode, + // A zero base fee is reported as "0" and not dropped: on BSC that IS the base fee, and the + // difference between "zero" and "absent" is the difference between the two fee models. + ...(mode === "eip1559" && fee.baseFeeWei !== undefined + ? { baseFeeWei: fee.baseFeeWei, priorityFeeWei: fee.suggestedPriorityWei ?? null } + : {}), + gasPriceWei: fee.gasPriceWei, + }; + } + + /** + * Node status. A computed view, not a passthrough — the question being answered is "is this + * node behind?", which no single RPC call reports. + * + * `finalized` stands in for TRON's solid block: both name the last irreversible block. Neither + * it nor `net_peerCount` is universally served — plenty of hosted endpoints refuse the latter + * outright — so both degrade to null rather than taking the command down with them. + */ + async node(network: NetworkDescriptor) { + const gateway = this.gateways.get(network, "evm"); + const [version, syncing, peers, head, finalized] = await Promise.all([ + optional(() => gateway.clientVersion()), + optional(() => gateway.syncing()), + optional(() => gateway.peerCount()), + gateway.getBlock(), + optional(() => gateway.getBlock("finalized")), + ]); + + const headBlock = head as Record | null; + const headNumber = quantity(headBlock?.number) ?? 0; + const solidNumber = quantity((finalized as Record | null)?.number); + const headTimestamp = quantity(headBlock?.timestamp); + + return { + endpoint: network.httpEndpoint ?? null, + version, + // EVM nodes expose no p2p protocol version over JSON-RPC; TRON's getnodeinfo does. + p2pVersion: null, + headBlock: { + number: headNumber, + // seconds on the wire, milliseconds in this view — as TRON already reports. + timestamp: headTimestamp === null ? 0 : headTimestamp * 1000, + }, + solidBlock: solidNumber === null ? null : { number: solidNumber }, + lagBlocks: solidNumber === null ? null : headNumber - solidNumber, + // `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) }, + }; + } +} diff --git a/ts/src/application/use-cases/evm/contract-service.test.ts b/ts/src/application/use-cases/evm/contract-service.test.ts new file mode 100644 index 000000000..110d97537 --- /dev/null +++ b/ts/src/application/use-cases/evm/contract-service.test.ts @@ -0,0 +1,170 @@ +/** + * EvmContractService — read-only `contract call`. + * + * Thin on purpose, mirroring TronContractService: the ABI encoding lives in the gateway, where + * the TRON family already keeps it (TronWeb does that job there). The result comes back as raw + * hex, exactly as TRON's already does — `--method "balanceOf(address)"` declares parameter types + * and nothing about the return, so there is nothing to decode against without guessing. + */ +import { describe, it, expect, vi } from "vitest"; +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 TOKEN = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; +const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +function service(result = "0x") { + const seen: unknown[] = []; + const gateway = { + callFunction: async (...args: unknown[]) => { + seen.push(args); + return result; + }, + }; + return { + svc: new EvmContractService({ get: () => gateway } as unknown as ChainGatewayProvider), + seen, + }; +} + +describe("EvmContractService.call", () => { + it("passes the contract, signature and typed parameters to the gateway", async () => { + const { svc, seen } = service(); + const params = [{ type: "address", value: OWNER }]; + await svc.call(net, TOKEN, "balanceOf(address)", params); + + expect(seen[0]).toEqual([TOKEN, "balanceOf(address)", params]); + }); + + it("returns the node's result as raw hex, undecoded", async () => { + const raw = `0x${(123n).toString(16).padStart(64, "0")}`; + const { svc } = service(raw); + + await expect(svc.call(net, TOKEN, "decimals()", [])).resolves.toEqual({ + contract: TOKEN, + method: "decimals()", + result: raw, + }); + }); +}); + +/** + * `contract send` and `contract deploy`. + * + * Both go through the same pipeline as `tx send`, so the fee model, nonce source and broadcast + * guard are shared rather than re-implemented. What is specific here is the calldata and, for a + * deployment, the address — which CREATE derives from the sender and nonce, so it is known at + * signing time rather than only from a receipt. + */ +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; + +function scope(): TransactionScope { + return { + activeAccount: "wlt_test", + resolveAddress: () => OWNER, + timeoutMs: 100, + wait: false, + waitTimeoutMs: 100, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +function writeHarness() { + const gateway = { + getTransactionCount: vi.fn(async () => "9"), + feeData: vi.fn(async () => ({ + baseFeeWei: "100", + gasPriceWei: "110", + suggestedPriorityWei: "10", + })), + estimateGas: vi.fn(async () => "120000"), + encodeFunctionCall: vi.fn(() => "0xcalldata"), + encodeDeploy: vi.fn(() => "0xdeploydata"), + contractAddressFor: vi.fn(() => "0xDEPLOYED"), + encodeTransactionHex: vi.fn(() => "0xhex"), + getTransactionReceipt: vi.fn(async () => null), + }; + const built: Record[] = []; + const pipeline = { + assertCanSign: vi.fn(), + run: vi.fn(async (params: TxPipelineParams) => { + const tx = (await params.build(OWNER)) as Record; + built.push(tx); + return { stage: "plan" as const, tx, fee: await params.estimate(tx) }; + }), + } as unknown as TxPipeline; + const service = new EvmContractService( + { get: () => gateway } as unknown as ChainGatewayProvider, + pipeline, + ); + return { service, gateway, built }; +} + +describe("EvmContractService.send", () => { + it("addresses the contract and carries the encoded call", async () => { + const { service, built, gateway } = writeHarness(); + await service.send(scope(), net, { + contract: TOKEN, + method: "transfer(address,uint256)", + params: [{ type: "address", value: OWNER }], + } as never); + + expect(gateway.encodeFunctionCall).toHaveBeenCalled(); + expect(built[0]).toMatchObject({ to: TOKEN, data: "0xcalldata", value: "0", nonce: 9 }); + }); + + it("attaches native value when the call is payable", async () => { + const { service, built } = writeHarness(); + await service.send(scope(), net, { + contract: TOKEN, + method: "deposit()", + callValue: "1", + } as never); + + // 1 native coin at 18 decimals. + expect(built[0]!.value).toBe("1000000000000000000"); + }); + + it("uses the node's gas estimate rather than a transfer-sized default", async () => { + const { service, built } = writeHarness(); + await service.send(scope(), net, { contract: TOKEN, method: "deposit()" } as never); + + expect(built[0]!.gasLimit).toBe("120000"); + }); +}); + +describe("EvmContractService.deploy", () => { + const ABI = JSON.stringify([{ type: "constructor", inputs: [] }]); + + it("builds a transaction with no recipient", async () => { + const { service, built } = writeHarness(); + await service.deploy(scope(), net, { abi: ABI, bytecode: "0x6080", params: [] } as never); + + expect(built[0]!.to).toBeUndefined(); + expect(built[0]!.data).toBe("0xdeploydata"); + }); + + it("reports the CREATE address derived from sender and nonce", async () => { + const { service, gateway } = writeHarness(); + const out = (await service.deploy(scope(), net, { + abi: ABI, + bytecode: "0x6080", + params: [], + } as never)) as { contractAddress?: string }; + + expect(gateway.contractAddressFor).toHaveBeenCalledWith(OWNER, "9"); + expect(out.contractAddress).toBe("0xDEPLOYED"); + }); + + it("refuses an ABI that is not JSON rather than deploying blind", async () => { + const { service } = writeHarness(); + + await expect( + service.deploy(scope(), net, { abi: "{not json", bytecode: "0x60" } as never), + ).rejects.toMatchObject({ code: "invalid_value" }); + }); +}); diff --git a/ts/src/application/use-cases/evm/contract-service.ts b/ts/src/application/use-cases/evm/contract-service.ts new file mode 100644 index 000000000..0c0ff42c8 --- /dev/null +++ b/ts/src/application/use-cases/evm/contract-service.ts @@ -0,0 +1,191 @@ +import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index.js"; +import { UsageError } from "../../../domain/errors/index.js"; +import { FAMILIES } from "../../../domain/family/index.js"; +import { toBaseUnits } from "../../../domain/amounts/index.js"; +import { planEvmFee } from "../../../domain/fees/evm-gas.js"; +import { evmConfirmation } from "../../services/evm-confirmation.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider, EvmGateway } 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"; + +export interface EvmContractWriteInput extends TransactionModeInput { + contract?: string; + method?: string; + /** `{type,value}` entries for a call; raw positional values for a deployment. */ + params?: unknown[]; + /** native coin sent along with the call, in whole coins (as `tx send --amount` is). */ + callValue?: string; + abi?: string; + bytecode?: string; + gasLimit?: string; + maxFee?: string; + priorityFee?: string; + 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. + * + * Writes go through the shared pipeline, so the fee model, the pending-nonce rule and the + * broadcast guard are the same ones `tx send` uses rather than a second copy. + */ +export class EvmContractService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly pipeline?: TxPipeline, + ) {} + + /** + * A read-only call. The result comes back as raw hex, exactly as TRON's already does: a + * signature declares its parameter types and nothing about its return, so there is nothing to + * decode against without guessing. + */ + async call( + network: NetworkDescriptor, + contract: string, + method: string, + params: Array<{ type: string; value: unknown }>, + ) { + return { + contract, + method, + result: await this.gateways.get(network, "evm").callFunction(contract, method, params), + }; + } + + async send(scope: TransactionScope, network: NetworkDescriptor, input: EvmContractWriteInput) { + const gateway = this.gateways.get(network, "evm"); + const data = gateway.encodeFunctionCall( + input.method!, + (input.params ?? []) as Array<{ type: string; value: unknown }>, + ); + const value = + input.callValue === undefined + ? "0" + : toBaseUnits(input.callValue, FAMILIES.evm.nativeDecimals, "call value"); + + const outcome = await this.#run(scope, network, gateway, input, { + to: input.contract!, + data, + value, + }); + return { + kind: "contract-send" as const, + ...outcomeData(outcome), + contract: input.contract, + method: input.method, + }; + } + + /** + * Deploy a contract. The transaction has no recipient — that is what makes it a deployment — + * and the address is derived from the sender and nonce rather than waited for, because CREATE + * determines it entirely from those two. + */ + async deploy(scope: TransactionScope, network: NetworkDescriptor, input: EvmContractWriteInput) { + const gateway = this.gateways.get(network, "evm"); + if (input.abi !== undefined) { + try { + JSON.parse(input.abi); + } catch { + throw new UsageError("invalid_value", "--abi must be valid JSON"); + } + } + const data = gateway.encodeDeploy(input.bytecode!, input.abi ?? "[]", input.params ?? []); + let contractAddress: string | undefined; + + const outcome = await this.#run( + scope, + network, + gateway, + input, + { data, value: "0" }, + (from, nonce) => { + contractAddress = gateway.contractAddressFor(from, nonce); + }, + ); + return { + kind: "contract-deploy" as const, + ...outcomeData(outcome), + ...(contractAddress === undefined ? {} : { contractAddress }), + }; + } + + async #run( + scope: TransactionScope, + network: NetworkDescriptor, + gateway: EvmGateway, + input: EvmContractWriteInput, + call: Record, + onNonce?: (from: string, nonce: string) => void, + ) { + if (transactionRequiresSigner(input)) this.pipeline!.assertCanSign(scope.activeAccount, "evm"); + let plan: Record = {}; + return this.pipeline!.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...transactionMode(input), + confirm: evmConfirmation(gateway, scope), + 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 = + input.gasLimit ?? (await gateway.estimateGas({ from, ...call }).catch(() => undefined)); + if (gasEstimate === undefined) { + throw new UsageError( + "invalid_option", + "the node could not estimate gas for this call; pass --gas-limit to proceed", + ); + } + const resolved = planEvmFee({ + ...fee, + gasLimit: gasEstimate, + declaredFeeModel: network.feeModel, + overrides: overridesOf(input), + }); + plan = { + feeModel: resolved.mode, + maxCostWei: resolved.maxCostWei, + gasLimit: resolved.gasLimit, + }; + 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; + }, + }); + } +} diff --git a/ts/src/application/use-cases/evm/token-service.test.ts b/ts/src/application/use-cases/evm/token-service.test.ts new file mode 100644 index 000000000..a489b9dcd --- /dev/null +++ b/ts/src/application/use-cases/evm/token-service.test.ts @@ -0,0 +1,130 @@ +/** + * EvmTokenService — the ERC-20 half of the `token` group. + * + * `token add` is the single point where a token's decimals are checked against the chain: once an + * entry is in the book, `tx send --token SYMBOL` takes its contract and decimals verbatim and + * never asks the chain again. So a missing `decimals` has to be refused here, while a symbol the + * contract spells in the legacy `bytes32` form must not cost the user the entry. + */ +import { describe, it, expect } from "vitest"; +import { EvmTokenService } from "./token-service.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TokenRepository } from "../../ports/token-repository.js"; +import type { AccountScope } from "../../contracts/execution-scope.js"; +import type { NetworkDescriptor, TokenEntry } from "../../../domain/types/index.js"; + +const scope: AccountScope = { activeAccount: "wlt_test.0", resolveAddress: () => "0xOWNER" }; +const net = { id: "evm:1", family: "evm", nativeSymbol: "ETH" } as NetworkDescriptor; +const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; + +function service( + meta: { symbol?: string; decimals?: number; name?: string } = {}, + balance = "5000000", +) { + const removed: unknown[] = []; + const added: TokenEntry[] = []; + const gateway = { + getErc20Balance: async () => balance, + getErc20Metadata: async () => meta, + }; + const tokens = { + add: (_n: string, _a: string, entry: TokenEntry) => { + added.push(entry); + return "added" as const; + }, + remove: (...args: unknown[]) => { + removed.push(args); + return { kind: "erc20", id: USDT, symbol: "USDT", decimals: 6 }; + }, + } as unknown as TokenRepository; + const svc = new EvmTokenService( + { get: () => gateway } as unknown as ChainGatewayProvider, + tokens, + ); + return { svc, added, removed }; +} + +describe("EvmTokenService.balance", () => { + it("returns the raw balance with the contract's metadata", async () => { + const { svc } = service({ symbol: "USDT", decimals: 6 }); + + await expect(svc.balance(scope, net, { contract: USDT })).resolves.toMatchObject({ + address: "0xOWNER", + token: USDT, + balance: "5000000", + symbol: "USDT", + decimals: 6, + }); + }); + + it("still reports the balance when metadata is unreadable", async () => { + const { svc } = service({}); + const out = await svc.balance(scope, net, { contract: USDT }); + + expect(out.balance).toBe("5000000"); + expect(out.decimals).toBeUndefined(); + }); +}); + +describe("EvmTokenService.add", () => { + it("stores the chain's symbol and decimals as an erc20 entry", async () => { + const { svc, added } = service({ symbol: "USDT", decimals: 6, name: "Tether USD" }); + const out = await svc.add(scope, net, { contract: USDT }); + + expect(added[0]).toEqual({ + kind: "erc20", + id: USDT, + symbol: "USDT", + decimals: 6, + name: "Tether USD", + }); + expect(out).toMatchObject({ network: "evm:1", action: "added" }); + }); + + // The load-bearing rule: `tx send --token` trusts this number for every later transfer. + it("refuses to add a token whose decimals the chain did not report", async () => { + const { svc, added } = service({ symbol: "USDT" }); + + await expect(svc.add(scope, net, { contract: USDT })).rejects.toMatchObject({ + code: "token_metadata_unavailable", + }); + expect(added).toEqual([]); + }); + + it("refuses to add a token with no readable symbol", async () => { + const { svc } = service({ decimals: 6 }); + + await expect(svc.add(scope, net, { contract: USDT })).rejects.toMatchObject({ + code: "token_metadata_unavailable", + }); + }); + + it("never substitutes a default when decimals is absent", async () => { + const { svc, added } = service({ symbol: "X" }); + + await expect(svc.add(scope, net, { contract: USDT })).rejects.toThrow(); + expect(added.map((e) => e.decimals)).not.toContain(18); + }); +}); + +describe("EvmTokenService.remove", () => { + it("removes under the erc20 kind, not a TRON one", async () => { + const { svc, removed } = service(); + await svc.remove(scope, net, { contract: USDT }); + + expect(removed[0]).toEqual(["evm:1", "wlt_test.0", "erc20", USDT]); + }); +}); + +describe("EvmTokenService.info", () => { + it("reports the contract's metadata", async () => { + const { svc } = service({ symbol: "USDT", decimals: 6, name: "Tether USD" }); + + await expect(svc.info(net, { contract: USDT })).resolves.toMatchObject({ + contract: USDT, + symbol: "USDT", + decimals: 6, + name: "Tether USD", + }); + }); +}); diff --git a/ts/src/application/use-cases/evm/token-service.ts b/ts/src/application/use-cases/evm/token-service.ts new file mode 100644 index 000000000..de80151ab --- /dev/null +++ b/ts/src/application/use-cases/evm/token-service.ts @@ -0,0 +1,77 @@ +import type { NetworkDescriptor, TokenEntry } from "../../../domain/types/index.js"; +import { ExecutionError } from "../../../domain/errors/index.js"; +import type { AccountScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TokenRepository } from "../../ports/token-repository.js"; + +export interface Erc20Selector { + contract: string; +} + +/** + * The ERC-20 half of the `token` group. Sibling of TronTokenService; `token list` is neither + * family's, and lives in the neutral TokenBookService. + */ +export class EvmTokenService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly tokens: TokenRepository, + ) {} + + async balance(scope: AccountScope, network: NetworkDescriptor, input: Erc20Selector) { + const address = scope.resolveAddress("evm"); + const gateway = this.gateways.get(network, "evm"); + const [balance, meta] = await Promise.all([ + gateway.getErc20Balance(input.contract, address), + // Metadata only labels the number. A contract that answers balanceOf but not symbol() is + // odd, not fatal, so a failed read degrades the labels rather than the balance. + gateway.getErc20Metadata(input.contract).catch(() => ({})), + ]); + return { address, token: input.contract, balance, ...meta }; + } + + async info(network: NetworkDescriptor, input: Erc20Selector) { + const meta = await this.gateways.get(network, "evm").getErc20Metadata(input.contract); + return { contract: input.contract, ...meta }; + } + + /** + * Adding is the one moment a token's decimals are checked against the chain: from here on + * `tx send --token SYMBOL` takes the stored contract and decimals verbatim and never asks + * again. An unreadable `decimals` is therefore refused rather than defaulted — a wrong one + * would silently scale every later transfer by a power of ten. + * + * A `bytes32` symbol is not a defect of the same kind: the gateway already decodes that legacy + * spelling, and a symbol is a label that no arithmetic depends on. + */ + async add(scope: AccountScope, network: NetworkDescriptor, input: Erc20Selector) { + const meta = await this.gateways.get(network, "evm").getErc20Metadata(input.contract); + if (meta.decimals === undefined || meta.symbol === undefined || meta.symbol === "") { + throw new ExecutionError( + "token_metadata_unavailable", + `could not read symbol/decimals for ${input.contract}`, + ); + } + const token: TokenEntry = { + kind: "erc20", + id: input.contract, + symbol: meta.symbol, + decimals: meta.decimals, + ...(meta.name === undefined ? {} : { name: meta.name }), + }; + return { + network: network.id, + account: scope.activeAccount, + action: this.tokens.add(network.id, scope.activeAccount, token), + token, + }; + } + + async remove(scope: AccountScope, network: NetworkDescriptor, input: Erc20Selector) { + return { + network: network.id, + account: scope.activeAccount, + removed: this.tokens.remove(network.id, scope.activeAccount, "erc20", input.contract), + }; + } +} diff --git a/ts/src/application/use-cases/evm/transaction-service.test.ts b/ts/src/application/use-cases/evm/transaction-service.test.ts new file mode 100644 index 000000000..901012a63 --- /dev/null +++ b/ts/src/application/use-cases/evm/transaction-service.test.ts @@ -0,0 +1,513 @@ +/** + * EvmTransactionService.send — what actually gets signed. + * + * The fake pipeline runs the real `build` and `estimate` callbacks, so these assert the + * transaction the wallet would put in front of a key, not a mock of one. + */ +import { describe, expect, it, vi } from "vitest"; +import { EvmTransactionService } from "./transaction-service.js"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; + +const OWNER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const RECEIVER = "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"; +const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; + +const SEPOLIA = { + id: "evm:11155111", + family: "evm", + nativeSymbol: "ETH", + chainId: "11155111", + capabilities: [], +} satisfies NetworkDescriptor; + +function scope(): TransactionScope { + return { + activeAccount: "wlt_test", + resolveAddress: () => OWNER, + timeoutMs: 100, + wait: false, + waitTimeoutMs: 100, + emit: vi.fn(), + warn: vi.fn(), + }; +} + +/** captures the built transaction by running the pipeline's own callbacks. */ +function harness(over: Partial> = {}) { + const gateway = { + getTransactionCount: vi.fn(async () => (over.nonce as string) ?? "5"), + feeData: vi.fn(async () => (over.fee as object) ?? { + baseFeeWei: "100", + gasPriceWei: "110", + suggestedPriorityWei: "10", + }), + estimateGas: vi.fn(async () => (over.gasEstimate as string) ?? "21000"), + encodeErc20Transfer: vi.fn(() => "0xa9059cbb-encoded"), + }; + const built: Record[] = []; + const pipeline = { + assertCanSign: vi.fn(), + run: vi.fn(async (params: TxPipelineParams) => { + const tx = (await params.build(OWNER)) as Record; + built.push(tx); + return { stage: "plan" as const, tx, fee: await params.estimate(tx) }; + }), + } as unknown as TxPipeline; + const recipients = { resolve: vi.fn(() => ({ address: RECEIVER })) }; + const tokens = { effective: () => [] }; + const service = new EvmTransactionService( + { get: () => gateway } as unknown as ChainGatewayProvider, + tokens as never, + pipeline, + recipients as never, + ); + return { service, gateway, built, pipeline, recipients }; +} + +describe("EvmTransactionService.send — native transfer", () => { + it("builds a type-2 transaction with the resolved recipient and scaled value", async () => { + const { service, built } = harness(); + await service.send(scope(), SEPOLIA, { to: RECEIVER, amount: "1", feeLimit: "0" } as never); + + expect(built[0]).toMatchObject({ + type: 2, + chainId: 11155111, + to: RECEIVER, + // 1 ETH at 18 decimals — the family's decimals, not a hardcoded 6. + value: "1000000000000000000", + nonce: 5, + gasLimit: "21000", + maxFeePerGas: "210", + maxPriorityFeePerGas: "10", + }); + }); + + // A nonce read at "latest" would refuse to queue behind an unconfirmed transaction of our own. + it("takes the nonce from the pending block", async () => { + const { service, gateway } = harness(); + await service.send(scope(), SEPOLIA, { to: RECEIVER, amount: "1" } as never); + + expect(gateway.getTransactionCount).toHaveBeenCalledWith(OWNER, "pending"); + }); + + it("refuses to sign before anything else when the account cannot sign", async () => { + const { service, pipeline } = harness(); + await service.send(scope(), SEPOLIA, { to: RECEIVER, amount: "1" } as never); + + expect(pipeline.assertCanSign).toHaveBeenCalledWith("wlt_test", "evm"); + }); + + it("passes --raw-amount through without scaling it", async () => { + const { service, built } = harness(); + await service.send(scope(), SEPOLIA, { to: RECEIVER, rawAmount: "12345" } as never); + + expect(built[0]!.value).toBe("12345"); + }); +}); + +describe("EvmTransactionService.send — fee overrides", () => { + it("honours the four gas flags", async () => { + const { service, built } = harness(); + await service.send(scope(), SEPOLIA, { + to: RECEIVER, + amount: "1", + gasLimit: "90000", + maxFee: "500", + priorityFee: "20", + nonce: 42, + } as never); + + expect(built[0]).toMatchObject({ + gasLimit: "90000", + maxFeePerGas: "500", + maxPriorityFeePerGas: "20", + nonce: 42, + }); + }); + + it("builds a legacy transaction on a chain with no base fee", async () => { + const { service, built } = harness({ fee: { gasPriceWei: "3000000000" } }); + await service.send(scope(), SEPOLIA, { to: RECEIVER, amount: "1" } as never); + + expect(built[0]).toMatchObject({ type: 0, gasPrice: "3000000000" }); + expect(built[0]!.maxFeePerGas).toBeUndefined(); + }); + + it("rejects a 1559 flag on a legacy chain instead of ignoring it", async () => { + const { service } = harness({ fee: { gasPriceWei: "3000000000" } }); + + await expect( + service.send(scope(), SEPOLIA, { to: RECEIVER, amount: "1", maxFee: "500" } as never), + ).rejects.toMatchObject({ code: "invalid_option" }); + }); +}); + +describe("EvmTransactionService.send — ERC-20 transfer", () => { + it("sends to the contract with encoded calldata and zero value", async () => { + const { service, built, gateway } = harness(); + await service.send(scope(), SEPOLIA, { + to: RECEIVER, + contract: USDT, + amount: "5", + decimals: 6, + } as never); + + expect(gateway.encodeErc20Transfer).toHaveBeenCalled(); + expect(built[0]).toMatchObject({ to: USDT, value: "0", data: "0xa9059cbb-encoded" }); + }); + + it("scales a token amount by the token's decimals, not the chain's", async () => { + const { service, gateway } = harness(); + await service.send(scope(), SEPOLIA, { + to: RECEIVER, + contract: USDT, + amount: "5", + decimals: 6, + } as never); + + // 5 USDT at 6 decimals is 5_000_000 — using 18 would overpay by a factor of a trillion. + expect(gateway.encodeErc20Transfer).toHaveBeenCalledWith(RECEIVER, "5000000"); + }); + + it("refuses a token transfer whose decimals it could not establish", async () => { + const { service } = harness(); + + await expect( + service.send(scope(), SEPOLIA, { to: RECEIVER, contract: USDT, amount: "5" } as never), + ).rejects.toMatchObject({ code: "token_metadata_unavailable" }); + }); +}); + +describe("EvmTransactionService.send — the transaction it hands over", () => { + // The built transaction is echoed verbatim by --dry-run and --build-only, so it must contain + // only transaction fields. A fee plan smuggled through it as a courier reads like part of the + // transaction and is not one. + it("carries no bookkeeping fields of its own", async () => { + const { service, built } = harness(); + await service.send(scope(), SEPOLIA, { to: RECEIVER, amount: "1" } as never); + + expect(built[0]).not.toHaveProperty("fee"); + expect(Object.keys(built[0]!).sort()).toEqual( + ["chainId", "gasLimit", "maxFeePerGas", "maxPriorityFeePerGas", "nonce", "to", "type", "value"].sort(), + ); + }); + + it("still reports the fee plan through the pipeline's estimate hook", async () => { + const { service } = harness(); + const out = (await service.send(scope(), SEPOLIA, { + to: RECEIVER, + amount: "1", + dryRun: true, + } as never)) as { fee?: Record }; + + expect(out.fee).toMatchObject({ feeModel: "eip1559", maxCostWei: String(21000n * 210n) }); + }); +}); + +/** + * `tx sign` and `tx broadcast` on EVM. + * + * An EVM transaction carries exactly one signature — there is no multi-signature accumulation to + * relay — so signing takes an UNSIGNED serialisation and returns the signed one. That symmetry is + * why `tx broadcast` accepts only `--hex`/`--file`: the artifact both ends exchange is raw hex, + * and TRON's `--transaction` JSON has no EVM meaning. + */ +describe("EvmTransactionService.sign", () => { + function signHarness(signed: unknown = { raw: "0x02signed", hash: `0x${"ab".repeat(32)}` }) { + const seen: unknown[] = []; + const pipeline = { + assertCanSign: vi.fn(), + signOnly: vi.fn(async (p: { tx: unknown }) => { + seen.push(p.tx); + return { stage: "signed" as const, signed }; + }), + } as unknown as TxPipeline; + const service = new EvmTransactionService( + { get: () => ({}) } as unknown as ChainGatewayProvider, + { effective: () => [] } as never, + pipeline, + { resolve: vi.fn() } as never, + ); + return { service, seen, pipeline }; + } + + // Produced with ethers' own `unsignedSerialized`, not written by hand: a hand-rolled RLP body + // is exactly the kind of fixture that fails for a reason unrelated to what is being tested. + const UNSIGNED = + "0x02f083aa36a780830f4240847944848282520894000000000000000000000000000000000000dead87038d7ea4c6800080c0"; + + it("parses the unsigned hex and hands the pipeline a transaction", async () => { + const { service, seen } = signHarness(); + await service.sign(scope(), SEPOLIA, UNSIGNED); + + // ethers' toJSON carries bigints as strings; the signer re-parses them. + expect(seen[0]).toMatchObject({ chainId: "11155111", nonce: 0, sig: null }); + }); + + it("returns the signed serialisation and its hash", async () => { + const { service } = signHarness(); + const out = (await service.sign(scope(), SEPOLIA, UNSIGNED)) as { signed?: unknown }; + + expect(out.signed).toMatchObject({ raw: "0x02signed" }); + }); + + it("rejects input that is not a transaction rather than signing rubbish", async () => { + const { service, pipeline } = signHarness(); + + await expect(service.sign(scope(), SEPOLIA, "0xnot-a-transaction")).rejects.toMatchObject({ + code: "invalid_transaction", + }); + expect(pipeline.signOnly).not.toHaveBeenCalled(); + }); + + it("refuses an already-signed transaction instead of double-signing it", async () => { + const { service } = signHarness(); + const alreadySigned = + "0x02f87383aa36a780830f424084793b5e8282520894000000000000000000000000000000000000dead87038d7ea4c6800080c001a02958ee6a65975b5f6c2067d08704bc367375ee3fd54f1a0b4cbbc2643ab6b95ca0044e8cb5dea54b08c8b43b68a842e75e4f6627caa3911e4f9e5119ca12c01fc9"; + + await expect(service.sign(scope(), SEPOLIA, alreadySigned)).rejects.toMatchObject({ + code: "invalid_transaction", + }); + }); +}); + +describe("EvmTransactionService.broadcast", () => { + function bcHarness(result: Record = { hash: `0x${"cd".repeat(32)}` }) { + const seen: string[] = []; + const gateway = { + sendRawTransaction: vi.fn(async (raw: string) => { + seen.push(raw); + return result; + }), + getTransactionReceipt: vi.fn(async () => null), + }; + const service = new EvmTransactionService( + { get: () => gateway } as unknown as ChainGatewayProvider, + { effective: () => [] } as never, + {} as never, + { resolve: vi.fn() } as never, + ); + return { service, seen }; + } + + const SIGNED = + "0x02f87383aa36a780830f424084793b5e8282520894000000000000000000000000000000000000dead87038d7ea4c6800080c001a02958ee6a65975b5f6c2067d08704bc367375ee3fd54f1a0b4cbbc2643ab6b95ca0044e8cb5dea54b08c8b43b68a842e75e4f6627caa3911e4f9e5119ca12c01fc9"; + + it("submits the hex and reports the locally derived hash", async () => { + const { service, seen } = bcHarness(); + const out = (await service.broadcast(scope(), SEPOLIA, SIGNED)) as Record; + + expect(seen[0]).toBe(SIGNED); + // 0x6bfa29… is keccak of these bytes; the node's answer does not get to choose it. + expect(out.txId).toBe("0x6bfa290e4749ac903192c155d9b0f534ec9a8c8ab9dbb55bd155a91e3c0d7026"); + }); + + it("reports an already-known transaction as submitted, not as an error", async () => { + const { service } = bcHarness({ alreadyKnown: true }); + const out = (await service.broadcast(scope(), SEPOLIA, SIGNED)) as Record; + + expect(out.stage).toBe("submitted"); + expect(out.alreadyKnown).toBe(true); + }); + + it("refuses hex that is not a signed transaction", async () => { + const { service } = bcHarness(); + + await expect(service.broadcast(scope(), SEPOLIA, "0xdeadbeef")).rejects.toMatchObject({ + code: "invalid_transaction", + }); + }); +}); + +/** + * `tx status` and `tx info`. + * + * A receipt alone cannot tell "in the mempool" from "never existed" — `eth_getTransactionReceipt` + * answers null to both — so the transaction object is read alongside it, exactly as the TRON side + * reads getTransactionById beside getTransactionInfoById. + */ +describe("EvmTransactionService.status", () => { + function statusHarness(tx: unknown, receipt: unknown) { + const warn = vi.fn(); + const gateway = { + getTransactionByHash: vi.fn(async () => tx), + getTransactionReceipt: vi.fn(async () => receipt), + }; + const service = new EvmTransactionService( + { get: () => gateway } as unknown as ChainGatewayProvider, + { effective: () => [] } as never, + {} as never, + { resolve: vi.fn() } as never, + ); + return { service, scope: { ...scope(), warn } as TransactionScope, warn }; + } + const HASH = `0x${"ab".repeat(32)}`; + + it("reports a mined, successful transaction as confirmed", async () => { + const { service, scope: s } = statusHarness({ hash: HASH }, { success: true, blockNumber: 10 }); + + await expect(service.status(s, SEPOLIA, HASH)).resolves.toMatchObject({ + txid: HASH, + state: "confirmed", + confirmed: true, + failed: false, + blockNumber: 10, + }); + }); + + it("reports a mined but reverted transaction as failed", async () => { + const { service, scope: s } = statusHarness({ hash: HASH }, { success: false, blockNumber: 10 }); + + await expect(service.status(s, SEPOLIA, HASH)).resolves.toMatchObject({ + state: "failed", + confirmed: true, + failed: true, + }); + }); + + it("reports a transaction the node knows but has not mined as pending", async () => { + const { service, scope: s } = statusHarness({ hash: HASH }, null); + + await expect(service.status(s, SEPOLIA, HASH)).resolves.toMatchObject({ + state: "pending", + confirmed: false, + }); + }); + + it("reports an unknown hash as not_found", async () => { + const { service, scope: s } = statusHarness(null, null); + + await expect(service.status(s, SEPOLIA, HASH)).resolves.toMatchObject({ state: "not_found" }); + }); + + // A public endpoint may simply not keep old transactions. Reporting not_found without saying so + // invites the reader to conclude the transaction never happened, which may be false. + it("warns that not_found may mean the node lacks history, not that the tx never existed", async () => { + const { service, scope: s, warn } = statusHarness(null, null); + await service.status(s, SEPOLIA, HASH); + + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/histor|prun/i)); + }); + + it("does not warn when the transaction was found", async () => { + const { service, scope: s, warn } = statusHarness({ hash: HASH }, { success: true }); + await service.status(s, SEPOLIA, HASH); + + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe("EvmTransactionService.info", () => { + function infoHarness(tx: unknown, receipt: unknown = null, meta: unknown = { symbol: "USDT", decimals: 6 }) { + const gateway = { + getTransactionByHash: vi.fn(async () => tx), + getTransactionReceipt: vi.fn(async () => receipt), + getErc20Metadata: vi.fn(async () => meta), + }; + return new EvmTransactionService( + { get: () => gateway } as unknown as ChainGatewayProvider, + { effective: () => [] } as never, + {} as never, + { resolve: vi.fn() } as never, + ); + } + const HASH = `0x${"cd".repeat(32)}`; + const TO = "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"; + + it("reports a native transfer's parties and amount", async () => { + const svc = infoHarness( + { hash: HASH, from: OWNER, to: TO, value: "0xde0b6b3a7640000", input: "0x" }, + { success: true, blockNumber: 5, gasUsed: "21000", feeWei: "1000" }, + ); + + await expect(svc.info(scope(), SEPOLIA, HASH)).resolves.toMatchObject({ + txid: HASH, + from: OWNER, + to: TO, + amount: "1", + symbol: "ETH", + blockNumber: 5, + gasUsed: 21000, + feeWei: "1000", + status: "SUCCESS", + }); + }); + + // The ruling: decode `transfer(address,uint256)` and nothing else. Reporting the raw fields for + // an ERC-20 transfer would name the CONTRACT as the recipient and the amount as zero. + it("decodes an ERC-20 transfer to its real recipient and amount", async () => { + // transfer(0xbBbB…, 5000000) + const input = `0xa9059cbb${"0".repeat(24)}${TO.slice(2).toLowerCase()}${(5000000n) + .toString(16) + .padStart(64, "0")}`; + const svc = infoHarness({ hash: HASH, from: OWNER, to: USDT, value: "0x0", input }); + + const out = await svc.info(scope(), SEPOLIA, HASH); + // Same field names the TRON side reports for a TRC20 transfer: contract + symbol + a human + // amount scaled by the token's own decimals. + expect(out).toMatchObject({ + from: OWNER, + to: TO, + contract: USDT, + symbol: "USDT", + amount: "5", + }); + }); + + it("falls back to the base-unit amount when the token's decimals are unreadable", async () => { + const input = `0xa9059cbb${"0".repeat(24)}${TO.slice(2).toLowerCase()}${(5000000n) + .toString(16) + .padStart(64, "0")}`; + const svc = infoHarness({ hash: HASH, from: OWNER, to: USDT, value: "0x0", input }, null, {}); + + await expect(svc.info(scope(), SEPOLIA, HASH)).resolves.toMatchObject({ amount: "5000000" }); + }); + + it("leaves calldata it does not recognise alone", async () => { + const svc = infoHarness({ hash: HASH, from: OWNER, to: USDT, value: "0x0", input: "0xdeadbeef" }); + + const out = await svc.info(scope(), SEPOLIA, HASH); + // still the contract, because guessing at unknown calldata is exactly what was ruled out + expect(out.to).toBe(USDT); + expect(out.contract).toBeUndefined(); + }); + + it("refuses a hash the node has never seen", async () => { + const svc = infoHarness(null); + + await expect(svc.info(scope(), SEPOLIA, HASH)).rejects.toMatchObject({ code: "not_found" }); + }); +}); + +describe("EvmTransactionService.info address style", () => { + const HASH = `0x${"ef".repeat(32)}`; + // Nodes return addresses in lower case. Every address this CLI prints elsewhere — wallet + // addresses, the calldata-decoded recipient below — is EIP-55, so one payload must not mix the + // two styles: a reader comparing `from` against their own address would see a mismatch. + it("checksums the transaction's own from and to", async () => { + const gateway = { + getTransactionByHash: async () => ({ + hash: HASH, + from: "0xe4aad11792f7e74f1b5cbce65f9a1e207c952961", + to: "0x000000000000000000000000000000000000dead", + value: "0x0", + input: "0x", + }), + getTransactionReceipt: async () => null, + getErc20Metadata: async () => ({}), + }; + const svc = new EvmTransactionService( + { get: () => gateway } as unknown as ChainGatewayProvider, + { effective: () => [] } as never, + {} as never, + { resolve: vi.fn() } as never, + ); + + const out = await svc.info(scope(), SEPOLIA, HASH); + expect(out.from).toBe("0xe4aAd11792F7E74f1B5cbce65f9a1E207c952961"); + expect(out.to).toBe("0x000000000000000000000000000000000000dEaD"); + }); +}); diff --git a/ts/src/application/use-cases/evm/transaction-service.ts b/ts/src/application/use-cases/evm/transaction-service.ts new file mode 100644 index 000000000..a45c4c43e --- /dev/null +++ b/ts/src/application/use-cases/evm/transaction-service.ts @@ -0,0 +1,402 @@ +import type { + AccountRef, + NetworkDescriptor, + TxInfoView, + TxStatusView, + UnsignedTx, +} from "../../../domain/types/index.js"; +import { Transaction } from "ethers"; +import { ChainError, ExecutionError, UsageError } from "../../../domain/errors/index.js"; +import { authoritativeTxId } from "../../services/broadcast-identity.js"; +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 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"; +import type { TokenRepository } from "../../ports/token-repository.js"; +import type { TxPipeline } from "../../services/pipeline/index.js"; +import type { RecipientResolver } from "../../services/recipient-resolver.js"; +import { + outcomeData, + transactionMode, + transactionRequiresSigner, + type TransactionModeInput, +} from "../../services/transaction-mode.js"; + +export interface EvmSendInput extends TransactionModeInput { + to: string; + token?: string; + contract?: string; + /** the token's decimals, resolved from the address book by the caller. */ + decimals?: number; + amount?: string; + rawAmount?: string; + gasLimit?: string; + maxFee?: string; + priorityFee?: string; + nonce?: number; +} + +export class EvmTransactionService { + constructor( + private readonly gateways: ChainGatewayProvider, + private readonly tokens: TokenRepository, + private readonly pipeline: TxPipeline, + private readonly recipients: RecipientResolver, + ) {} + + async send(scope: TransactionScope, network: NetworkDescriptor, input: EvmSendInput) { + if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "evm"); + const gateway = this.gateways.get(network, "evm"); + const recipient = this.recipients.resolve("evm", input.to); + const transfer = this.resolveTransfer(network.id, scope.activeAccount, input); + + // The plan is produced while building and read back by the estimate hook. It is held here + // rather than attached to the transaction: --dry-run and --build-only echo that object + // verbatim, and a fee plan riding along inside it reads as part of the transaction. + let plan: Record = {}; + const outcome = await this.pipeline.run({ + ctx: scope, + net: network, + account: scope.activeAccount, + broadcaster: gateway, + ...transactionMode(input), + confirm: evmConfirmation(gateway, scope), + artifact: (tx) => gateway.encodeTransactionHex(tx), + build: async (from) => { + const { tx, fee } = await this.#build( + gateway, + network, + from, + recipient.address, + transfer, + input, + ); + plan = fee; + return tx; + }, + // The plan already carries the ceiling, so there is nothing further to ask the node. + estimate: async () => plan, + }); + + return { + kind: "send" as const, + ...outcomeData(outcome), + rawAmount: transfer.rawAmount, + token: transfer.symbol, + decimals: transfer.decimals, + contract: transfer.contract, + to: recipient.address, + ...(recipient.contactName ? { toContact: recipient.contactName } : {}), + }; + } + + /** + * Scale the amount. A token is scaled by ITS OWN decimals, never the chain's: 5 USDT is + * 5_000_000 at six decimals, and using the native eighteen would overpay by a factor of a + * trillion. `--raw-amount` is already in base units and is passed through untouched. + */ + private resolveTransfer(networkId: string, account: AccountRef, input: EvmSendInput) { + let contract = input.contract; + let decimals = input.decimals; + let symbol: string | undefined; + if (input.token) { + const entry = this.tokens + .effective(networkId, account) + .find((t) => t.symbol.toLowerCase() === input.token!.toLowerCase()); + if (!entry || entry.kind !== "erc20") { + throw new ExecutionError( + "token_metadata_unavailable", + `${input.token} is not an ERC-20 token on ${networkId}`, + ); + } + contract = entry.id; + decimals = entry.decimals; + symbol = entry.symbol; + } + if (input.rawAmount !== undefined) { + return { contract, decimals, symbol, rawAmount: input.rawAmount }; + } + if (contract === undefined) { + const native = FAMILIES.evm.nativeDecimals; + return { contract, decimals, symbol, rawAmount: toBaseUnits(input.amount!, native, "amount") }; + } + if (decimals === undefined) { + throw new ExecutionError( + "token_metadata_unavailable", + `could not establish decimals for ${contract}; add it with \`token add\` first`, + ); + } + return { + contract, + decimals, + symbol, + rawAmount: toBaseUnits(input.amount!, decimals, "token"), + }; + } + + /** the party fields for a decoded ERC-20 transfer, in the same shape the TRON side reports for + * TRC20: the token contract, its symbol, and a human amount scaled by its decimals. Metadata + * is best-effort — an unreadable contract degrades to the base-unit amount rather than losing + * the transfer. */ + async #erc20Parties( + gateway: EvmGateway, + contract: string, + transfer: { to: string; rawAmount: string }, + ) { + const meta = await gateway.getErc20Metadata(contract).catch(() => ({}) as { symbol?: string; decimals?: number }); + return { + to: transfer.to, + contract, + ...(meta.symbol === undefined ? {} : { symbol: meta.symbol }), + amount: + meta.decimals === undefined + ? transfer.rawAmount + : fromBaseUnits(transfer.rawAmount, meta.decimals), + }; + } + + async #build( + gateway: EvmGateway, + network: NetworkDescriptor, + from: string, + to: string, + transfer: { contract?: string; rawAmount: string }, + input: EvmSendInput, + ): Promise<{ tx: UnsignedTx; fee: Record }> { + // An ERC-20 transfer moves no native coin: the recipient and amount live in the calldata, + // and the transaction is addressed to the contract. + const call = transfer.contract + ? { to: transfer.contract, value: "0", data: gateway.encodeErc20Transfer(to, transfer.rawAmount) } + : { 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(), + ]); + const gasEstimate = + input.gasLimit ?? + (await gateway.estimateGas({ from, ...call }).catch(() => undefined)) ?? + "21000"; + + 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 }), + }, + }); + + 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 }, + }; + } + + /** + * Sign a transaction built elsewhere. An EVM transaction carries exactly one signature — there + * is no multi-signature accumulation to relay — so the input is an UNSIGNED serialisation and + * the output is the signed one. A transaction that already carries a signature is refused + * rather than re-signed: the result would be a different transaction wearing the same intent. + */ + async sign(scope: TransactionScope, network: NetworkDescriptor, hex: string) { + const parsed = parseEvmTransaction(hex); + if (parsed.signature !== null) { + throw new ChainError( + "invalid_transaction", + "this transaction is already signed; an EVM transaction takes exactly one signature", + ); + } + const outcome = await this.pipeline.signOnly({ + ctx: scope, + net: network, + account: scope.activeAccount, + tx: parsed.toJSON ? JSON.parse(JSON.stringify(parsed.toJSON())) : parsed, + }); + return { kind: "sign" as const, ...outcomeData(outcome) }; + } + + /** + * Broadcast a signed transaction supplied as raw hex. + * + * The reported id is derived from the bytes, never taken from the node: the hash of a signed + * transaction is a property of the transaction, and `authoritativeTxId` exists so a node cannot + * name a different one for us to poll and quote back. + */ + async broadcast(scope: TransactionScope, network: NetworkDescriptor, hex: string) { + const parsed = parseEvmTransaction(hex); + if (parsed.signature === null) { + throw new ChainError("invalid_transaction", "this transaction carries no signature"); + } + const gateway = this.gateways.get(network, "evm"); + const result = await gateway.sendRawTransaction(parsed.serialized); + const txId = authoritativeTxId(parsed.hash ?? undefined, result.hash, (m) => scope.warn(m)); + const submitted = { + stage: "submitted" as const, + ...result, + txId, + ...(result.alreadyKnown ? { alreadyKnown: true } : {}), + }; + if (!scope.wait) return submitted; + const confirmed = await evmConfirmation(gateway, scope)(txId).catch(() => undefined); + if (!confirmed) { + scope.warn( + `--wait: ${txId} not confirmed within ${scope.waitTimeoutMs}ms; returning submitted`, + ); + return submitted; + } + return { ...submitted, stage: confirmed.failed ? ("failed" as const) : ("confirmed" as const), ...confirmed }; + } + + /** + * Confirmation state, in four kinds. + * + * A receipt alone cannot separate "in the mempool" from "never existed" — the RPC answers null + * to both — so the transaction object is read alongside it, mirroring how the TRON side pairs + * getTransactionById with getTransactionInfoById. + * + * `not_found` carries a warning because it is the one answer that can be wrong about the past: + * a pruned or non-archival endpoint reports null for a transaction that really did happen, and + * a bare "not found" invites the reader to conclude it never did. + */ + async status( + scope: TransactionScope, + network: NetworkDescriptor, + hash: string, + ): Promise { + const gateway = this.gateways.get(network, "evm"); + const [transaction, receipt] = await Promise.all([ + gateway.getTransactionByHash(hash).catch(() => null), + gateway.getTransactionReceipt(hash).catch(() => null), + ]); + const confirmed = receipt !== null; + const failed = confirmed && receipt.success !== true; + const state = confirmed + ? failed + ? ("failed" as const) + : ("confirmed" as const) + : transaction + ? ("pending" as const) + : ("not_found" as const); + if (state === "not_found") { + scope.warn( + `${hash} is unknown to this endpoint. Public nodes often prune history, so this may mean ` + + "the node has no record of it rather than that it never existed; try an archival endpoint.", + ); + } + return { + txid: hash, + state, + confirmed, + failed, + ...(receipt?.blockNumber === undefined + ? {} + : { blockNumber: receipt.blockNumber as number }), + }; + } + + /** + * Full detail. `to` and the amount are read from the transaction, except for an ERC-20 + * `transfer`, whose real recipient and amount live in the calldata — reporting the raw fields + * there would name the CONTRACT as the recipient and the amount as zero, which is what the TRON + * side already avoids for TRC20. + * + * Only that one selector is decoded. Anything else is left as the chain recorded it: guessing + * at unknown calldata would be inventing meaning the signature does not carry. + */ + async info( + scope: TransactionScope, + network: NetworkDescriptor, + hash: string, + ): Promise { + const gateway = this.gateways.get(network, "evm"); + const [transaction, receipt] = await Promise.all([ + gateway.getTransactionByHash(hash), + gateway.getTransactionReceipt(hash).catch(() => null), + ]); + if (!transaction) { + throw new UsageError("not_found", `no transaction with hash ${hash} on ${network.id}`); + } + const transfer = decodeErc20Transfer(String(transaction.input ?? "0x")); + const value = BigInt(String(transaction.value ?? "0x0")); + return { + txid: hash, + from: checksummed(transaction.from), + ...(transfer + ? await this.#erc20Parties(gateway, checksummed(transaction.to), transfer) + : { + to: checksummed(transaction.to), + amount: fromBaseUnits(value.toString(10), FAMILIES.evm.nativeDecimals), + symbol: network.nativeSymbol, + }), + ...(receipt === null + ? {} + : { + status: receipt.success === true ? "SUCCESS" : "REVERT", + ...(receipt.blockNumber === undefined + ? {} + : { blockNumber: receipt.blockNumber as number }), + ...(receipt.gasUsed === undefined ? {} : { gasUsed: Number(receipt.gasUsed) }), + ...(receipt.feeWei === undefined ? {} : { feeWei: String(receipt.feeWei) }), + }), + transaction, + receipt, + }; + } +} + +/** parse raw hex into an ethers Transaction, reporting bad input as bad input. */ +function parseEvmTransaction(hex: string): Transaction { + try { + return Transaction.from(hex); + } catch (e) { + throw new ChainError( + "invalid_transaction", + `not a valid EVM transaction: ${(e as Error).message}`, + ); + } +} + +/** ERC-20 `transfer(address,uint256)` calldata → its recipient and base-unit amount. */ +function decodeErc20Transfer(input: string): { to: string; rawAmount: string } | undefined { + // 0xa9059cbb is the transfer(address,uint256) selector; 4 bytes + two 32-byte words. + if (!/^0xa9059cbb[0-9a-fA-F]{128}$/.test(input)) return undefined; + const body = input.slice(10); + return { + // Calldata carries the address in lower case with no checksum. Every other address this CLI + // prints is EIP-55, so it is re-checksummed here rather than shown in a second style. + to: evmChecksumAddress(hexToBytes(body.slice(24, 64))), + rawAmount: BigInt(`0x${body.slice(64, 128)}`).toString(10), + }; +} + +/** + * An address in EIP-55 form. Nodes answer in lower case, but every address this CLI prints comes + * out checksummed, and one payload mixing both styles invites a reader comparing an address + * against their own to conclude they do not match. Anything that is not a 20-byte hex address + * (a contract creation's null `to`, say) is passed through untouched. + */ +function checksummed(value: unknown): string { + const text = String(value ?? ""); + if (!/^0x[0-9a-fA-F]{40}$/.test(text)) return text; + return evmChecksumAddress(hexToBytes(text.slice(2))); +} diff --git a/ts/src/application/use-cases/message-service.test.ts b/ts/src/application/use-cases/message-service.test.ts new file mode 100644 index 000000000..cd2c955c2 --- /dev/null +++ b/ts/src/application/use-cases/message-service.test.ts @@ -0,0 +1,53 @@ +/** + * MessageService — the response contract and the pre-flight capability gate. + * + * The service itself is family-agnostic: the family only chooses which SignStrategy hashes the + * message, so the same binding serves TRON and EVM and the envelope must not vary between them. + */ +import { describe, it, expect, vi } from "vitest"; +import { MessageService } from "./message-service.js"; +import { WalletError } from "../../domain/errors/index.js"; +import type { SignerResolver } from "../services/signer/index.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; + +const scope = { timeoutMs: 1000, emit: () => {} } as unknown as TransactionScope; + +function resolverStub(overrides: Partial> = {}) { + return { + assertCanSign: vi.fn(), + resolve: vi.fn(() => ({ + kind: "software" as const, + address: "0xabc", + signMessage: async () => "0xsig", + })), + ...overrides, + } as unknown as SignerResolver & { assertCanSign: ReturnType }; +} + +describe("MessageService.sign", () => { + it("returns address, message and signature", async () => { + const out = await new MessageService(resolverStub()).sign(scope, "evm", "acct", "hello"); + expect(out).toEqual({ address: "0xabc", message: "hello", signature: "0xsig" }); + }); + + it("returns the same field set for either family", async () => { + const service = new MessageService(resolverStub()); + const tron = await service.sign(scope, "tron", "acct", "hello"); + const evm = await service.sign(scope, "evm", "acct", "hello"); + expect(Object.keys(evm)).toEqual(Object.keys(tron)); + }); + + it("refuses a watch-only account before resolving a signer", async () => { + // The gate belongs ahead of the keystore work, as it already is in TypedDataService: a + // "cannot sign" failure must win over anything the resolve path might report first. + const signers = resolverStub({ + assertCanSign: vi.fn(() => { + throw new WalletError("watch_only_no_signer", "watch-only account cannot sign"); + }), + }); + await expect(new MessageService(signers).sign(scope, "evm", "acct", "hi")).rejects.toMatchObject( + { code: "watch_only_no_signer" }, + ); + expect(signers.resolve).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/message-service.ts b/ts/src/application/use-cases/message-service.ts index a297de868..7433786c6 100644 --- a/ts/src/application/use-cases/message-service.ts +++ b/ts/src/application/use-cases/message-service.ts @@ -7,6 +7,9 @@ export class MessageService { constructor(private readonly signers: SignerResolver) {} async sign(scope: TransactionScope, family: ChainFamily, account: AccountRef, message: string) { + // Cheap gate first, as in TypedDataService: a watch-only account must fail as "cannot sign" + // rather than through whatever the resolve path happens to report. + this.signers.assertCanSign(account, family); const signer = this.signers.resolve(account, family); // obtainSignature handles the device preliminaries: verify the connected device still derives // this account's cached address (wrong seed/passphrase → wrong_device_seed) before attributing diff --git a/ts/src/application/use-cases/portfolio-holdings.test.ts b/ts/src/application/use-cases/portfolio-holdings.test.ts new file mode 100644 index 000000000..692ec9fb8 --- /dev/null +++ b/ts/src/application/use-cases/portfolio-holdings.test.ts @@ -0,0 +1,79 @@ +/** + * Portfolio holding rows. + * + * `account portfolio` is one command, so both families must report the same row shape. These + * helpers are shared rather than copied for exactly that reason — a second copy is how the two + * listings drift into reporting different fields for the same thing. + */ +import { describe, it, expect } from "vitest"; +import { holding, portfolioTotal, unavailableHolding } from "./portfolio-holdings.js"; + +describe("holding", () => { + it("scales the raw balance by the token's decimals", () => { + expect(holding("erc20", "USDT", 6, "5000000", null)).toMatchObject({ + kind: "erc20", + symbol: "USDT", + decimals: 6, + rawBalance: "5000000", + balance: "5", + }); + }); + + it("values the holding at the given price", () => { + expect(holding("native", "ETH", 18, "2000000000000000000", 1500).valueUsd).toBe(3000); + }); + + it("reports a null value when there is no price, rather than zero", () => { + // Zero would read as "this is worthless", which is a different claim from "we don't know". + const row = holding("native", "ETH", 18, "1000000000000000000", null); + expect(row.priceUsd).toBeNull(); + expect(row.valueUsd).toBeNull(); + }); + + it("carries extra identity fields through", () => { + expect(holding("erc20", "USDT", 6, "1", null, { id: "0xdAC1", source: "official" })).toMatchObject( + { id: "0xdAC1", source: "official" }, + ); + }); +}); + +describe("unavailableHolding", () => { + // One unreadable token must not sink the whole portfolio: the row keeps its identity and says + // why it has no numbers, instead of vanishing or reporting a fictitious zero. + it("keeps the row's identity and nulls only the numbers", () => { + expect(unavailableHolding("erc20", "USDT", 6, { id: "0xdAC1" })).toMatchObject({ + kind: "erc20", + symbol: "USDT", + decimals: 6, + id: "0xdAC1", + rawBalance: null, + balance: null, + valueUsd: null, + balanceUnavailable: true, + reason: "rpc_error", + }); + }); + + it("shares its field names with a readable holding", () => { + const ok = Object.keys(holding("erc20", "USDT", 6, "1", 1)); + const bad = Object.keys(unavailableHolding("erc20", "USDT", 6)); + + expect(ok.every((key) => bad.includes(key))).toBe(true); + }); +}); + +describe("portfolioTotal", () => { + it("sums only the rows that have a value", () => { + expect( + portfolioTotal([{ valueUsd: 10 }, { valueUsd: null }, { valueUsd: 2.5 }]), + ).toBe(12.5); + }); + + it("reports null when nothing could be valued", () => { + expect(portfolioTotal([{ valueUsd: null }, { valueUsd: null }])).toBeNull(); + }); + + it("rounds to six places, as the per-row values are", () => { + expect(portfolioTotal([{ valueUsd: 0.1234567 }, { valueUsd: 0.1 }])).toBe(0.223457); + }); +}); diff --git a/ts/src/application/use-cases/portfolio-holdings.ts b/ts/src/application/use-cases/portfolio-holdings.ts new file mode 100644 index 000000000..be47f7d51 --- /dev/null +++ b/ts/src/application/use-cases/portfolio-holdings.ts @@ -0,0 +1,72 @@ +import { fromBaseUnits } from "../../domain/amounts/index.js"; + +/** + * The rows `account portfolio` reports, for any family. + * + * `account portfolio` is ONE command, so both families must produce the same row shape. These + * helpers live here rather than being copied per family for exactly that reason — a second copy + * is how two listings drift into reporting different fields for the same thing. + * + * Extracted verbatim from the TRON implementation, which is the shape already shipped; the TRON + * service now delegates here, so its output is unchanged. + */ + +const round6 = (value: number): number => Math.round(value * 1e6) / 1e6; + +/** one readable holding: the raw balance, the same amount scaled, and its valuation if priced. */ +export function holding( + kind: string, + symbol: string, + decimals: number, + raw: string, + price: number | null, + extra: Record = {}, +): Record { + const balance = fromBaseUnits(raw, decimals); + return { + kind, + symbol, + decimals, + rawBalance: raw, + balance, + priceUsd: price, + // null, never 0, when unpriced: zero reads as "this is worthless", which is a different + // claim from "we could not find out what it is worth". + valueUsd: price === null ? null : round6(Number(balance) * price), + ...extra, + }; +} + +/** + * A holding whose balance could not be read. The row keeps its identity and records why, rather + * than vanishing from the listing or reporting a fictitious zero — one unreadable token must not + * take the whole portfolio down with it. The field set stays additive with `holding`, so a + * consumer can read both kinds of row the same way. + */ +export function unavailableHolding( + kind: string, + symbol: string, + decimals: number, + extra: Record = {}, +): Record { + return { + kind, + symbol, + decimals, + rawBalance: null, + balance: null, + priceUsd: null, + valueUsd: null, + balanceUnavailable: true, + reason: "rpc_error", + ...extra, + }; +} + +/** the portfolio's total, over the rows that could be valued; null when none could. */ +export function portfolioTotal(rows: Array<{ valueUsd?: unknown }>): number | null { + const values = rows + .map((row) => row.valueUsd) + .filter((value): value is number => typeof value === "number"); + return values.length ? round6(values.reduce((sum, value) => sum + value, 0)) : null; +} diff --git a/ts/src/application/use-cases/token-book-service.test.ts b/ts/src/application/use-cases/token-book-service.test.ts new file mode 100644 index 000000000..791bea46d --- /dev/null +++ b/ts/src/application/use-cases/token-book-service.test.ts @@ -0,0 +1,56 @@ +/** + * TokenBookService — the address-book reads that touch no chain. + * + * `token list` only merges the official and user layers for a (network, account) pair, which is + * the same operation on every family. Keeping one implementation is what stops the two families' + * listings from drifting apart in shape. + */ +import { describe, it, expect } from "vitest"; +import { TokenBookService } from "./token-book-service.js"; +import type { TokenRepository } from "../ports/token-repository.js"; +import type { AccountScope } from "../contracts/execution-scope.js"; +import type { EffectiveTokenEntry, NetworkDescriptor } from "../../domain/types/index.js"; + +const scope: AccountScope = { activeAccount: "wlt_test.0", resolveAddress: () => "0xADDR" }; +const net = { id: "evm:1", family: "evm", nativeSymbol: "ETH" } as NetworkDescriptor; + +const USDT: EffectiveTokenEntry = { + kind: "erc20", + id: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + symbol: "USDT", + decimals: 6, + source: "official", +}; + +function repo(entries: EffectiveTokenEntry[]) { + const calls: Array<[string, string]> = []; + const repository = { + effective: (networkId: string, account: string) => { + calls.push([networkId, account]); + return entries; + }, + } as unknown as TokenRepository; + return { repository, calls }; +} + +describe("TokenBookService.list", () => { + it("returns the book for the selected network and active account", () => { + const { repository, calls } = repo([USDT]); + + expect(new TokenBookService(repository).list(scope, net)).toEqual({ + network: "evm:1", + account: "wlt_test.0", + tokens: [USDT], + }); + expect(calls).toEqual([["evm:1", "wlt_test.0"]]); + }); + + it("reports an empty book rather than failing", () => { + expect(new TokenBookService(repo([]).repository).list(scope, net).tokens).toEqual([]); + }); + + it("reads no chain state at all", () => { + // No gateway is injected: if listing ever needed one, this would not compile or construct. + expect(() => new TokenBookService(repo([]).repository)).not.toThrow(); + }); +}); diff --git a/ts/src/application/use-cases/token-book-service.ts b/ts/src/application/use-cases/token-book-service.ts new file mode 100644 index 000000000..06ef816e8 --- /dev/null +++ b/ts/src/application/use-cases/token-book-service.ts @@ -0,0 +1,22 @@ +import type { NetworkDescriptor } from "../../domain/types/index.js"; +import type { AccountScope } from "../contracts/execution-scope.js"; +import type { TokenRepository } from "../ports/token-repository.js"; + +/** + * Address-book reads that touch no chain. + * + * Listing merges the official and user layers for one (network, account) pair — the same + * operation on every family, so it lives once and both families bind to it. Anything that has to + * ask the chain (balance, metadata, adding an entry) belongs to a family's own token service. + */ +export class TokenBookService { + constructor(private readonly tokens: TokenRepository) {} + + list(scope: AccountScope, network: NetworkDescriptor) { + return { + network: network.id, + account: scope.activeAccount, + tokens: this.tokens.effective(network.id, scope.activeAccount), + }; + } +} 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 b6660c519..5d5639c4d 100644 --- a/ts/src/application/use-cases/tron/account-service.test.ts +++ b/ts/src/application/use-cases/tron/account-service.test.ts @@ -12,8 +12,8 @@ import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index const net: NetworkDescriptor = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: ["nile"], capabilities: [], }; const scope: AccountScope = { activeAccount: "wlt_test.0", resolveAddress: () => "TXaddress" }; @@ -42,19 +42,6 @@ function serviceWith(nativeRaw: string, nativePrice: number | null) { ); } -describe("TronAccountService.balance (direction A shape)", () => { - it("returns raw sun balance with native decimals + symbol (no unit label)", async () => { - const result = await serviceWith("1983993000", 0.12).balance(scope, net, "tron"); - expect(result).toEqual({ - address: "TXaddress", - balance: "1983993000", - decimals: 6, - symbol: "TRX", - }); - expect(result).not.toHaveProperty("unit"); - }); -}); - describe("TronAccountService.portfolio native USD conversion", () => { it("prices the native TRX holding from raw sun × price at 6-decimal scale", async () => { const result = await serviceWith("1983993000", 0.12).portfolio(scope, net); diff --git a/ts/src/application/use-cases/tron/account-service.ts b/ts/src/application/use-cases/tron/account-service.ts index ecc6f6ba3..89d97d556 100644 --- a/ts/src/application/use-cases/tron/account-service.ts +++ b/ts/src/application/use-cases/tron/account-service.ts @@ -12,6 +12,8 @@ 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"; +// Shared with every other family: `account portfolio` is one command, so one row shape. +import { holding, portfolioTotal, unavailableHolding } from "../portfolio-holdings.js"; import type { TokenRepository } from "../../ports/token-repository.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; import { @@ -24,52 +26,8 @@ import { tronConfirmation } from "../../services/tron-confirmation.js"; import { warnOnPostCheck } from "../../services/post-check.js"; import { tronTransactionHooks } from "./multisig-authorization.js"; -const round6 = (value: number): number => Math.round(value * 1e6) / 1e6; const ADDRESS = new TronAddress(); -function holding( - kind: string, - symbol: string, - decimals: number, - raw: string, - price: number | null, - extra: Record = {}, -) { - const balance = fromBaseUnits(raw, decimals); - return { - kind, - symbol, - decimals, - rawBalance: raw, - balance, - priceUsd: price, - valueUsd: price === null ? null : round6(Number(balance) * price), - ...extra, - }; -} - -/** degraded holding when a token balance could not be read; keeps the row (and its identity) - * but nulls the numeric fields and records why. Shape stays additive with holding(). */ -function unavailableHolding( - kind: string, - symbol: string, - decimals: number, - extra: Record = {}, -) { - return { - kind, - symbol, - decimals, - rawBalance: null, - balance: null, - priceUsd: null, - valueUsd: null, - balanceUnavailable: true, - reason: "rpc_error", - ...extra, - }; -} - export class TronAccountService { constructor( private readonly gateways: ChainGatewayProvider, @@ -213,17 +171,6 @@ export class TronAccountService { }; } - async balance(scope: AccountScope, network: NetworkDescriptor, family: ChainFamily) { - const address = scope.resolveAddress(family); - const meta = FAMILIES[family]; - return { - address, - balance: await this.gateways.client(network).getNativeBalance(address), - decimals: meta.nativeDecimals, - symbol: meta.nativeSymbol, - }; - } - async info(scope: AccountScope, network: NetworkDescriptor) { const address = scope.resolveAddress("tron"); const gateway = this.gateways.get(network, "tron"); @@ -292,7 +239,7 @@ export class TronAccountService { const nativeMeta = FAMILIES.tron; const holdings: Array> = [ - holding("native", nativeMeta.nativeSymbol, nativeMeta.nativeDecimals, nativeRaw, nativePrice), + holding("native", network.nativeSymbol, nativeMeta.nativeDecimals, nativeRaw, nativePrice), ...tokens.map((token: EffectiveTokenEntry, index) => { const result = tokenBalances[index]!; const extra = { id: token.id, name: token.name, source: token.source }; @@ -309,9 +256,6 @@ export class TronAccountService { ); }), ]; - const values = holdings - .map((item) => item.valueUsd) - .filter((value): value is number => typeof value === "number"); return { network: network.id, account: scope.activeAccount, @@ -319,7 +263,7 @@ export class TronAccountService { priceSource: this.prices.source, ...(priceUnavailable ? { priceUnavailable: true, priceReason: "price_provider_error" } : {}), holdings, - totalValueUsd: values.length ? round6(values.reduce((sum, value) => sum + value, 0)) : null, + totalValueUsd: portfolioTotal(holdings), }; } } diff --git a/ts/src/application/use-cases/tron/asset-service.test.ts b/ts/src/application/use-cases/tron/asset-service.test.ts index 253e857ee..4fdbe0661 100644 --- a/ts/src/application/use-cases/tron/asset-service.test.ts +++ b/ts/src/application/use-cases/tron/asset-service.test.ts @@ -9,8 +9,8 @@ import type { TxPipeline } from "../../services/pipeline/index.js"; const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: [], capabilities: [], }; const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; diff --git a/ts/src/application/use-cases/tron/chain-service.test.ts b/ts/src/application/use-cases/tron/chain-service.test.ts index 9c35018cc..99f45a8e0 100644 --- a/ts/src/application/use-cases/tron/chain-service.test.ts +++ b/ts/src/application/use-cases/tron/chain-service.test.ts @@ -6,6 +6,7 @@ import type { NetworkDescriptor } from "../../../domain/types/index.js"; const net = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", aliases: [], capabilities: [], diff --git a/ts/src/application/use-cases/tron/contract-service.deploy.test.ts b/ts/src/application/use-cases/tron/contract-service.deploy.test.ts index e48ed2023..d1cd0eaab 100644 --- a/ts/src/application/use-cases/tron/contract-service.deploy.test.ts +++ b/ts/src/application/use-cases/tron/contract-service.deploy.test.ts @@ -6,7 +6,7 @@ import type { TxPipeline } from "../../services/pipeline/index.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; -const NET = { id: "tron:nile", family: "tron", chainId: "nile" } as unknown as NetworkDescriptor; +const NET = { id: "tron:nile", family: "tron", nativeSymbol: "TRX", chainId: "nile" } as unknown as NetworkDescriptor; const SCOPE = {} as unknown as TransactionScope; const DEPLOY_INPUT = { abi: [], bytecode: "0x00", feeLimit: "1000000000", parameters: [] }; const CONTRACT_HEX = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"; diff --git a/ts/src/application/use-cases/tron/contract-service.fee-limit.test.ts b/ts/src/application/use-cases/tron/contract-service.fee-limit.test.ts index 7ea34ea72..fcca431bb 100644 --- a/ts/src/application/use-cases/tron/contract-service.fee-limit.test.ts +++ b/ts/src/application/use-cases/tron/contract-service.fee-limit.test.ts @@ -8,7 +8,7 @@ import { TronContractService } from "./contract-service.js"; const NETWORK = { id: "tron:nile", - family: "tron", + family: "tron", nativeSymbol: "TRX", chainId: "nile", } as unknown as NetworkDescriptor; diff --git a/ts/src/application/use-cases/tron/contract-service.governance.test.ts b/ts/src/application/use-cases/tron/contract-service.governance.test.ts index 7e146f77a..fcfec263c 100644 --- a/ts/src/application/use-cases/tron/contract-service.governance.test.ts +++ b/ts/src/application/use-cases/tron/contract-service.governance.test.ts @@ -10,8 +10,8 @@ import { TronContractService } from "./contract-service.js"; const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: [], capabilities: [], }; const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index 70632d7fe..777d73a51 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -1,7 +1,7 @@ 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 { TronContractParameter } from "../../ports/chain/tron-gateway.js"; +import type { TronContractParameter, TronGateway } from "../../ports/chain/tron-gateway.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; import { ChainError } from "../../../domain/errors/index.js"; import { computeTronCreate2Address } from "../../../domain/governance/create2.js"; @@ -207,7 +207,7 @@ export class TronContractService { | "contract-clear-abi" | "contract-set-origin-energy-limit" | "contract-set-user-resource-percent", - build: (gateway: ReturnType, owner: string) => Promise, + build: (gateway: TronGateway, owner: string) => Promise, fields: Record, ) { const gateway = this.gateways.get(network, "tron"); diff --git a/ts/src/application/use-cases/tron/exchange-service.test.ts b/ts/src/application/use-cases/tron/exchange-service.test.ts index 0e6e17ee3..04f0a31f3 100644 --- a/ts/src/application/use-cases/tron/exchange-service.test.ts +++ b/ts/src/application/use-cases/tron/exchange-service.test.ts @@ -9,8 +9,8 @@ import type { TxPipeline } from "../../services/pipeline/index.js"; const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: [], capabilities: [], }; const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; 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 f87215f59..fee7a57ea 100644 --- a/ts/src/application/use-cases/tron/gasfree-service.test.ts +++ b/ts/src/application/use-cases/tron/gasfree-service.test.ts @@ -18,8 +18,8 @@ const SIGNATURE = const NETWORK = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: ["nile"], capabilities: [], gasfree: { baseUrl: "https://open-test.gasfree.io", diff --git a/ts/src/application/use-cases/tron/gasfree-service.ts b/ts/src/application/use-cases/tron/gasfree-service.ts index 317622b90..53513cbc9 100644 --- a/ts/src/application/use-cases/tron/gasfree-service.ts +++ b/ts/src/application/use-cases/tron/gasfree-service.ts @@ -1,3 +1,4 @@ +import { isTronNetwork } from "../../../domain/types/network.js"; import { bytesToHex } from "@noble/hashes/utils.js"; import type { GasFreeProvider } from "../../ports/gasfree-provider.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; @@ -108,7 +109,7 @@ export class GasFreeService { if (!input.dryRun) { this.signers.assertCanSign(scope.activeAccount, "tron"); } - const metadata = network.gasfree; + const metadata = isTronNetwork(network) ? network.gasfree : undefined; if (!metadata) { throw new UsageError("unsupported_network", `network ${network.id} does not support GasFree`); } diff --git a/ts/src/application/use-cases/tron/governance-artifact.test.ts b/ts/src/application/use-cases/tron/governance-artifact.test.ts index 18105d0e1..8b89d9562 100644 --- a/ts/src/application/use-cases/tron/governance-artifact.test.ts +++ b/ts/src/application/use-cases/tron/governance-artifact.test.ts @@ -11,8 +11,8 @@ import { TronContractService } from "./contract-service.js"; const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: [], capabilities: [], }; const OWNER = "TNmoJ3Be59WFEq5dsW6eCkZjveiL3G8HVB"; diff --git a/ts/src/application/use-cases/tron/governance-transaction-mode.test.ts b/ts/src/application/use-cases/tron/governance-transaction-mode.test.ts index 221d81a4e..753fb732e 100644 --- a/ts/src/application/use-cases/tron/governance-transaction-mode.test.ts +++ b/ts/src/application/use-cases/tron/governance-transaction-mode.test.ts @@ -22,8 +22,8 @@ import { TronContractService } from "./contract-service.js"; const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: [], capabilities: [], }; const OWNER = "TNmoJ3Be59WFEq5dsW6eCkZjveiL3G8HVB"; diff --git a/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts b/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts index 2b067e779..8a900464a 100644 --- a/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts +++ b/ts/src/application/use-cases/tron/multisig-collaboration-service.test.ts @@ -20,7 +20,7 @@ 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; +const NETWORK = { id: "tron:nile", family: "tron", nativeSymbol: "TRX", chainId: "nile" } as never; function unsignedHex(amount = 1): string { return encodeTransactionHex({ 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 dbdbf95ab..d716fa43c 100644 --- a/ts/src/application/use-cases/tron/multisig-service.test.ts +++ b/ts/src/application/use-cases/tron/multisig-service.test.ts @@ -108,7 +108,7 @@ function service(gateway: TronGateway, signer?: Signer) { return new TronMultisigService(provider, signing, () => NOW); } -const NETWORK = { id: "tron:nile", family: "tron" } as never; +const NETWORK = { id: "tron:nile", family: "tron", nativeSymbol: "TRX" } as never; describe("local TRON multi-signature workflow", () => { it("reports structured permission and missing weight for an unsigned transaction", async () => { 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 350b2cd9d..6e2b7cb86 100644 --- a/ts/src/application/use-cases/tron/permission-service.test.ts +++ b/ts/src/application/use-cases/tron/permission-service.test.ts @@ -9,7 +9,7 @@ import { TronPermissionService } from "./permission-service.js"; const A = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; const B = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; -const NETWORK = { id: "tron:nile", family: "tron" } as never; +const NETWORK = { id: "tron:nile", family: "tron", nativeSymbol: "TRX" } as never; function permissions(): AccountPermissionsView { return { diff --git a/ts/src/application/use-cases/tron/proposal-service.test.ts b/ts/src/application/use-cases/tron/proposal-service.test.ts index cb3cf3da3..691a7e8cf 100644 --- a/ts/src/application/use-cases/tron/proposal-service.test.ts +++ b/ts/src/application/use-cases/tron/proposal-service.test.ts @@ -9,8 +9,8 @@ import { TronProposalService } from "./proposal-service.js"; const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: [], capabilities: [], }; const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; diff --git a/ts/src/application/use-cases/tron/reward-service.test.ts b/ts/src/application/use-cases/tron/reward-service.test.ts index bd25c2610..ff1b413a6 100644 --- a/ts/src/application/use-cases/tron/reward-service.test.ts +++ b/ts/src/application/use-cases/tron/reward-service.test.ts @@ -9,8 +9,8 @@ import type { TxPipeline } from "../../services/pipeline/index.js"; const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: [], capabilities: [], }; const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; diff --git a/ts/src/application/use-cases/tron/sig-service.test.ts b/ts/src/application/use-cases/tron/sig-service.test.ts index 49bd486cc..5549cbd70 100644 --- a/ts/src/application/use-cases/tron/sig-service.test.ts +++ b/ts/src/application/use-cases/tron/sig-service.test.ts @@ -16,7 +16,7 @@ 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; +const NETWORK = { id: "tron:nile", family: "tron", nativeSymbol: "TRX" } as never; function unsignedHex(expiration = NOW + 60_000): string { return encodeTransactionHex({ diff --git a/ts/src/application/use-cases/tron/stake-service.query.test.ts b/ts/src/application/use-cases/tron/stake-service.query.test.ts index f63ee1db2..7e9821005 100644 --- a/ts/src/application/use-cases/tron/stake-service.query.test.ts +++ b/ts/src/application/use-cases/tron/stake-service.query.test.ts @@ -7,6 +7,7 @@ import type { NetworkDescriptor } from "../../../domain/types/index.js"; const net = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", aliases: [], capabilities: [], diff --git a/ts/src/application/use-cases/tron/stake-service.unfreeze.test.ts b/ts/src/application/use-cases/tron/stake-service.unfreeze.test.ts index da5796a77..33f385ab2 100644 --- a/ts/src/application/use-cases/tron/stake-service.unfreeze.test.ts +++ b/ts/src/application/use-cases/tron/stake-service.unfreeze.test.ts @@ -7,6 +7,7 @@ import type { NetworkDescriptor } from "../../../domain/types/index.js"; const net = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", aliases: [], capabilities: [], diff --git a/ts/src/application/use-cases/tron/stake-service.withdraw.test.ts b/ts/src/application/use-cases/tron/stake-service.withdraw.test.ts index c67364bcc..77065d754 100644 --- a/ts/src/application/use-cases/tron/stake-service.withdraw.test.ts +++ b/ts/src/application/use-cases/tron/stake-service.withdraw.test.ts @@ -7,6 +7,7 @@ import type { NetworkDescriptor } from "../../../domain/types/index.js"; const net = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", aliases: [], capabilities: [], 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 index 33c7b2534..6a2200a60 100644 --- a/ts/src/application/use-cases/tron/transaction-service.send.test.ts +++ b/ts/src/application/use-cases/tron/transaction-service.send.test.ts @@ -11,8 +11,8 @@ const RECEIVER = "TEkj3ndMVEmFLYaFrATMwMjBRZ1EAZkucT"; const NETWORK = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: ["nile"], capabilities: [], } satisfies NetworkDescriptor; 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 48e56a021..548f42f3a 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 @@ -4,7 +4,7 @@ import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js import type { TronGateway, TronTxInfo, TronTx } from "../../ports/chain/tron-gateway.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; -const NET = { id: "tron:nile", family: "tron", chainId: "nile" } as unknown as NetworkDescriptor; +const NET = { id: "tron:nile", family: "tron", nativeSymbol: "TRX", chainId: "nile" } as unknown as NetworkDescriptor; // Minimal fake gateway: status() only touches the two lookup endpoints. function service(opts: { tx?: TronTx | Error; info?: TronTxInfo }) { diff --git a/ts/src/application/use-cases/tron/vote-service.test.ts b/ts/src/application/use-cases/tron/vote-service.test.ts index f4276558e..015bc29b3 100644 --- a/ts/src/application/use-cases/tron/vote-service.test.ts +++ b/ts/src/application/use-cases/tron/vote-service.test.ts @@ -11,8 +11,8 @@ import { WalletError } from "../../../domain/errors/index.js"; const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: [], capabilities: [], }; const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; diff --git a/ts/src/application/use-cases/tron/witness-service.test.ts b/ts/src/application/use-cases/tron/witness-service.test.ts index 24090a046..fc1c873be 100644 --- a/ts/src/application/use-cases/tron/witness-service.test.ts +++ b/ts/src/application/use-cases/tron/witness-service.test.ts @@ -9,8 +9,8 @@ import { TronWitnessService } from "./witness-service.js"; const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", + nativeSymbol: "TRX", chainId: "nile", - aliases: [], capabilities: [], }; const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; diff --git a/ts/src/application/use-cases/wallet-service.keystore.test.ts b/ts/src/application/use-cases/wallet-service.keystore.test.ts index ed222b1ca..7d27cbc52 100644 --- a/ts/src/application/use-cases/wallet-service.keystore.test.ts +++ b/ts/src/application/use-cases/wallet-service.keystore.test.ts @@ -83,7 +83,7 @@ describe("WalletService.backupKeystore", () => { it("exports an HD account's OWN derived key, not the seed", () => { const { accountId } = h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); - h.service.backupKeystore(accountId, undefined, PW); + h.service.backupKeystore(accountId, undefined, PW, "tron"); const expected = Derivation.derive( Derivation.mnemonicToSeed(MNEMONIC), @@ -102,7 +102,7 @@ describe("WalletService.backupKeystore", () => { const walletId = root.split(".")[0]!; const { accountId } = h.keystore.addAccount(walletId, 3); - h.service.backupKeystore(accountId, undefined, PW); + h.service.backupKeystore(accountId, undefined, PW, "tron"); const expected = Derivation.derive( Derivation.mnemonicToSeed(MNEMONIC), Derivation.path("tron", 3), @@ -114,7 +114,7 @@ describe("WalletService.backupKeystore", () => { it("exports a privateKey wallet's stored key and records the account's TRON address", () => { const { accountId } = h.keystore.import({ secret: RAW_KEY, type: "privateKey", label: "hot" }); - const result = h.service.backupKeystore(accountId, undefined, PW); + const result = h.service.backupKeystore(accountId, undefined, PW, "tron"); const file = h.writer.writes[0]!.payload as { address: string }; expect(bytesToHex(KeystoreV3.decrypt(file, PW))).toBe(RAW_KEY); @@ -124,7 +124,7 @@ describe("WalletService.backupKeystore", () => { it("encrypts with the master password it was given, not with a fixed one", () => { const { accountId } = h.keystore.import({ secret: RAW_KEY, type: "privateKey" }); - h.service.backupKeystore(accountId, undefined, "a-different-password"); + h.service.backupKeystore(accountId, undefined, "a-different-password", "tron"); expect(() => KeystoreV3.decrypt(h.writer.writes[0]!.payload, PW)).toThrowError( /incorrect keystore file password/, ); @@ -132,7 +132,7 @@ describe("WalletService.backupKeystore", () => { it("asks the writer for the keystore filename shape and reports format: keystore", () => { const { accountId } = h.keystore.import({ secret: RAW_KEY, type: "privateKey" }); - const result = h.service.backupKeystore(accountId, undefined, PW); + const result = h.service.backupKeystore(accountId, undefined, PW, "tron"); expect(h.writer.writes[0]!.format).toBe("keystore"); expect(result).toMatchObject({ format: "keystore", @@ -146,7 +146,7 @@ describe("WalletService.backupKeystore", () => { family: "tron", address: "TQ5NMqJjCu5zSvSHSsuMEwjZ8pmpBRhkHm", }); - expect(() => h.service.backupKeystore(accountId, undefined, PW)).toThrowError( + expect(() => h.service.backupKeystore(accountId, undefined, PW, "tron")).toThrowError( /hold no exportable secret/, ); }); @@ -176,7 +176,7 @@ describe("WalletService export audit log", () => { it("distinguishes a keystore export from a native one", () => { const { accountId } = h.keystore.import({ secret: RAW_KEY, type: "privateKey", label: "hot" }); - h.service.backupKeystore(accountId, "./hot.keystore.json", PW); + h.service.backupKeystore(accountId, "./hot.keystore.json", PW, "tron"); expect(h.store.list()[0]).toMatchObject({ operation: "backup --keystore", out: "./hot.keystore.json", @@ -196,7 +196,7 @@ describe("WalletService export audit log", () => { h.store, () => NOW, ); - expect(() => failing.backupKeystore(accountId, undefined, PW)).toThrowError(); + expect(() => failing.backupKeystore(accountId, undefined, PW, "tron")).toThrowError(); expect(h.store.list()).toEqual([]); }); @@ -395,7 +395,7 @@ describe("WalletService reports the file it wrote when the audit append fails", it.each([ ["native backup", (s: WalletService, id: string) => s.backup(id, undefined)], - ["keystore backup", (s: WalletService, id: string) => s.backupKeystore(id, undefined, PW)], + ["keystore backup", (s: WalletService, id: string) => s.backupKeystore(id, undefined, PW, "tron")], ])("%s still fails, but names the file it already committed", (_label, run) => { const h = harness(); const { accountId } = h.keystore.import({ secret: RAW_KEY, type: "privateKey" }); @@ -411,3 +411,43 @@ describe("WalletService reports the file it wrote when the audit append fails", } }); }); + +// §3.10's problem, restated: a seed account holds a DIFFERENT private key per family (§1.2 puts +// TRON at coin 195 and EVM at coin 60). A V3 keystore holds exactly one key, so "export my +// private key" has two answers and the wallet must be told which. +describe("keystore export follows the selected network's family", () => { + const MNEMONIC = "test test test test test test test test test test test junk"; + const seed = Derivation.mnemonicToSeed(MNEMONIC); + + function exported(family: "tron" | "evm") { + const h = harness(); + h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + h.service.backupKeystore("main", undefined, PW, family); + return h.writer.writes.at(-1)!.payload as { address: string }; + } + + it.each([ + ["tron", "m/44'/195'/0'/0/0"], + ["evm", "m/44'/60'/0'/0/0"], + ])("encrypts the %s key, derived at %s", (family, path) => { + const file = exported(family as "tron" | "evm"); + const expected = Derivation.derive(seed, path).privateKey; + + expect(bytesToHex(KeystoreV3.decrypt(file, PW))).toBe(bytesToHex(expected)); + }); + + // The two keys are genuinely different, so exporting the wrong one hands the user an address + // their wallet has never shown them. + it("exports two different keys for the two families", () => { + expect(KeystoreV3.decrypt(exported("tron"), PW)).not.toEqual( + KeystoreV3.decrypt(exported("evm"), PW), + ); + }); + + // `address` is informational — every reader derives the real address from the key it decrypts + // (our own importer ignores it) — but writing the wrong family's encoding is still misleading. + it("writes the address in the exported family's own encoding", () => { + expect(exported("tron").address).toMatch(/^41[0-9a-f]{40}$/); + expect(exported("evm").address).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); +}); diff --git a/ts/src/application/use-cases/wallet-service.ts b/ts/src/application/use-cases/wallet-service.ts index 5198fe874..9d1cb6fce 100644 --- a/ts/src/application/use-cases/wallet-service.ts +++ b/ts/src/application/use-cases/wallet-service.ts @@ -3,7 +3,7 @@ import { Derivation } from "../../domain/derivation/index.js"; import { CHAIN_FAMILIES, familyOf, type ChainFamily } from "../../domain/family/index.js"; import { KeystoreV3 } from "../../domain/keystore/index.js"; import { derivePrivAddresses } from "../../domain/wallet/index.js"; -import { tronHexAddress } from "../../domain/address/index.js"; +import { TronAddress, evmAddressFromPublicKey, tronHexAddress } from "../../domain/address/index.js"; import type { Bytes } from "../../domain/types/index.js"; import { ExecutionError, UsageError, WalletError } from "../../domain/errors/index.js"; import type { BackupWriter } from "../ports/backup-writer.js"; @@ -184,22 +184,29 @@ export class WalletService { * so the file is an isolated account elsewhere and nothing can be derived from it. Moving a whole * seed is what the native `backup` (mnemonic) is for. */ - backupKeystore(account: string, requestedPath: string | undefined, masterPassword: string) { + /** + * `family` selects WHICH key: a seed account holds a different one per family (§1.2 derives + * TRON at coin 195 and EVM at coin 60), and a V3 keystore holds exactly one. The caller passes + * the selected network's family; a privateKey account has only one key and ignores it. + */ + backupKeystore( + account: string, + requestedPath: string | undefined, + masterPassword: string, + family: ChainFamily, + ) { const descriptor = this.wallets.describe(account); - const privateKey = this.#exportablePrivateKey(account); + const privateKey = this.#exportablePrivateKey(account, family); const file = this.backups.write( descriptor.accountId, requestedPath, - KeystoreV3.encrypt( - privateKey, - masterPassword, - tronHexAddress(descriptor.addresses[KEYSTORE_FAMILY]!), - ), + KeystoreV3.encrypt(privateKey, masterPassword, keystoreAddress(family, privateKey)), "keystore", ); this.#recordExport("backup --keystore", descriptor, file.out); return { ...descriptor, + family, secretType: "privateKey" as const, format: "keystore" as const, ...file, @@ -264,13 +271,14 @@ export class WalletService { /** The account's own private key: an HD account's is derived at its index; a privateKey wallet's is * the stored key. Watch/Ledger accounts have none (assertExportable is the caller's early gate). */ - #exportablePrivateKey(account: string): Bytes { + #exportablePrivateKey(account: string, family: ChainFamily): Bytes { const { wallet, index } = this.wallets.resolveAccount(account); const source = wallet.source; + // One key, shared by every family — nothing to choose. if (source.type === "privateKey") return this.wallets.decryptKey(source.keyId); if (source.type === "seed") { const seed = this.wallets.decryptSeed(source.vaultId); - return Derivation.derive(seed, Derivation.path(KEYSTORE_FAMILY, index)).privateKey; + return Derivation.derive(seed, Derivation.path(family, index)).privateKey; } throw notExportable(source.type); } @@ -328,3 +336,18 @@ export class WalletService { return { status: mutationStatus(result.created), ...this.wallets.describe(result.accountId) }; } } + +/** + * The keystore's `address` field, in the exported family's own encoding. + * + * It is informational: the Web3 V3 spec does not require it, and every reader (including our own + * importer) derives the real address from the key it decrypts. Writing the other family's + * encoding would not break an import, but it would misdescribe the file — and TRON's `41…` form + * is what TronLink round-trips, so each family keeps its own. + */ +function keystoreAddress(family: ChainFamily, privateKey: Bytes): string { + const publicKey = Derivation.publicKeyFromPrivate(privateKey); + return family === "tron" + ? tronHexAddress(new TronAddress().fromPublicKey(publicKey)) + : evmAddressFromPublicKey(publicKey); +} diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index b2e9020c1..60013ba88 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -1,3 +1,4 @@ +import { isTronNetwork } from "../domain/types/network.js"; import type { OutputMode } from "../domain/types/index.js"; import type { Globals, SessionRef } from "../adapters/inbound/cli/contracts/index.js"; import { ConfigLoader, NetworkRegistry } from "../adapters/outbound/config/index.js"; @@ -27,6 +28,9 @@ import { ConfigService } from "../application/use-cases/config-service.js"; import { WalletService } from "../application/use-cases/wallet-service.js"; import { familyMap } from "./family-registry.js"; import { registerTronChainCommands } from "./families/tron.js"; +import { registerEvmChainCommands } from "./families/evm.js"; +import { AccountBalanceService } from "../application/use-cases/account-balance-service.js"; +import { TokenBookService } from "../application/use-cases/token-book-service.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"; @@ -96,6 +100,8 @@ export function composeCliRuntime(options: BootstrapOptions) { registerContactCommands(registry, new ContactService(contactBook)); registerEncodingCommands(registry, new EncodingService()); registerAddressCommands(registry, new AddressService(new SecureKeypairWriter(root))); + const accountBalances = new AccountBalanceService(gatewayProvider); + const tokenBookService = new TokenBookService(tokenBook); registerTronChainCommands(registry, { gateways: gatewayProvider, tokens: tokenBook, @@ -107,13 +113,31 @@ export function composeCliRuntime(options: BootstrapOptions) { tronlink: new TronLinkClient(config, timeoutMs), gasfree: new GasFreeClient(config, timeoutMs), recipients: recipientResolver, + balances: accountBalances, + tokenBook: tokenBookService, + }); + registerEvmChainCommands(registry, { + signers: signerResolver, + gateways: gatewayProvider, + balances: accountBalances, + tokens: tokenBook, + tokenBook: tokenBookService, + prices: priceProvider, + transactions: txPipeline, + recipients: recipientResolver, }); 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)) + .filter( + (key) => + key !== "tx.multisig.tronlink" || + (isTronNetwork(network) && Boolean(network.tronlinkHttpEndpoint)), + ) + .filter( + (key) => !key.startsWith("gasfree.") || (isTronNetwork(network) && Boolean(network.gasfree)), + ) .map((key) => ({ key, summary: CAP_SUMMARIES[key] ?? key, @@ -137,6 +161,8 @@ export function composeCliRuntime(options: BootstrapOptions) { const session: SessionRef = {}; return { + root, + store, config, streams, formatter, diff --git a/ts/src/bootstrap/families/evm.test.ts b/ts/src/bootstrap/families/evm.test.ts new file mode 100644 index 000000000..fc792cea5 --- /dev/null +++ b/ts/src/bootstrap/families/evm.test.ts @@ -0,0 +1,146 @@ +/** + * The EVM family's command registrations. + * + * The signing commands come first because they need nothing from the chain: `MessageService` and + * `TypedDataService` take only a SignerResolver, and the per-family hashing already lives behind + * `evmSignStrategy`. So the same binding object serves both families, and the response contract + * is family-invariant by construction — these tests pin that down so it cannot drift once + * EVM-specific bindings start landing beside them. + */ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { registerEvmChainCommands } from "./evm.js"; +import { main } from "../runner.js"; +import { CommandRegistry } from "../../adapters/inbound/cli/registry/index.js"; +import type { SignerResolver } from "../../application/services/signer/index.js"; +import type { ChainGatewayProvider } from "../../application/ports/chain/gateway-provider.js"; +import type { AccountBalanceService } from "../../application/use-cases/account-balance-service.js"; +import type { TokenBookService } from "../../application/use-cases/token-book-service.js"; +import type { TokenRepository } from "../../application/ports/token-repository.js"; +import type { PriceProvider } from "../../application/ports/price-provider.js"; +import type { TxPipeline } from "../../application/services/pipeline/index.js"; +import type { RecipientResolver } from "../../application/services/recipient-resolver.js"; + +function registry(): CommandRegistry { + const reg = new CommandRegistry(); + registerEvmChainCommands(reg, { + signers: {} as SignerResolver, + gateways: {} as ChainGatewayProvider, + balances: {} as AccountBalanceService, + tokens: {} as TokenRepository, + prices: {} as PriceProvider, + tokenBook: {} as TokenBookService, + transactions: {} as TxPipeline, + recipients: {} as RecipientResolver, + }); + return reg; +} + +describe("registerEvmChainCommands", () => { + it("binds message sign and typed-data sign to the evm family", () => { + const reg = registry(); + expect(reg.resolveChain(["message", "sign"])?.families.evm).toBeDefined(); + expect(reg.resolveChain(["typed-data", "sign"])?.families.evm).toBeDefined(); + }); + + it("binds the read commands to the evm family", () => { + const reg = registry(); + for (const path of [ + ["account", "balance"], + ["account", "info"], + ["account", "portfolio"], + ["block"], + ["chain", "node"], + ["chain", "prices"], + ["token", "balance"], + ["token", "info"], + ["token", "add"], + ["token", "list"], + ["token", "remove"], + ["contract", "call"], + ["contract", "send"], + ["contract", "deploy"], + ["tx", "send"], + ["tx", "sign"], + ["tx", "broadcast"], + ["tx", "status"], + ["tx", "info"], + ]) { + expect(reg.resolveChain(path)?.families.evm, path.join(" ")).toBeDefined(); + } + }); + + it("reports the signing capabilities under evm", () => { + expect(registry().capabilityKeysByFamily().get("evm")).toEqual( + expect.arrayContaining(["message.sign", "typedData.sign"]), + ); + }); + + it("declares no evm-only flags on the signing commands", () => { + const reg = registry(); + // A family flag that exists on one side only would show up in help tagged "(evm)". These two + // commands take the same input everywhere; anything else is a regression. + expect(reg.resolveChain(["message", "sign"])?.families.evm?.fields).toBeUndefined(); + expect(reg.resolveChain(["typed-data", "sign"])?.families.evm?.fields).toBeUndefined(); + }); +}); + +/** + * The registration above is only worth anything if the composition root actually calls it. + * Asserting on `registerEvmChainCommands` alone would stay green if the wiring line were deleted, + * so this drives the real bootstrap and reads the catalog it produces. + */ +describe("EVM commands reach the assembled CLI", () => { + async function catalog() { + const previous = process.env.WALLET_CLI_HOME; + process.env.WALLET_CLI_HOME = mkdtempSync(join(tmpdir(), "wcli-evm-catalog-")); + const chunks: string[] = []; + const out = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + chunks.push(String(chunk)); + return true; + }); + const err = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + await main(["node", "wallet-cli", "--json-schema"]); + return JSON.parse(chunks.join("")) as { + commands: Array<{ id: string; families?: string[] }>; + }; + } finally { + out.mockRestore(); + err.mockRestore(); + if (previous === undefined) delete process.env.WALLET_CLI_HOME; + else process.env.WALLET_CLI_HOME = previous; + } + } + + it("advertises the signing commands under both families", async () => { + const byId = new Map((await catalog()).commands.map((c) => [c.id, c.families ?? []])); + for (const id of [ + "message.sign", + "typed-data.sign", + "account.balance", + "account.info", + "account.portfolio", + "block", + "chain.node", + "chain.prices", + "token.balance", + "token.info", + "token.add", + "token.list", + "token.remove", + "contract.call", + "contract.send", + "contract.deploy", + "tx.send", + "tx.sign", + "tx.broadcast", + "tx.status", + "tx.info", + ]) { + expect(byId.get(id), id).toEqual(expect.arrayContaining(["tron", "evm"])); + } + }); +}); diff --git a/ts/src/bootstrap/families/evm.ts b/ts/src/bootstrap/families/evm.ts new file mode 100644 index 000000000..406062c4c --- /dev/null +++ b/ts/src/bootstrap/families/evm.ts @@ -0,0 +1,147 @@ +/** + * The EVM family plugin — the composition root's entry for `evm`. + * + * The plugin supplies the family's identity, signing strategy and gateway factory; + * `registerEvmChainCommands` binds the commands EVM can serve. Paths with no binding here still + * refuse cleanly at dispatch (`family_mismatch`). + * + * Only the signing commands are bound so far. They need nothing from the chain — the family + * difference is entirely inside `evmSignStrategy` — so they reuse the very same binding objects + * the TRON family registers. Everything else waits on the EVM gateway's JSON-RPC surface. + */ +import { FAMILIES } from "../../domain/family/index.js"; +import { evmSignStrategy } from "../../adapters/outbound/chain/evm/signing-strategy.js"; +import { EvmRpcClient } from "../../adapters/outbound/chain/evm/evm.js"; +import { MessageService } from "../../application/use-cases/message-service.js"; +import { TypedDataService } from "../../application/use-cases/typed-data-service.js"; +import type { SignerResolver } from "../../application/services/signer/index.js"; +import type { CommandRegistry } from "../../adapters/inbound/cli/registry/index.js"; +import { messageSignBinding, messageSignSpec } from "../../adapters/inbound/cli/commands/shared.js"; +import { + typedDataSignBinding, + typedDataSignSpec, +} from "../../adapters/inbound/cli/commands/typed-data.js"; +import { + accountBalanceBinding, + accountBalanceSpec, + accountInfoEvmBinding, + accountInfoSpec, + accountPortfolioEvmBinding, + accountPortfolioSpec, +} from "../../adapters/inbound/cli/commands/account.js"; +import { blockEvmBinding, blockSpec } from "../../adapters/inbound/cli/commands/block.js"; +import { + chainNodeEvmBinding, + chainNodeSpec, + chainPricesEvmBinding, + chainPricesSpec, +} from "../../adapters/inbound/cli/commands/chain.js"; +import { AccountBalanceService } from "../../application/use-cases/account-balance-service.js"; +import { EvmAccountService } from "../../application/use-cases/evm/account-service.js"; +import { EvmBlockService } from "../../application/use-cases/evm/block-service.js"; +import { EvmChainService } from "../../application/use-cases/evm/chain-service.js"; +import { + tokenAddEvmBinding, + tokenAddSpec, + tokenBalanceEvmBinding, + tokenBalanceSpec, + tokenInfoEvmBinding, + tokenInfoSpec, + tokenListBinding, + tokenListSpec, + tokenRemoveEvmBinding, + tokenRemoveSpec, +} from "../../adapters/inbound/cli/commands/token.js"; +import { + contractCallEvmBinding, + contractCallSpec, + contractDeployEvmBinding, + contractDeploySpec, + contractSendEvmBinding, + contractSendSpec, +} from "../../adapters/inbound/cli/commands/contract.js"; +import { TokenBookService } from "../../application/use-cases/token-book-service.js"; +import { EvmTokenService } from "../../application/use-cases/evm/token-service.js"; +import { EvmContractService } from "../../application/use-cases/evm/contract-service.js"; +import { + txBroadcastEvmBinding, + txBroadcastSpec, + txSendEvmBinding, + txSendSpec, + txInfoEvmBinding, + txInfoSpec, + txSignEvmBinding, + txSignSpec, + txStatusEvmBinding, + txStatusSpec, +} from "../../adapters/inbound/cli/commands/tx.js"; +import { EvmTransactionService } from "../../application/use-cases/evm/transaction-service.js"; +import type { TxPipeline } from "../../application/services/pipeline/index.js"; +import type { RecipientResolver } from "../../application/services/recipient-resolver.js"; +import type { TokenRepository } from "../../application/ports/token-repository.js"; +import type { PriceProvider } from "../../application/ports/price-provider.js"; +import type { ChainGatewayProvider } from "../../application/ports/chain/gateway-provider.js"; +import type { FamilyPlugin } from "./types.js"; + +export const evmFamily: FamilyPlugin<"evm"> = { + meta: FAMILIES.evm, + signStrategy: evmSignStrategy, + createGateway: (network, timeoutMs) => new EvmRpcClient(network.httpEndpoint ?? "", timeoutMs), +}; + +export interface EvmChainCommandDependencies { + signers: SignerResolver; + gateways: ChainGatewayProvider; + /** the family-neutral native-balance service, shared with every other family. */ + balances: AccountBalanceService; + tokens: TokenRepository; + prices: PriceProvider; + /** the family-neutral address-book listing, shared with every other family. */ + tokenBook: TokenBookService; + transactions: TxPipeline; + recipients: RecipientResolver; +} + +export function registerEvmChainCommands( + reg: CommandRegistry, + deps: EvmChainCommandDependencies, +): void { + reg.addChain(messageSignSpec, "evm", messageSignBinding(new MessageService(deps.signers))); + reg.addChain( + typedDataSignSpec, + "evm", + typedDataSignBinding(new TypedDataService(deps.signers)), + ); + + const account = new EvmAccountService(deps.gateways, deps.tokens, deps.prices); + reg.addChain(accountBalanceSpec, "evm", accountBalanceBinding(deps.balances)); + reg.addChain(accountInfoSpec, "evm", accountInfoEvmBinding(account)); + reg.addChain(accountPortfolioSpec, "evm", accountPortfolioEvmBinding(account)); + reg.addChain(blockSpec, "evm", blockEvmBinding(new EvmBlockService(deps.gateways))); + const chain = new EvmChainService(deps.gateways); + reg.addChain(chainNodeSpec, "evm", chainNodeEvmBinding(chain)); + reg.addChain(chainPricesSpec, "evm", chainPricesEvmBinding(chain)); + + const transaction = new EvmTransactionService( + deps.gateways, + deps.tokens, + deps.transactions, + deps.recipients, + ); + reg.addChain(txSendSpec, "evm", txSendEvmBinding(transaction)); + reg.addChain(txSignSpec, "evm", txSignEvmBinding(transaction)); + reg.addChain(txBroadcastSpec, "evm", txBroadcastEvmBinding(transaction)); + reg.addChain(txStatusSpec, "evm", txStatusEvmBinding(transaction)); + reg.addChain(txInfoSpec, "evm", txInfoEvmBinding(transaction)); + + const token = new EvmTokenService(deps.gateways, deps.tokens); + reg.addChain(tokenBalanceSpec, "evm", tokenBalanceEvmBinding(token)); + reg.addChain(tokenInfoSpec, "evm", tokenInfoEvmBinding(token)); + reg.addChain(tokenAddSpec, "evm", tokenAddEvmBinding(token)); + reg.addChain(tokenListSpec, "evm", tokenListBinding(deps.tokenBook)); + reg.addChain(tokenRemoveSpec, "evm", tokenRemoveEvmBinding(token)); + const contract = new EvmContractService(deps.gateways, deps.transactions); + reg.addChain(contractCallSpec, "evm", contractCallEvmBinding(contract)); + reg.addChain(contractSendSpec, "evm", contractSendEvmBinding(contract)); + reg.addChain(contractDeploySpec, "evm", contractDeployEvmBinding(contract)); +} diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 12bc9b811..fbf4df11e 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -7,7 +7,7 @@ import { accountActivateSpec, accountActivateTronBinding, accountBalanceSpec, - accountBalanceTronBinding, + accountBalanceBinding, accountHistorySpec, accountHistoryTronBinding, accountInfoSpec, @@ -25,7 +25,7 @@ import { tokenInfoSpec, tokenInfoTronBinding, tokenListSpec, - tokenListTronBinding, + tokenListBinding, tokenRemoveSpec, tokenRemoveTronBinding, } from "../../adapters/inbound/cli/commands/token.js"; @@ -59,7 +59,15 @@ import { import { stakeDefinitions } from "../../adapters/inbound/cli/commands/stake.js"; import { assetDefinitions } from "../../adapters/inbound/cli/commands/asset.js"; import { exchangeDefinitions } from "../../adapters/inbound/cli/commands/exchange.js"; -import { chainDefinitions } from "../../adapters/inbound/cli/commands/chain.js"; +import { + chainDefinitions, + chainNodeSpec, + chainNodeTronBinding, + chainPricesSpec, + chainPricesTronBinding, +} from "../../adapters/inbound/cli/commands/chain.js"; +import type { AccountBalanceService } from "../../application/use-cases/account-balance-service.js"; +import type { TokenBookService } from "../../application/use-cases/token-book-service.js"; import { voteCastSpec, voteCastTronBinding, @@ -170,6 +178,8 @@ export interface TronChainCommandDependencies { tronlink: TronLinkCollaborationPort; gasfree: GasFreeProvider; recipients: RecipientResolver; + balances: AccountBalanceService; + tokenBook: TokenBookService; } export function registerTronChainCommands( @@ -213,7 +223,7 @@ export function registerTronChainCommands( reg.addChain(blockSpec, "tron", blockTronBinding(new TronBlockService(deps.gateways))); reg.addChain(accountActivateSpec, "tron", accountActivateTronBinding(account)); - reg.addChain(accountBalanceSpec, "tron", accountBalanceTronBinding(account)); + reg.addChain(accountBalanceSpec, "tron", accountBalanceBinding(deps.balances)); reg.addChain(accountInfoSpec, "tron", accountInfoTronBinding(account)); reg.addChain(accountHistorySpec, "tron", accountHistoryTronBinding(account)); reg.addChain(accountPortfolioSpec, "tron", accountPortfolioTronBinding(account)); @@ -221,7 +231,7 @@ export function registerTronChainCommands( reg.addChain(tokenBalanceSpec, "tron", tokenBalanceTronBinding(token)); reg.addChain(tokenInfoSpec, "tron", tokenInfoTronBinding(token)); reg.addChain(tokenAddSpec, "tron", tokenAddTronBinding(token)); - reg.addChain(tokenListSpec, "tron", tokenListTronBinding(token)); + reg.addChain(tokenListSpec, "tron", tokenListBinding(deps.tokenBook)); reg.addChain(tokenRemoveSpec, "tron", tokenRemoveTronBinding(token)); reg.addChain(messageSignSpec, "tron", messageSignBinding(message)); reg.addChain(typedDataSignSpec, "tron", typedDataSignBinding(typedData)); @@ -258,6 +268,8 @@ export function registerTronChainCommands( for (const definition of chainDefinitions(chain)) { reg.addChain(definition.spec, "tron", definition.binding); } + reg.addChain(chainNodeSpec, "tron", chainNodeTronBinding(chain)); + reg.addChain(chainPricesSpec, "tron", chainPricesTronBinding(chain)); reg.addChain(contractCallSpec, "tron", contractCallTronBinding(contract)); reg.addChain(contractSendSpec, "tron", contractSendTronBinding(contract)); reg.addChain(contractDeploySpec, "tron", contractDeployTronBinding(contract)); diff --git a/ts/src/bootstrap/family-registry.ts b/ts/src/bootstrap/family-registry.ts index 438874bf6..c091eef81 100644 --- a/ts/src/bootstrap/family-registry.ts +++ b/ts/src/bootstrap/family-registry.ts @@ -1,9 +1,10 @@ import type { ChainFamily } from "../domain/family/index.js"; import { tronFamily } from "./families/tron.js"; +import { evmFamily } from "./families/evm.js"; import type { AnyFamilyPlugin } from "./families/types.js"; /** Enabled family plugins. Adding a family requires one plugin and one entry here. */ -export const FAMILY_REGISTRY: readonly AnyFamilyPlugin[] = [tronFamily]; +export const FAMILY_REGISTRY: readonly AnyFamilyPlugin[] = [tronFamily, evmFamily]; export function familyMap(pick: (plugin: AnyFamilyPlugin) => T): Record { return Object.fromEntries( diff --git a/ts/src/bootstrap/migration-gate.test.ts b/ts/src/bootstrap/migration-gate.test.ts new file mode 100644 index 000000000..b46c82803 --- /dev/null +++ b/ts/src/bootstrap/migration-gate.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AtomicFileStore } from "../adapters/outbound/persistence/fs/index.js"; +import { MigrationRunner, type MigrationStep } from "../adapters/outbound/persistence/migration.js"; +import { runMigrationGate } from "./migration-gate.js"; +import { CliError } from "../domain/errors/index.js"; + +function stalePasswordStep(path: string, needsPassword: boolean): MigrationStep { + return { + path, + currentVersion: 2, + needsPassword: () => needsPassword, + migrate: (doc, password) => ({ ...(doc as object), version: 2, sawPassword: password ?? null }), + }; +} + +function seededRoot(): string { + const dir = mkdtempSync(join(tmpdir(), "gate-")); + writeFileSync(join(dir, "wallets.json"), JSON.stringify({ version: 1, wallets: [] })); + return dir; +} + +describe("runMigrationGate", () => { + it("refuses with migration_required when a password is needed but unavailable", async () => { + const wallets = join(seededRoot(), "wallets.json"); + const runner = new MigrationRunner(new AtomicFileStore()); + + const error = await runMigrationGate(runner, [stalePasswordStep(wallets, true)], async () => null) + .then(() => null) + .catch((e: unknown) => e as CliError); + + expect(error?.code).toBe("migration_required"); + expect(error?.exitCode()).toBe(2); + // and it changed nothing + expect(JSON.parse(readFileSync(wallets, "utf8"))).toEqual({ version: 1, wallets: [] }); + }); + + it("migrates silently when no stale file needs a password", async () => { + const wallets = join(seededRoot(), "wallets.json"); + const runner = new MigrationRunner(new AtomicFileStore()); + const obtain = vi.fn(async () => "should-not-be-asked"); + + await runMigrationGate(runner, [stalePasswordStep(wallets, false)], obtain); + + expect(obtain).not.toHaveBeenCalled(); + expect(JSON.parse(readFileSync(wallets, "utf8")).version).toBe(2); + }); + + it("hands the supplied password to the migration that asked for it", async () => { + const wallets = join(seededRoot(), "wallets.json"); + const runner = new MigrationRunner(new AtomicFileStore()); + + await runMigrationGate(runner, [stalePasswordStep(wallets, true)], async () => "hunter2"); + + expect(JSON.parse(readFileSync(wallets, "utf8")).sawPassword).toBe("hunter2"); + }); + + it("asks for nothing and writes nothing when every file is current", async () => { + const dir = mkdtempSync(join(tmpdir(), "gate-")); + const wallets = join(dir, "wallets.json"); + writeFileSync(wallets, JSON.stringify({ version: 2, wallets: [] })); + const runner = new MigrationRunner(new AtomicFileStore()); + const obtain = vi.fn(async () => "nope"); + + await runMigrationGate(runner, [stalePasswordStep(wallets, true)], obtain); + + expect(obtain).not.toHaveBeenCalled(); + expect(JSON.parse(readFileSync(wallets, "utf8"))).toEqual({ version: 2, wallets: [] }); + }); +}); diff --git a/ts/src/bootstrap/migration-gate.ts b/ts/src/bootstrap/migration-gate.ts new file mode 100644 index 000000000..e93c02a73 --- /dev/null +++ b/ts/src/bootstrap/migration-gate.ts @@ -0,0 +1,37 @@ +/** + * The startup migration gate (ADR-0008). Runs before any command dispatches, after the + * help/meta short-circuit so `--help` stays reachable on a stale or unmigratable keystore. + * + * The gate is absolute: while a registered file lags this binary, no command runs. That is what + * lets `ChainAddresses` stay total instead of degrading to a partial map everywhere. + */ +import { UsageError } from "../domain/errors/index.js"; +import type { MigrationRunner, MigrationStep } from "../adapters/outbound/persistence/migration.js"; + +/** Yields the master password, or null when none can be obtained (no TTY and no --password-stdin). */ +export type PasswordSource = () => Promise; + +export async function runMigrationGate( + runner: MigrationRunner, + steps: MigrationStep[], + obtainPassword: PasswordSource, +): Promise { + // No early exit for "nothing stale" is needed: planMigrations only aggregates needsPassword + // over stale files, and apply() no-ops on an empty set. Mutation testing proved the guard dead. + const plan = runner.plan(steps); + + let password: string | undefined; + if (plan.needsPassword) { + const supplied = await obtainPassword(); + if (supplied === null) { + throw new UsageError( + "migration_required", + "this wallet file was created by an older version and must be updated before any command " + + "can run; run wallet-cli in a terminal, or pipe the master password with --password-stdin", + ); + } + password = supplied; + } + + runner.apply(plan.stale, password); +} diff --git a/ts/src/bootstrap/migration-steps.test.ts b/ts/src/bootstrap/migration-steps.test.ts new file mode 100644 index 000000000..ded79a331 --- /dev/null +++ b/ts/src/bootstrap/migration-steps.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { AtomicFileStore } from "../adapters/outbound/persistence/fs/index.js"; +import { Keystore } from "../adapters/outbound/keystore/index.js"; +import { migrationSteps } from "./migration-steps.js"; +import { Derivation } from "../domain/derivation/index.js"; +import { evmAddressFromPublicKey } from "../domain/address/index.js"; + +const MNEMONIC = "test test test test test test test test test test test junk"; +const PASSWORD = "masterpw123A"; + +/** a REAL keystore with a REAL encrypted vault, then wound back to the v1 shape on disk. */ +function realV1Keystore() { + const root = mkdtempSync(join(tmpdir(), "mig-real-")); + const store = new AtomicFileStore(); + const ks = new Keystore(root, store, () => PASSWORD); + ks.import({ secret: MNEMONIC, type: "seed", label: "main" }); + ks.addAccount(ks.list()[0]!.seedId!, 2); + + const path = join(root, "wallets.json"); + const doc = JSON.parse(readFileSync(path, "utf8")); + doc.version = 1; + for (const byIndex of Object.values(doc.wallets[0].source.addresses as Record>)) { + delete byIndex.evm; // wind back to what a pre-EVM keystore actually looks like + } + writeFileSync(path, JSON.stringify(doc)); + return { root, store, path }; +} + +describe("the wallets step against a real encrypted vault", () => { + it("derives every known index's EVM address using the decrypted seed", () => { + const { root, store, path } = realV1Keystore(); + const step = migrationSteps(root, store)[0]!; + const doc = JSON.parse(readFileSync(path, "utf8")); + + expect(step.needsPassword(doc)).toBe(true); + const migrated = step.migrate(doc, PASSWORD) as { + wallets: [{ source: { addresses: Record } }]; + }; + + const seed = Derivation.mnemonicToSeed(MNEMONIC); + for (const index of ["0", "2"]) { + expect(migrated.wallets[0].source.addresses[index]!.evm).toBe( + evmAddressFromPublicKey(Derivation.derive(seed, `m/44'/60'/0'/0/${index}`).publicKey), + ); + } + }); + + it("refuses a wrong password rather than writing garbage", () => { + const { root, store, path } = realV1Keystore(); + const step = migrationSteps(root, store)[0]!; + const doc = JSON.parse(readFileSync(path, "utf8")); + + expect(() => step.migrate(doc, "not-the-password")).toThrow(); + }); +}); diff --git a/ts/src/bootstrap/migration-steps.ts b/ts/src/bootstrap/migration-steps.ts new file mode 100644 index 000000000..8440c4523 --- /dev/null +++ b/ts/src/bootstrap/migration-steps.ts @@ -0,0 +1,36 @@ +/** + * The registered migrations (ADR-0008). Adding one = one entry here. + * + * Only wallets.json has ever needed a migration: contacts.json is already family-keyed at rest + * (`entries` is Partial> and every entry carries its own `family`), and + * tokens.json is keyed by network id, so EVM only adds keys to both. + */ +import { join } from "node:path"; +import type { MigrationStep } from "../adapters/outbound/persistence/migration.js"; +import type { AtomicFileStore } from "../adapters/outbound/persistence/fs/index.js"; +import { Keystore } from "../adapters/outbound/keystore/index.js"; +import { + WALLETS_VERSION, + migrateWalletsToV2, + walletsNeedPassword, + type WalletsFileV1, +} from "../domain/migration/wallets-v2.js"; + +export function migrationSteps(root: string, store: AtomicFileStore): MigrationStep[] { + return [ + { + path: join(root, "wallets.json"), + currentVersion: WALLETS_VERSION, + needsPassword: (doc) => walletsNeedPassword(doc as WalletsFileV1), + migrate: (doc, password) => { + // A throwaway Keystore purely as the secret reader. Its own #assertPassword checks the + // verifier, so a wrong password surfaces as auth_failed rather than corrupt output. + const reader = new Keystore(root, store, () => password ?? ""); + return migrateWalletsToV2(doc as WalletsFileV1, { + seedFor: (vaultId) => reader.decryptSeed(vaultId), + keyFor: (keyId) => reader.decryptKey(keyId), + }); + }, + }, + ]; +} diff --git a/ts/src/bootstrap/migration-wiring.test.ts b/ts/src/bootstrap/migration-wiring.test.ts new file mode 100644 index 000000000..dc41e2107 --- /dev/null +++ b/ts/src/bootstrap/migration-wiring.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { main } from "./runner.js"; + +const TRON_ADDR = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; +const EVM_ADDR = "0xe2E1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; + +async function runIn(walletsDoc: unknown, tokens: string[]) { + const root = mkdtempSync(join(tmpdir(), "wcli-mig-")); + const walletsPath = join(root, "wallets.json"); + writeFileSync(walletsPath, JSON.stringify(walletsDoc)); + const previous = process.env.WALLET_CLI_HOME; + process.env.WALLET_CLI_HOME = root; + const stdout: string[] = []; + const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const code = await main(["node", "wallet-cli", ...tokens]); + return { code, stdout: stdout.join(""), walletsPath }; + } finally { + outSpy.mockRestore(); + errSpy.mockRestore(); + if (previous === undefined) delete process.env.WALLET_CLI_HOME; + else process.env.WALLET_CLI_HOME = previous; + } +} + +const v1SeedDoc = { + version: 1, + activeAccount: "wlt_s.0", + labels: {}, + wallets: [ + { id: "wlt_s", source: { type: "seed", vaultId: "vlt_1", addresses: { "0": { tron: TRON_ADDR } } } }, + ], +}; + +const v1PrivateKeyDoc = { + version: 1, + activeAccount: "wlt_k", + labels: {}, + wallets: [{ id: "wlt_k", source: { type: "privateKey", keyId: "key_1", addresses: { tron: TRON_ADDR } } }], +}; + +// watch and ledger hold no secret anywhere, so this keystore migrates with no prompt at all. +const v1WatchDoc = { + version: 1, + activeAccount: "wlt_w", + labels: { wlt_w: "team-vault" }, + wallets: [{ id: "wlt_w", source: { type: "watch", family: "tron", address: TRON_ADDR } }], +}; + +describe("the startup migration gate is wired into main()", () => { + it("refuses a seed keystore with migration_required when no password can be obtained", async () => { + const { code, stdout, walletsPath } = await runIn(v1SeedDoc, ["-o", "json", "list"]); + + expect(JSON.parse(stdout).error.code).toBe("migration_required"); + expect(code).toBe(2); + expect(JSON.parse(readFileSync(walletsPath, "utf8")).version).toBe(1); + }); + + // A privateKey wallet is re-derived from its decrypted key, exactly as a seed wallet is, so + // it needs the password too. Only sources with NO local secret migrate free. + it("refuses a privateKey keystore with migration_required when no password can be obtained", async () => { + const { code, stdout } = await runIn(v1PrivateKeyDoc, ["-o", "json", "list"]); + + expect(JSON.parse(stdout).error.code).toBe("migration_required"); + expect(code).toBe(2); + }); + + it("migrates a secret-free keystore silently and runs the command", async () => { + const { code, walletsPath } = await runIn(v1WatchDoc, ["-o", "json", "list"]); + + expect(code).toBe(0); + expect(JSON.parse(readFileSync(walletsPath, "utf8")).version).toBe(2); + }); + + it("preserves everything it was not asked to change", async () => { + const { walletsPath } = await runIn(v1WatchDoc, ["-o", "json", "list"]); + const doc = JSON.parse(readFileSync(walletsPath, "utf8")); + + expect(doc.activeAccount).toBe(v1WatchDoc.activeAccount); + expect(doc.labels).toEqual(v1WatchDoc.labels); + expect(doc.wallets[0].source).toEqual(v1WatchDoc.wallets[0]!.source); + }); + + it("keeps the pre-migration copy", async () => { + const { walletsPath } = await runIn(v1WatchDoc, ["-o", "json", "list"]); + + expect(JSON.parse(readFileSync(`${walletsPath}.v1.bak`, "utf8"))).toEqual(v1WatchDoc); + }); + + it("leaves --help reachable on a stale keystore", async () => { + const { code } = await runIn(v1SeedDoc, ["--help"]); + expect(code).toBe(0); + }); +}); + +describe("TRON-only commands on an EVM network", () => { + // Reachable for the first time now that EVM networks are builtin: dispatch looks up the + // command's family binding and finds none, so it must refuse before touching any RPC. + it.each([["gasfree", "info"], ["stake", "info"], ["permission", "show"]])( + "refuses `%s %s` on evm:1", + async (group, verb) => { + const { code, stdout } = await runIn({ version: 2, activeAccount: null, labels: {}, wallets: [] }, [ + "-o", + "json", + group, + verb, + "--network", + "evm:1", + ]); + + expect(JSON.parse(stdout).error.code).toBe("family_mismatch"); + expect(code).toBe(2); + }, + ); +}); + +describe("aliases resolve at selection and nowhere else", () => { + const emptyKeystore = { version: 2, activeAccount: null, labels: {}, wallets: [] }; + + it("accepts an alias on --network and reports the CANONICAL id downstream", async () => { + const { stdout } = await runIn(emptyKeystore, [ + "-o", + "json", + "stake", + "info", + "--network", + "sepolia", + ]); + + const { error } = JSON.parse(stdout); + // resolved (not "unknown network"), and everything past resolution speaks canonical ids + expect(error.message).toContain("evm:11155111"); + expect(error.message).not.toContain("sepolia"); + }); + + it("accepts the canonical id just as well", async () => { + const { stdout } = await runIn(emptyKeystore, [ + "-o", + "json", + "stake", + "info", + "--network", + "evm:11155111", + ]); + expect(JSON.parse(stdout).error.message).toContain("evm:11155111"); + }); +}); + +describe("networks lists both families with their endpoints", () => { + const emptyKeystore = { version: 2, activeAccount: null, labels: {}, wallets: [] }; + + it("reports each network's alias and endpoint host", async () => { + const { stdout } = await runIn(emptyKeystore, ["-o", "json", "networks"]); + const rows: Array> = JSON.parse(stdout).data; + const byId = Object.fromEntries(rows.map((r) => [r.id, r])); + + expect(byId["evm:11155111"]).toMatchObject({ + family: "evm", + chainId: "11155111", + feeModel: "evm-gas", + alias: "sepolia", + }); + // §2.3 shows the HOST, not the full URL with any embedded key + expect(byId["evm:11155111"]!.endpoint).toBe("ethereum-sepolia-rpc.publicnode.com"); + expect(byId["tron:nile"]!.endpoint).toBe("nile.trongrid.io"); + }); + + it("renders the endpoint column in text mode", async () => { + const { stdout } = await runIn(emptyKeystore, ["networks"]); + expect(stdout).toContain("Endpoint"); + expect(stdout).toContain("nile.trongrid.io"); + }); +}); + +describe("config addresses networks by nested key", () => { + const emptyKeystore = { version: 2, activeAccount: null, labels: {}, wallets: [] }; + + it("sets an endpoint by alias and stores it under the canonical id", async () => { + const { code, stdout } = await runIn(emptyKeystore, [ + "-o", + "json", + "config", + "networks.sepolia.httpEndpoint", + "https://my-node.example/key", + ]); + + expect(code).toBe(0); + expect(JSON.parse(stdout).data).toMatchObject({ + key: "networks.evm:11155111.httpEndpoint", + }); + }); + + it("shows each network's endpoint host so a change can be confirmed", async () => { + const { stdout } = await runIn(emptyKeystore, ["-o", "json", "config", "networks"]); + expect(JSON.parse(stdout).data.value).toMatchObject({ + "tron:nile": "nile.trongrid.io", + "evm:11155111": "ethereum-sepolia-rpc.publicnode.com", + }); + }); + + it("shows the alias book so a short name can be traced to its network", async () => { + const { stdout } = await runIn(emptyKeystore, ["-o", "json", "config", "aliases"]); + expect(JSON.parse(stdout).data.value).toMatchObject({ + nile: "tron:nile", + sepolia: "evm:11155111", + "bsc-testnet": "evm:97", + }); + }); + + it("rejects an unknown config key rather than silently ignoring it", async () => { + const { code, stdout } = await runIn(emptyKeystore, ["-o", "json", "config", "nonsense", "x"]); + expect(code).toBe(2); + expect(JSON.parse(stdout).error.code).toBeDefined(); + }); +}); diff --git a/ts/src/bootstrap/runner.test.ts b/ts/src/bootstrap/runner.test.ts index cfb00863e..2fa766720 100644 --- a/ts/src/bootstrap/runner.test.ts +++ b/ts/src/bootstrap/runner.test.ts @@ -5,11 +5,33 @@ import { tmpdir } from "node:os"; import { main } from "./runner.js"; import { parseGlobals, hasCommand } from "./argv.js"; import { FAMILY_REGISTRY } from "./family-registry.js"; +import { CHAIN_FAMILIES } from "../domain/family/index.js"; +import { familyMap } from "./family-registry.js"; +import { ChainGatewayRegistry } from "../adapters/outbound/chain/tron/provider.js"; +import { EvmRpcClient } from "../adapters/outbound/chain/evm/evm.js"; describe("FAMILY_REGISTRY (composition manifest)", () => { - it("registers the tron family for sign/rpc resolution + the user command surface", () => { - expect(FAMILY_REGISTRY.map((d) => d.meta.family)).toEqual(["tron"]); + it("registers every family for sign/rpc resolution + the user command surface", () => { + expect(FAMILY_REGISTRY.map((d) => d.meta.family)).toEqual(["tron", "evm"]); }); + + // familyMap() casts its Object.fromEntries result to a TOTAL Record, so a + // family present in the type union but missing a plugin type-checks fine and then hands out + // `undefined` at runtime — SoftwareSigner would fail with a bare TypeError on the strategy. + // tsc cannot catch this; these two assertions are the only thing that can. + it("leaves no family without a plugin", () => { + const registered = new Set(FAMILY_REGISTRY.map((d) => d.meta.family)); + expect([...CHAIN_FAMILIES].filter((f) => !registered.has(f))).toEqual([]); + }); + + it.each(["signStrategy", "createGateway"] as const)( + "gives every family a %s", + (capability) => { + for (const plugin of FAMILY_REGISTRY) { + expect(plugin[capability], `${plugin.meta.family} is missing ${capability}`).toBeDefined(); + } + }, + ); }); describe("hasCommand (bare invocation → root help)", () => { @@ -143,3 +165,25 @@ describe("bootstrap error boundary", () => { expect(stderr).toMatch(/invalid_config/); }); }); + +// The registry guard above proves a factory EXISTS; this proves the factory, the descriptor and +// the gateway registry actually line up — that `--network sepolia` would reach a live client. +describe("composition resolves a gateway per family", () => { + const gateways = () => new ChainGatewayRegistry(familyMap((p) => p.createGateway), 5_000); + const sepolia = { + id: "evm:11155111", + family: "evm" as const, + nativeSymbol: "ETH", + chainId: "11155111", + httpEndpoint: "https://sepolia.example", + capabilities: [], + }; + + it("builds an EVM JSON-RPC client for an evm network", () => { + expect(gateways().get(sepolia, "evm")).toBeInstanceOf(EvmRpcClient); + }); + + it("refuses to hand an evm network out as a tron gateway", () => { + expect(() => gateways().get(sepolia, "tron")).toThrow(/family mismatch/); + }); +}); diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index 5d29c4cd4..610f4e41f 100644 --- a/ts/src/bootstrap/runner.ts +++ b/ts/src/bootstrap/runner.ts @@ -1,3 +1,6 @@ +import { runMigrationGate } from "./migration-gate.js"; +import { migrationSteps } from "./migration-steps.js"; +import { MigrationRunner } from "../adapters/outbound/persistence/migration.js"; import { hideBin } from "yargs/helpers"; import type { ExitCode, OutputMode } from "../domain/types/index.js"; import { normalizeError, UsageError } from "../domain/errors/index.js"; @@ -69,6 +72,19 @@ export async function main(argv: string[]): Promise { ); } + // The migration gate runs after the meta short-circuit above (so `--help` stays reachable on a + // stale keystore) and before any command dispatches — ADR-0008. + await runMigrationGate( + new MigrationRunner(runtime.store), + migrationSteps(runtime.root, runtime.store), + async () => { + const { secrets, keystore, prompter } = runtime.deps; + if (!secrets.hasMasterPassword() && !prompter.isTTY()) return null; + await secrets.primePassword({ mode: "verify", verify: (pw) => keystore.verifyPassword(pw) }); + return secrets.masterPassword(); + }, + ); + const cli = buildCli({ registry: runtime.registry, globals, diff --git a/ts/src/domain/address/address.test.ts b/ts/src/domain/address/address.test.ts new file mode 100644 index 000000000..6eed0d23d --- /dev/null +++ b/ts/src/domain/address/address.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { + TronAddress, + evmAddressFromPublicKey, + evmChecksumAddress, + isEvmAddress, + tronAddressBytes, +} from "./index.js"; +import { Derivation } from "../derivation/index.js"; +import { hexToBytes } from "@noble/hashes/utils.js"; + +// Canonical EIP-55 vectors (https://eips.ethereum.org/EIPS/eip-55). +const CHECKSUMMED = [ + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359", + "0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB", + "0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb", +]; + +describe("isEvmAddress", () => { + it.each(CHECKSUMMED)("accepts the correctly checksummed address %s", (address) => { + expect(isEvmAddress(address)).toBe(true); + }); +}); + +describe("isEvmAddress rejects a broken checksum", () => { + // §1.3: a checksummed address with ONE character altered must fail. Letting it through turns + // "typed one character wrong" and "clipboard was swapped" straight into fund loss. + it.each([ + ["0x5aaeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "A->a at index 2"], + ["0xfb6916095ca1df60bB79Ce92cE3Ea74c37c5d359", "B->b at index 1"], + ["0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6Fb", "B->b at the end"], + ["0xD1220A0Cf47c7B9Be7A2E6BA89F429762e7b9aDb", "c->C at index 7"], + ])("rejects %s (%s)", (address) => { + expect(isEvmAddress(address)).toBe(false); + }); +}); + +describe("isEvmAddress accepts unchecksummed input", () => { + // §1.3: all-lower and all-upper carry no case information, so there is nothing to verify — + // EIP-55 itself says clients may accept them. Both forms below are the same address as the + // first CHECKSUMMED vector, whose checksum form is neither all-lower nor all-upper. + it("accepts an all-lowercase address", () => { + expect(isEvmAddress("0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed")).toBe(true); + }); + + it("accepts an all-uppercase address", () => { + expect(isEvmAddress("0x5AAEB6053F3E94C9B9A09F33669435E7EF1BEAED")).toBe(true); + }); +}); + +describe("isEvmAddress rejects malformed input", () => { + it.each([ + ["empty", ""], + ["no 0x prefix", "5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"], + ["uppercase 0X prefix", "0X5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"], + ["one nibble short", "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAe"], + ["one nibble long", "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAedd"], + ["non-hex character", "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAeg"], + ["leading whitespace", " 0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"], + ["trailing whitespace", "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed "], + // cross-family: a TRON address must never validate as EVM (drives `familyOf` detection) + ["a TRON base58 address", "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"], + ["a TRON hex address", "0x41e2e1a54926527fbb4e4420de4c6bab82beaee24d"], + ])("rejects %s", (_label, address) => { + expect(isEvmAddress(address)).toBe(false); + }); +}); + +describe("evmAddressFromPublicKey", () => { + // Anvil / Hardhat account #0 — a widely published key/address pair, so this anchors the + // derivation to an external fact rather than to our own implementation. + const ANVIL_KEY = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const ANVIL_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + + it("derives the published address for a known private key", () => { + const pub = Derivation.publicKeyFromPrivate(hexToBytes(ANVIL_KEY)); + expect(evmAddressFromPublicKey(pub)).toBe(ANVIL_ADDRESS); + }); + + it("emits an address that passes its own EIP-55 check", () => { + const pub = Derivation.publicKeyFromPrivate(hexToBytes(ANVIL_KEY)); + expect(isEvmAddress(evmAddressFromPublicKey(pub))).toBe(true); + }); + + // ADR-0008 leans on this: a privateKey wallet's EVM address is a pure RE-ENCODING of its + // cached TRON address, so that half of the migration needs no secret and no password. + it("shares its 20-byte body with the TRON address of the same key", () => { + const pub = Derivation.publicKeyFromPrivate(hexToBytes(ANVIL_KEY)); + const tron = new TronAddress().fromPublicKey(pub); + + const reEncoded = evmChecksumAddress(tronAddressBytes(tron).slice(1)); + + expect(reEncoded).toBe(evmAddressFromPublicKey(pub)); + }); +}); diff --git a/ts/src/domain/address/index.ts b/ts/src/domain/address/index.ts index 43dfb57c4..416e9ceb8 100644 --- a/ts/src/domain/address/index.ts +++ b/ts/src/domain/address/index.ts @@ -91,6 +91,16 @@ export class TronAddress implements AddressCodec { } } +export class EvmAddress implements AddressCodec { + readonly family: ChainFamily = "evm"; + fromPublicKey(pub: Bytes): string { + return evmAddressFromPublicKey(pub); + } + validate(addr: string): boolean { + return isEvmAddress(addr); + } +} + /** Convert a 41-prefixed TRON hex address to base58; preserve non-hex values unchanged. */ export function tronHexToBase58(address: unknown): string { const value = String(address ?? ""); @@ -119,3 +129,19 @@ export function tronBytesToBase58(payload: Uint8Array): string { } return b58c.encode(payload); } + +/** + * EIP-55 acceptance policy for an EVM address supplied by a caller (§1.3). + * + * All-lower and all-upper carry no case information and are accepted unverified, as EIP-55 + * permits. A mixed-case address MUST carry a valid checksum. The protocol itself is case-insensitive, so + * accepting a mismatched one would turn "typed one character wrong" and "clipboard was swapped" + * into fund loss — the same reason ethers' getAddress() throws and hardware wallets refuse. + */ +export function isEvmAddress(address: string): boolean { + if (!/^0x[0-9a-fA-F]{40}$/.test(address)) return false; + const body = address.slice(2); + // No mixed case ⇒ no checksum was ever encoded ⇒ nothing to verify. + if (body === body.toLowerCase() || body === body.toUpperCase()) return true; + return address === evmChecksumAddress(hexToBytes(body.toLowerCase())); +} diff --git a/ts/src/domain/contact/contact.test.ts b/ts/src/domain/contact/contact.test.ts index 9624fc827..d6e1fb3c1 100644 --- a/ts/src/domain/contact/contact.test.ts +++ b/ts/src/domain/contact/contact.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { contactNameKey, contactNote, createContact } from "./index.js"; +import { + contactName, + contactNameKey, + contactNote, + createContact, + resemblesAddress, +} from "./index.js"; const ADDRESS = "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC"; @@ -24,8 +30,69 @@ describe("contact validation", () => { }); it("rejects an invalid Base58Check address", () => { + // The message now names the family rather than the encoding, since each family validates + // against its own codec. expect(() => createContact("tron", "alice", "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HX")).toThrow( - /Base58Check/, + /valid tron address/, ); }); }); + +const TRON = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; +const EVM = "0xe2E1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; + +describe("createContact validates the address against its own family", () => { + it.each([ + ["tron", TRON], + ["evm", EVM], + ])("accepts a %s address", (family, address) => { + expect(createContact(family as never, "friend", address)).toMatchObject({ family, address }); + }); + + // Storing a TRON address under `evm` would make `--to friend` on an EVM network resolve to an + // address that does not exist there. + it.each([ + ["tron", EVM], + ["evm", TRON], + ])("rejects an address belonging to another family (%s)", (family, address) => { + expect(() => createContact(family as never, "friend", address)).toThrow(); + }); + + it("rejects an EVM address whose checksum does not hold", () => { + expect(() => + createContact("evm" as never, "friend", "0xe2e1a54926527Fbb4E4420DE4c6BAb82beAEE24D"), + ).toThrow(); + }); +}); + +// A contact name that looks like an address is how a typo'd recipient becomes a silent redirect: +// the address fails validation, falls through to a name lookup, and matches the impostor. The +// TRON side has always been guarded; EVM must be too, before contacts can live in an evm bucket. +describe("contact names may not impersonate an address of any family", () => { + it.each([ + ["an EVM address", EVM], + ["a lowercase EVM address", EVM.toLowerCase()], + ["an uppercase EVM address", `0x${EVM.slice(2).toUpperCase()}`], + ])("rejects %s as a name", (_label, name) => { + expect(() => contactName(name)).toThrow(/must not resemble/); + }); + + it("still accepts ordinary names that merely start with 0x", () => { + expect(contactName("0x-not-an-address")).toBe("0x-not-an-address"); + }); +}); + +describe("resemblesAddress spots a near-miss of any family", () => { + it.each([ + ["tron", TRON], + ["tron with a broken checksum", "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL7"], + ["evm", EVM], + ["evm with a broken checksum", "0xe2e1a54926527Fbb4E4420DE4c6BAb82beAEE24D"], + ])("is true for %s", (_label, value) => { + expect(resemblesAddress(value)).toBe(true); + }); + + it("is false for an ordinary name", () => { + expect(resemblesAddress("team-vault")).toBe(false); + }); +}); diff --git a/ts/src/domain/contact/index.ts b/ts/src/domain/contact/index.ts index 28a0a5517..13040fc31 100644 --- a/ts/src/domain/contact/index.ts +++ b/ts/src/domain/contact/index.ts @@ -1,10 +1,18 @@ import type { ChainFamily, ContactEntry } from "../types/index.js"; import { UsageError } from "../errors/index.js"; -import { TronAddress } from "../address/index.js"; +import { addressCodec, CHAIN_FAMILIES } from "../family/index.js"; -const TRON_SHAPED = /^T[1-9A-HJ-NP-Za-km-z]{25,40}$/; +/** + * Address-SHAPED, not address-VALID: these deliberately match a near-miss too — a checksum typo, + * a truncated paste. A name may not look like any of them, and a recipient that looks like one is + * never allowed to fall through to a name lookup. Without that, "typed one character wrong" + * silently becomes "sent to whoever registered that name". + */ +const ADDRESS_SHAPED: Array<[ChainFamily, RegExp]> = [ + ["tron", /^T[1-9A-HJ-NP-Za-km-z]{25,40}$/], // base58check + ["evm", /^0x[0-9a-fA-F]{38,42}$/], // hex +]; const UNSAFE_TEXT = /[\p{Cc}\p{Cf}]/u; -const ADDRESS = new TronAddress(); /** Case-insensitive, compatibility-normalized lookup key. */ export function contactNameKey(input: string): string { @@ -14,10 +22,10 @@ export function contactNameKey(input: string): string { 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)) { + if (length < 1 || length > 64 || UNSAFE_TEXT.test(value) || resemblesAddress(value)) { throw new UsageError( "invalid_value", - "contact name must be 1-64 safe characters and must not resemble a TRON address", + "contact name must be 1-64 safe characters and must not resemble a chain address", ); } return value; @@ -38,11 +46,10 @@ export function createContact( 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", - ); + // Validated against the entry's OWN family: a TRON address filed under `evm` would make + // `--to friend` resolve, on an EVM network, to an address that does not exist there. + if (!CHAIN_FAMILIES.includes(family) || !addressCodec(family).validate(address)) { + throw new UsageError("invalid_value", `contact address must be a valid ${family} address`); } const name = contactName(nameInput); return { @@ -54,6 +61,18 @@ export function createContact( }; } -export function resemblesTronAddress(input: string): boolean { - return TRON_SHAPED.test(input.trim()); +/** whether a value looks like a chain address of ANY family — including a malformed one. */ +export function resemblesAddress(input: string): boolean { + return resembledFamily(input) !== undefined; +} + +/** + * Which family a value LOOKS like, by shape alone — unlike `familyOf`, which needs a valid + * address. That difference is the point: a mistyped recipient has no valid family, and telling + * the user it "resembles a address" names the wrong chain's rules and sends + * them to check the wrong thing. + */ +export function resembledFamily(input: string): ChainFamily | undefined { + const value = input.trim(); + return ADDRESS_SHAPED.find(([, shape]) => shape.test(value))?.[0]; } diff --git a/ts/src/domain/derivation/derivation.test.ts b/ts/src/domain/derivation/derivation.test.ts index cf645b907..8b091eb8a 100644 --- a/ts/src/domain/derivation/derivation.test.ts +++ b/ts/src/domain/derivation/derivation.test.ts @@ -34,3 +34,17 @@ describe("AddressCodec.validate", () => { expect(tron.validate("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")).toBe(false); }); }); + +// §1.2: each family follows its own ecosystem's template, so the account number hangs at a +// DIFFERENT level per family. Swapping the coin type alone is not enough. +describe("Derivation.path follows each family's own BIP44 template", () => { + it("puts the TRON account number at the account level", () => { + expect(Derivation.path("tron", 0)).toBe("m/44'/195'/0'/0/0"); + expect(Derivation.path("tron", 2)).toBe("m/44'/195'/2'/0/0"); + }); + + it("puts the EVM account number at the address_index level", () => { + expect(Derivation.path("evm", 0)).toBe("m/44'/60'/0'/0/0"); + expect(Derivation.path("evm", 2)).toBe("m/44'/60'/0'/0/2"); + }); +}); diff --git a/ts/src/domain/derivation/index.ts b/ts/src/domain/derivation/index.ts index 575e5aa0e..dd49430cc 100644 --- a/ts/src/domain/derivation/index.ts +++ b/ts/src/domain/derivation/index.ts @@ -38,9 +38,12 @@ export class Derivation { return entropyToMnemonic(entropy, wordlist); } - /** m/44'/{coin}'/{account}'/0/0 */ + /** the family's own BIP44 template with `account` slotted into the level it uses (§1.2). */ static path(family: ChainFamily, account: number): string { - return `m/44'/${FAMILIES[family].coinType}'/${account}'/0/0`; + const { coinType, indexAt } = FAMILIES[family]; + return indexAt === "account" + ? `m/44'/${coinType}'/${account}'/0/0` + : `m/44'/${coinType}'/0'/0/${account}`; } /** Derive a keypair from a 64-byte seed at the given BIP44 path. publicKey is uncompressed (65B). */ diff --git a/ts/src/domain/family/chain-family.ts b/ts/src/domain/family/chain-family.ts index 715ed576d..8fe229892 100644 --- a/ts/src/domain/family/chain-family.ts +++ b/ts/src/domain/family/chain-family.ts @@ -9,5 +9,5 @@ * a module made every type that names a family reach through the registry, which is what closed the * `types → family → address → types` cycle. */ -export const ChainFamily = { tron: "tron" } as const; +export const ChainFamily = { tron: "tron", evm: "evm" } as const; export type ChainFamily = (typeof ChainFamily)[keyof typeof ChainFamily]; diff --git a/ts/src/domain/family/family.test.ts b/ts/src/domain/family/family.test.ts index 9db17dafe..fd9426c51 100644 --- a/ts/src/domain/family/family.test.ts +++ b/ts/src/domain/family/family.test.ts @@ -1,12 +1,46 @@ import { describe, it, expect } from "vitest"; -import { FAMILIES } from "./index.js"; +import { FAMILIES, familyOf } from "./index.js"; describe("domain family facts + ledger meta", () => { it("tron carries the expected coin facts and is ledger-wired", () => { expect(FAMILIES.tron.nativeUnit).toBe("sun"); - expect(FAMILIES.tron.nativeSymbol).toBe("TRX"); + // the coin's SYMBOL is not here — it belongs to the network (evm:1 = ETH, evm:56 = BNB), + // and a family-level one could only ever be right for one chain of the family. + expect("nativeSymbol" in FAMILIES.tron).toBe(false); expect(FAMILIES.tron.nativeDecimals).toBe(6); expect(FAMILIES.tron.coinType).toBe(195); expect(FAMILIES.tron.ledger).toEqual({ app: "tron" }); }); }); + +describe("evm family facts", () => { + it("carries ETH/wei coin facts at BIP44 coin type 60", () => { + expect(FAMILIES.evm).toMatchObject({ + family: "evm", + nativeUnit: "wei", + nativeDecimals: 18, + coinType: 60, + }); + }); + + // The field's contract is "present = hardware app wired", and it drives both assertWired() and + // the `--app` choices `import ledger` offers. It was deliberately absent until hw-app-eth was + // a dependency; wiring the app is what makes it correct to declare. + it("is ledger-wired to the ethereum app", () => { + expect(FAMILIES.evm.ledger).toEqual({ app: "ethereum" }); + }); +}); + +describe("familyOf detects a family from an address's encoding", () => { + it.each([ + ["TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6", "tron"], + ["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "evm"], + ["0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", "evm"], + ])("maps %s to %s", (address, family) => { + expect(familyOf(address)).toBe(family); + }); + + it("returns undefined for a checksum-broken EVM address rather than guessing", () => { + expect(familyOf("0xf39fd6e51aad88F6F4ce6aB8827279cffFb92266")).toBeUndefined(); + }); +}); diff --git a/ts/src/domain/family/index.ts b/ts/src/domain/family/index.ts index 1cc2f738a..bb7cff83e 100644 --- a/ts/src/domain/family/index.ts +++ b/ts/src/domain/family/index.ts @@ -8,7 +8,7 @@ * * Adding a chain = one entry in FAMILIES (facts) + one FamilyDef in FAMILY_REGISTRY. */ -import { type AddressCodec, TronAddress } from "../address/index.js"; +import { type AddressCodec, EvmAddress, TronAddress } from "../address/index.js"; /** The family identity itself lives in a dependency-free module; re-exported here so the registry * stays the one place callers import family facts from. */ @@ -18,23 +18,37 @@ import type { ChainFamily } from "./chain-family.js"; export interface FamilyMeta { family: ChainFamily; nativeUnit: string; // smallest-unit name: "sun" / "wei" - nativeSymbol: string; // native coin display symbol: "TRX" / "ETH" + // NOTE: the coin's SYMBOL is deliberately absent — it lives on NetworkDescriptor. Two networks + // of one family can use different coins (evm:1 = ETH, evm:56 = BNB), so a family-level symbol + // can only ever be right for one of them. nativeDecimals: number; // native coin decimals: base unit → coin (sun→TRX = 6) coinType: number; // BIP44 coin_type + /** which BIP44 level the account number hangs at — each family follows its own ecosystem + * convention, so the coin type alone does not determine the path (§1.2). */ + indexAt: "account" | "addressIndex"; codec: AddressCodec; // address derive/validate ledger?: { app: string }; // present = hardware app wired; value = the Ledger app name } -export const FAMILIES: Record = { +export const FAMILIES: { [F in ChainFamily]: FamilyMeta & { family: F } } = { tron: { family: "tron", nativeUnit: "sun", - nativeSymbol: "TRX", nativeDecimals: 6, coinType: 195, + indexAt: "account", // m/44'/195'/'/0/0 codec: new TronAddress(), ledger: { app: "tron" }, }, + evm: { + family: "evm", + nativeUnit: "wei", + nativeDecimals: 18, + coinType: 60, + indexAt: "addressIndex", // m/44'/60'/0'/0/ — MetaMask/Trezor/Rabby, not Ledger Live + codec: new EvmAddress(), + ledger: { app: "ethereum" }, + }, }; /** every known family, in declaration order. */ diff --git a/ts/src/domain/fees/evm-gas.test.ts b/ts/src/domain/fees/evm-gas.test.ts new file mode 100644 index 000000000..7430508fd --- /dev/null +++ b/ts/src/domain/fees/evm-gas.test.ts @@ -0,0 +1,158 @@ +/** + * The EVM gas fee model — pure arithmetic over numbers the gateway supplies. + * + * The mode decision is the load-bearing part. Measured on the four builtin chains: + * ethereum / sepolia → baseFeePerGas non-zero + * bsc / bsc-testnet → baseFeePerGas PRESENT BUT ZERO + * so "the field is non-zero" would misclassify BSC. A zero base fee is not the absence of + * EIP-1559: it is EIP-1559 where the whole fee is the tip, which is exactly BSC's model — and + * the 1559 arithmetic degenerates to the legacy one on its own, with no second code path. + */ +import { describe, it, expect } from "vitest"; +import { evmFeeMode, gweiToWei, planEvmFee } from "./evm-gas.js"; + +const GAS_LIMIT = "21000"; + +describe("evmFeeMode", () => { + it("treats a non-zero base fee as EIP-1559", () => { + expect(evmFeeMode("155315168")).toBe("eip1559"); + }); + + it("treats a ZERO base fee as EIP-1559 too — that is BSC, not a legacy chain", () => { + expect(evmFeeMode("0")).toBe("eip1559"); + }); + + it("falls back to legacy when the chain reports no base fee at all", () => { + expect(evmFeeMode(undefined)).toBe("legacy"); + }); + + // The escape hatch: a chain that advertises a base fee but refuses type-2 transactions can be + // pinned through the network's own feeModel rather than by patching the detection. + it("lets a network force legacy despite reporting a base fee", () => { + expect(evmFeeMode("155315168", "legacy")).toBe("legacy"); + }); + + it("ignores the umbrella evm-gas label and decides from the chain", () => { + expect(evmFeeMode("0", "evm-gas")).toBe("eip1559"); + expect(evmFeeMode(undefined, "evm-gas")).toBe("legacy"); + }); +}); + +describe("planEvmFee — EIP-1559", () => { + const base = { baseFeeWei: "100", suggestedPriorityWei: "10", gasPriceWei: "110" }; + + it("defaults maxFee to base doubled plus the priority tip", () => { + const plan = planEvmFee({ ...base, gasLimit: GAS_LIMIT }); + + expect(plan).toMatchObject({ + mode: "eip1559", + maxFeeWei: "210", + priorityFeeWei: "10", + gasLimit: GAS_LIMIT, + }); + }); + + it("reports the worst-case cost as gasLimit times maxFee", () => { + expect(planEvmFee({ ...base, gasLimit: GAS_LIMIT }).maxCostWei).toBe(String(21000n * 210n)); + }); + + it("honours both overrides verbatim", () => { + const plan = planEvmFee({ + ...base, + gasLimit: GAS_LIMIT, + overrides: { maxFeeWei: "500", priorityFeeWei: "20" }, + }); + + expect(plan).toMatchObject({ maxFeeWei: "500", priorityFeeWei: "20" }); + }); + + it("clamps a suggested tip that exceeds a user-supplied maxFee", () => { + // maxPriorityFeePerGas > maxFeePerGas is rejected by nodes; the user's ceiling wins. + const plan = planEvmFee({ + ...base, + suggestedPriorityWei: "900", + gasLimit: GAS_LIMIT, + overrides: { maxFeeWei: "500" }, + }); + + expect(plan.priorityFeeWei).toBe("500"); + }); + + it("derives maxFee from a lone priority override", () => { + const plan = planEvmFee({ ...base, gasLimit: GAS_LIMIT, overrides: { priorityFeeWei: "50" } }); + + expect(plan).toMatchObject({ maxFeeWei: "250", priorityFeeWei: "50" }); + }); + + it("takes the gas limit override over the estimate", () => { + expect(planEvmFee({ ...base, gasLimit: GAS_LIMIT, overrides: { gasLimit: "90000" } }).gasLimit) + .toBe("90000"); + }); + + // BSC: base fee zero means the whole fee is the tip, and the formula produces exactly that. + it("degenerates to a tip-only fee when the base fee is zero", () => { + const plan = planEvmFee({ + baseFeeWei: "0", + suggestedPriorityWei: "50000000", + gasPriceWei: "50000000", + gasLimit: GAS_LIMIT, + }); + + expect(plan).toMatchObject({ mode: "eip1559", maxFeeWei: "50000000" }); + }); +}); + +describe("planEvmFee — legacy", () => { + const legacy = { gasPriceWei: "3000000000", gasLimit: GAS_LIMIT }; + + it("prices from gasPrice and reports no 1559 fields", () => { + const plan = planEvmFee(legacy); + + expect(plan).toMatchObject({ mode: "legacy", gasPriceWei: "3000000000" }); + expect(plan.maxFeeWei).toBeUndefined(); + expect(plan.priorityFeeWei).toBeUndefined(); + }); + + it("reports the cost as gasLimit times gasPrice", () => { + expect(planEvmFee(legacy).maxCostWei).toBe(String(21000n * 3000000000n)); + }); + + // Silently dropping a fee flag the chain cannot honour would misreport what was signed. + it("refuses a 1559 override on a legacy chain", () => { + expect(() => planEvmFee({ ...legacy, overrides: { maxFeeWei: "500" } })).toThrow( + /legacy|1559|not support/i, + ); + expect(() => planEvmFee({ ...legacy, overrides: { priorityFeeWei: "5" } })).toThrow(); + }); + + it("still accepts a gas limit override", () => { + expect(planEvmFee({ ...legacy, overrides: { gasLimit: "50000" } }).gasLimit).toBe("50000"); + }); +}); + +describe("gweiToWei", () => { + it("scales by nine decimal places", () => { + expect(gweiToWei("30")).toBe("30000000000"); + expect(gweiToWei("0.05")).toBe("50000000"); + }); + + it("keeps sub-gwei precision down to a single wei", () => { + expect(gweiToWei("0.000000001")).toBe("1"); + }); + + // The reason this is string arithmetic and not `parseFloat(x) * 1e9`: past 2^53 a float cannot + // represent consecutive integers, so the scaled result would come back off by one wei — and a + // fee ceiling is not a place to silently lose the last digit. + it("stays exact for a value whose wei amount exceeds Number.MAX_SAFE_INTEGER", () => { + expect(gweiToWei("9007199.254740993")).toBe("9007199254740993"); + expect(Number("9007199254740993")).toBe(9007199254740992); // what a float would have given + }); + + it("rejects a value finer than one wei rather than rounding it away", () => { + expect(() => gweiToWei("0.0000000001")).toThrow(); + }); + + it("rejects text that is not a number", () => { + expect(() => gweiToWei("fast")).toThrow(); + }); +}); diff --git a/ts/src/domain/fees/evm-gas.ts b/ts/src/domain/fees/evm-gas.ts new file mode 100644 index 000000000..b8151a086 --- /dev/null +++ b/ts/src/domain/fees/evm-gas.ts @@ -0,0 +1,128 @@ +/** + * The EVM gas fee model — pure arithmetic, zero I/O. The gateway reads the numbers off the chain; + * this decides what they mean and what a transaction will cost at worst. + * + * Everything is a decimal wei string carried through BigInt: a gas price times a gas limit + * comfortably exceeds Number.MAX_SAFE_INTEGER, and this figure is what a user is shown before + * they agree to spend it. + */ +import { UsageError } from "../errors/index.js"; + +export type EvmFeeMode = "eip1559" | "legacy"; + +export interface EvmFeeOverrides { + maxFeeWei?: string; + priorityFeeWei?: string; + gasLimit?: string; +} + +export interface EvmFeeInput { + /** the latest block's baseFeePerGas; absent when the chain does not implement EIP-1559. */ + baseFeeWei?: string; + /** the node's suggested tip (`eth_maxPriorityFeePerGas`). */ + suggestedPriorityWei?: string; + gasPriceWei: string; + /** the estimate, used unless overridden — deliberately not padded (see `plan`). */ + gasLimit: string; + /** the network's declared fee model, used only to force legacy. */ + declaredFeeModel?: string; + overrides?: EvmFeeOverrides; +} + +export interface EvmFeePlan { + mode: EvmFeeMode; + gasLimit: string; + maxFeeWei?: string; + priorityFeeWei?: string; + gasPriceWei?: string; + /** the most this transaction can cost: gasLimit × the per-gas ceiling. */ + maxCostWei: string; +} + +/** + * Which transaction type this chain takes. + * + * A base fee of ZERO still means EIP-1559 — that is BSC, where the base fee is always zero and + * the entire fee is the tip. Requiring a non-zero value would misclassify it and force a second + * code path for a case the 1559 arithmetic already handles: with base = 0 the formula collapses + * to "the fee is the tip", which is precisely the legacy behaviour on that chain. + * + * `declared` is the escape hatch. A chain that advertises a base fee but rejects type-2 + * transactions can be pinned with `feeModel: "legacy"` in its network entry, without anyone + * having to special-case it here. The umbrella "evm-gas" label declares nothing and is ignored. + */ +export function evmFeeMode(baseFeeWei?: string, declared?: string): EvmFeeMode { + if (declared === "legacy") return "legacy"; + return baseFeeWei === undefined ? "legacy" : "eip1559"; +} + +/** + * Resolve the fee a transaction will be signed with. + * + * The gas limit is the estimate as-is, never padded: a silent multiplier would inflate the + * ceiling shown by `--dry-run`, and the point of that number is that it is the truth. When an + * estimate really is too tight — a contract call racing a state change — `--gas-limit` is the + * explicit way to say so. + */ +export function planEvmFee(input: EvmFeeInput): EvmFeePlan { + const mode = evmFeeMode(input.baseFeeWei, input.declaredFeeModel); + const overrides = input.overrides ?? {}; + const gasLimit = overrides.gasLimit ?? input.gasLimit; + + if (mode === "legacy") { + // Accepting a flag the chain cannot honour would misreport what was actually signed. + if (overrides.maxFeeWei !== undefined || overrides.priorityFeeWei !== undefined) { + throw new UsageError( + "invalid_option", + "--max-fee and --priority-fee need an EIP-1559 chain; this network prices in gasPrice", + ); + } + return { + mode, + gasLimit, + gasPriceWei: input.gasPriceWei, + maxCostWei: (BigInt(gasLimit) * BigInt(input.gasPriceWei)).toString(10), + }; + } + + const base = BigInt(input.baseFeeWei ?? "0"); + const suggested = BigInt(input.suggestedPriorityWei ?? "0"); + const priorityGiven = + overrides.priorityFeeWei === undefined ? undefined : BigInt(overrides.priorityFeeWei); + // A lone --max-fee keeps the node's suggested tip; a lone --priority-fee sets the ceiling from + // it. Doubling the base leaves room for it to rise over the next few blocks, which is the + // usual headroom rule and the reason the ceiling is not just base + tip. + const maxFee = + overrides.maxFeeWei !== undefined + ? BigInt(overrides.maxFeeWei) + : base * 2n + (priorityGiven ?? suggested); + // maxPriorityFeePerGas above maxFeePerGas is rejected outright by nodes, so the user's ceiling + // wins over a suggestion that outgrew it. + const priority = priorityGiven ?? (suggested > maxFee ? maxFee : suggested); + + return { + mode, + gasLimit, + maxFeeWei: maxFee.toString(10), + priorityFeeWei: priority.toString(10), + maxCostWei: (BigInt(gasLimit) * maxFee).toString(10), + }; +} + +/** + * Gas prices are quoted in gwei everywhere a human reads them — wallets, explorers, docs — so the + * fee flags take gwei while everything downstream carries wei. Nine zeros is a real typo risk in + * the other direction. + * + * Scaled by string manipulation rather than float arithmetic: `0.05 * 1e9` is not exactly + * 50000000 in binary floating point, and a fee is not a place to discover that. + */ +export function gweiToWei(gwei: string): string { + const match = /^(\d+)(?:\.(\d+))?$/.exec(gwei.trim()); + if (!match) throw new UsageError("invalid_value", `not a gwei amount: ${gwei}`); + const fraction = match[2] ?? ""; + if (fraction.length > 9) { + throw new UsageError("invalid_value", `${gwei} gwei is finer than one wei`); + } + return BigInt(`${match[1]}${fraction.padEnd(9, "0")}`).toString(10); +} diff --git a/ts/src/domain/migration/index.ts b/ts/src/domain/migration/index.ts new file mode 100644 index 000000000..55a1f99e6 --- /dev/null +++ b/ts/src/domain/migration/index.ts @@ -0,0 +1,41 @@ +import { ExecutionError } from "../errors/index.js"; + +/** + * Migration planning — pure decisions about which persisted files lag the running binary. + * Reading versions and applying migrations is I/O and lives in the adapters; this module only + * decides what is stale and what that will cost. + */ + +export interface MigrationCandidate { + path: string; + currentVersion: number; + storedVersion: number; + needsPassword: boolean; +} + +export interface MigrationPlan { + stale: MigrationCandidate[]; + needsPassword: boolean; +} + +export function planMigrations(candidates: MigrationCandidate[]): MigrationPlan { + const stale = candidates.filter((c) => c.storedVersion < c.currentVersion); + return { stale, needsPassword: stale.some((c) => c.needsPassword) }; +} + +/** + * The version a stored document reports. An ABSENT file (readJson → null) is a fresh install, + * not a stale one: it reports the current version so the gate leaves it alone and `create` + * can run on a clean machine. + * + * Anything present but without a usable version is CORRUPT, never "version 0". Reading it as 0 + * would run a migration against a shape we know nothing about, on a file holding wallet state. + */ +export function storedVersionOf(doc: unknown, currentVersion: number, label: string): number { + if (doc === null || doc === undefined) return currentVersion; + const version = (doc as { version?: unknown }).version; + if (typeof version !== "number" || !Number.isInteger(version) || version < 1) { + throw new ExecutionError("encoding_error", `${label} has an invalid schema version`); + } + return version; +} diff --git a/ts/src/domain/migration/migration.test.ts b/ts/src/domain/migration/migration.test.ts new file mode 100644 index 000000000..ef79a8e12 --- /dev/null +++ b/ts/src/domain/migration/migration.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { planMigrations, storedVersionOf } from "./index.js"; + +describe("planMigrations", () => { + it("plans no work when every file is already at the current version", () => { + const plan = planMigrations([ + { path: "wallets.json", currentVersion: 2, storedVersion: 2, needsPassword: false }, + { path: "contacts.json", currentVersion: 2, storedVersion: 2, needsPassword: false }, + ]); + + expect(plan.stale).toEqual([]); + expect(plan.needsPassword).toBe(false); + }); + + it("plans work for a file whose stored version lags the binary", () => { + const plan = planMigrations([ + { path: "wallets.json", currentVersion: 2, storedVersion: 1, needsPassword: false }, + { path: "contacts.json", currentVersion: 2, storedVersion: 2, needsPassword: false }, + ]); + + expect(plan.stale.map((c) => c.path)).toEqual(["wallets.json"]); + }); + + // ADR-0008: the check is `<`, not `!==`. A file written by a NEWER binary is left alone + // rather than being "migrated" downward into a shape this binary invented. + it("leaves a file newer than the binary alone", () => { + const plan = planMigrations([ + { path: "wallets.json", currentVersion: 2, storedVersion: 3, needsPassword: true }, + ]); + + expect(plan.stale).toEqual([]); + expect(plan.needsPassword).toBe(false); + }); + + it("requires the password when a stale file needs one", () => { + const plan = planMigrations([ + { path: "contacts.json", currentVersion: 2, storedVersion: 1, needsPassword: false }, + { path: "wallets.json", currentVersion: 2, storedVersion: 1, needsPassword: true }, + ]); + + expect(plan.needsPassword).toBe(true); + }); + + // A keystore holding only ledger / watch accounts migrates with no secret at all: they are + // single-family by construction and carry no address map to fill in (ADR-0008). + it("requires no password when no stale file needs one", () => { + const plan = planMigrations([ + { path: "wallets.json", currentVersion: 2, storedVersion: 1, needsPassword: false }, + ]); + + expect(plan.stale).toHaveLength(1); + expect(plan.needsPassword).toBe(false); + }); + + it("ignores a password-needing file that is not stale", () => { + const plan = planMigrations([ + { path: "wallets.json", currentVersion: 2, storedVersion: 2, needsPassword: true }, + { path: "contacts.json", currentVersion: 2, storedVersion: 1, needsPassword: false }, + ]); + + expect(plan.needsPassword).toBe(false); + }); +}); + +describe("storedVersionOf", () => { + // The bug this exists to prevent: keystore/index.ts and contactbook/index.ts synthesise a + // LITERAL version 1 for an absent file. Once CURRENT is 2, a machine with no wallet at all + // would look stale and be told to migrate something that was never created. + it("treats an absent file as already current", () => { + expect(storedVersionOf(null, 2, "wallets.json")).toBe(2); + }); + + it("reports the version a stored document carries", () => { + expect(storedVersionOf({ version: 1, wallets: [] }, 2, "wallets.json")).toBe(1); + }); + + // A garbage version must NOT read as 0 and trigger a migration: that would run a v1->v2 + // transform against a shape we know nothing about, on a file holding wallet state. + it.each([ + ["missing", { wallets: [] }], + ["non-numeric", { version: "1" }], + ["fractional", { version: 1.5 }], + ["zero", { version: 0 }], + ["negative", { version: -1 }], + ])("rejects a document whose version is %s", (_label, doc) => { + expect(() => storedVersionOf(doc, 2, "wallets.json")).toThrow(/wallets\.json/); + }); +}); diff --git a/ts/src/domain/migration/wallets-v2.test.ts b/ts/src/domain/migration/wallets-v2.test.ts new file mode 100644 index 000000000..037d12de4 --- /dev/null +++ b/ts/src/domain/migration/wallets-v2.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { migrateWalletsToV2, walletsNeedPassword } from "./wallets-v2.js"; +import { Derivation } from "../derivation/index.js"; +import { TronAddress, evmAddressFromPublicKey } from "../address/index.js"; +import type { ChainAddresses } from "../types/index.js"; + +const seedWallet = { id: "wlt_s", source: { type: "seed", vaultId: "v1", addresses: {} } }; +const pkWallet = { id: "wlt_k", source: { type: "privateKey", keyId: "k1", addresses: {} } }; +const ledgerWallet = { + id: "wlt_l", + source: { type: "ledger", family: "tron", nativeSymbol: "TRX", path: "m/44'/195'/0'/0/0", address: "T1" }, +}; +const watchWallet = { id: "wlt_w", source: { type: "watch", family: "tron", nativeSymbol: "TRX", address: "T2" } }; + +describe("walletsNeedPassword", () => { + // The migration re-runs the SAME derivation the creation path uses, so any source holding a + // local secret must be decrypted. That is exactly SOURCE_KINDS[type].hasSecret — an exhaustive + // registry, so a new source type is forced to answer rather than defaulting to "free". + it.each([ + ["a seed wallet", seedWallet], + ["a privateKey wallet", pkWallet], + ])("is true for %s", (_label, wallet) => { + expect(walletsNeedPassword({ version: 1, wallets: [wallet] })).toBe(true); + }); + + // ledger and watch hold no secret anywhere and are single-family by construction, so a + // keystore made only of them still migrates with no prompt at all. + it("is false when no wallet holds a local secret", () => { + expect(walletsNeedPassword({ version: 1, wallets: [ledgerWallet, watchWallet] })).toBe(false); + }); + + it("is false for an empty keystore", () => { + expect(walletsNeedPassword({ version: 1, wallets: [] })).toBe(false); + }); +}); + +// A real key pair: the two encodings of one public key (see domain/address tests). +const TRON_ADDR = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; +const EVM_ADDR = "0xe2E1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; + +const noSeeds = (): never => { + throw new Error("seed access must not be needed"); +}; + +const seed = Derivation.mnemonicToSeed( + "test test test test test test test test test test test junk", +); +const PRIV_KEY = Derivation.derive(seed, "m/44'/195'/0'/0/0").privateKey; + +const secrets = { + seedFor: (vaultId: string) => { + if (vaultId !== "v1") throw new Error(`unexpected vault ${vaultId}`); + return seed; + }, + keyFor: (keyId: string) => { + if (keyId !== "k1") throw new Error(`unexpected key ${keyId}`); + return PRIV_KEY; + }, +}; + +const noSecrets = { + seedFor: (): never => { + throw new Error("seed access must not be needed"); + }, + keyFor: (): never => { + throw new Error("key access must not be needed"); + }, +}; + +describe("migrateWalletsToV2 — privateKey", () => { + // Both addresses come from the DECRYPTED key, the same way derivePrivAddresses builds them at + // import time — not from re-encoding whatever the file happened to cache. A stale cached value + // is therefore corrected, and no second statement of "how an EVM address is derived" exists. + it("derives both addresses from the key, replacing a stale cached address", () => { + const doc = { + version: 1, + wallets: [ + { id: "wlt_k", source: { type: "privateKey", keyId: "k1", addresses: { tron: "T-stale" } } }, + ], + }; + + const out = migrateWalletsToV2(doc, secrets); + + expect((out.wallets[0]!.source as { addresses: ChainAddresses }).addresses).toEqual({ + tron: TRON_ADDR, + evm: EVM_ADDR, + }); + }); +}); + +describe("migrateWalletsToV2 — the untouched sources", () => { + it("leaves ledger and watch accounts alone without touching any secret", () => { + const ledger = { type: "ledger", family: "tron", nativeSymbol: "TRX", path: "m/44'/195'/0'/0/0", address: TRON_ADDR }; + const watch = { type: "watch", family: "tron", nativeSymbol: "TRX", address: TRON_ADDR }; + const doc = { + version: 1, + wallets: [{ id: "wlt_l", source: ledger }, { id: "wlt_w", source: watch }], + }; + + const out = migrateWalletsToV2(doc, noSecrets); + + expect(out.wallets[0]!.source).toEqual(ledger); + expect(out.wallets[1]!.source).toEqual(watch); + }); + + it("stamps the new version", () => { + expect(migrateWalletsToV2({ version: 1, wallets: [] }, noSecrets).version).toBe(2); + }); +}); + +describe("migrateWalletsToV2 — the seed path", () => { + const docWithIndices = (indices: string[]) => ({ + version: 1, + wallets: [ + { + id: "wlt_s", + source: { + type: "seed", + vaultId: "v1", + addresses: Object.fromEntries(indices.map((i) => [i, { tron: `T-stale-${i}` }])), + }, + }, + ], + }); + + const addressesOf = (out: { wallets: Array<{ source: unknown }> }) => + (out.wallets[0]!.source as { addresses: Record }).addresses; + + it("derives each known index's EVM address at m/44'/60'/0'/0/N", () => { + const addresses = addressesOf(migrateWalletsToV2(docWithIndices(["0", "2"]), secrets)); + + for (const index of ["0", "2"]) { + expect(addresses[index]!.evm).toBe( + evmAddressFromPublicKey(Derivation.derive(seed, `m/44'/60'/0'/0/${index}`).publicKey), + ); + } + }); + + // Previously this asserted the cached TRON address was PRESERVED. Re-running the creation + // path's derivation recomputes every family, so a stale cached value is corrected instead — + // there is one derivation rule, and the file is brought into line with it. + it("re-derives the TRON address too, correcting a stale cached value", () => { + const addresses = addressesOf(migrateWalletsToV2(docWithIndices(["0"]), secrets)); + + expect(addresses["0"]!.tron).toBe( + new TronAddress().fromPublicKey(Derivation.derive(seed, "m/44'/195'/0'/0/0").publicKey), + ); + }); + + it("decrypts each vault only once, however many indices it has", () => { + let calls = 0; + migrateWalletsToV2(docWithIndices(["0", "1", "2", "3"]), { + ...secrets, + seedFor: (id: string) => { + calls += 1; + return secrets.seedFor(id); + }, + }); + + expect(calls).toBe(1); + }); +}); diff --git a/ts/src/domain/migration/wallets-v2.ts b/ts/src/domain/migration/wallets-v2.ts new file mode 100644 index 000000000..e344ce034 --- /dev/null +++ b/ts/src/domain/migration/wallets-v2.ts @@ -0,0 +1,58 @@ +/** + * wallets.json v1 → v2: every account gains its EVM address (ADR-0008). + * + * The migration re-runs the SAME address derivation the creation path uses — deriveSeedAddresses + * and derivePrivAddresses — so it produces exactly what `create` / `import` would have produced. + * Deriving the EVM address any other way (e.g. re-encoding the cached TRON address, which happens + * to work while both families share a key) would be a second, independent statement of the rule, + * free to drift from the first. + * + * - seed / privateKey — hold a local secret, so both decrypt and re-derive. Needs the password. + * - ledger / watch — nothing to do. Single-family by construction; they carry no address map. + */ +import type { Bytes, WalletsFile } from "../types/index.js"; +import { derivePrivAddresses, deriveSeedAddresses } from "../wallet/index.js"; +import { SOURCE_KINDS } from "../sources/index.js"; +import type { Source } from "../types/wallet.js"; + +export const WALLETS_VERSION = 2; + +/** the v1 document: identical to WalletsFile except its address maps lack `evm`. */ +export interface WalletsFileV1 { + version: number; + wallets: Array<{ id: string; source: Record }>; + [key: string]: unknown; +} + +export function walletsNeedPassword(doc: WalletsFileV1): boolean { + return doc.wallets.some((w) => SOURCE_KINDS[w.source.type as Source["type"]]?.hasSecret); +} + +/** the secret material the migration needs, injected so the rules stay free of keystore I/O. */ +export interface MigrationSecrets { + seedFor(vaultId: string): Bytes; + keyFor(keyId: string): Bytes; +} + +export function migrateWalletsToV2(doc: WalletsFileV1, secrets: MigrationSecrets): WalletsFile { + const wallets = doc.wallets.map((wallet) => { + const source = wallet.source; + + if (source.type === "seed") { + const seed = secrets.seedFor(source.vaultId as string); // once per wallet, not per index + const indices = Object.keys(source.addresses as Record); + const addresses = Object.fromEntries( + indices.map((index) => [index, deriveSeedAddresses(seed, Number(index))]), + ); + return { ...wallet, source: { ...source, addresses } }; + } + + if (source.type === "privateKey") { + const addresses = derivePrivAddresses(secrets.keyFor(source.keyId as string)); + return { ...wallet, source: { ...source, addresses } }; + } + + return wallet; + }); + return { ...doc, version: WALLETS_VERSION, wallets } as unknown as WalletsFile; +} diff --git a/ts/src/domain/sources/sources.test.ts b/ts/src/domain/sources/sources.test.ts index 7dd51c6ff..8f7b642a3 100644 --- a/ts/src/domain/sources/sources.test.ts +++ b/ts/src/domain/sources/sources.test.ts @@ -39,7 +39,7 @@ describe("source registry", () => { }; const watch: Source = { type: "watch", family: "tron", address: "T..." }; const seed: Source = { type: "seed", vaultId: "vlt_x", addresses: {} }; - const priv: Source = { type: "privateKey", keyId: "key_x", addresses: { tron: "T..." } }; + const priv: Source = { type: "privateKey", keyId: "key_x", addresses: { tron: "T...", evm: "0x..." } }; expect(sourceFamily(ledger)).toBe("tron"); expect(sourceFamily(watch)).toBe("tron"); expect(sourceFamily(seed)).toBeUndefined(); diff --git a/ts/src/domain/types/contact.ts b/ts/src/domain/types/contact.ts index f98e377ba..3b0b4795d 100644 --- a/ts/src/domain/types/contact.ts +++ b/ts/src/domain/types/contact.ts @@ -10,11 +10,12 @@ export interface ContactEntry { } /** Public contact projection; storage-only normalization fields never leak. */ +/** A contact as the user sees it: a flat name → address entry. The chain is evident from the + * address itself, so `family` stays internal — it buckets the stored file and routes `--to`. */ export interface ContactView { name: string; address: string; note: string | null; - family: ChainFamily; } export interface ContactListView { diff --git a/ts/src/domain/types/network.ts b/ts/src/domain/types/network.ts index f690131c7..ce123f05e 100644 --- a/ts/src/domain/types/network.ts +++ b/ts/src/domain/types/network.ts @@ -6,13 +6,21 @@ import type { OutputMode } from "./primitives.js"; export type NetworkId = string; // canonical, e.g. "tron:nile" export type AccountRef = string; // "wlt_x.0" (HD) / "wlt_k" (privateKey) -export type FeeModel = "legacy" | "eip1559" | "tron-resource"; +export type FeeModel = "legacy" | "eip1559" | "tron-resource" | "evm-gas"; /** fields shared by every family; `family` is the discriminant for the union below. */ interface NetworkBase { id: NetworkId; chainId: string; - aliases: string[]; + /** + * Display symbol of this chain's native coin — TRX / ETH / BNB. + * + * A NETWORK fact, not a family one: `evm:1` and `evm:56` share every encoding and arithmetic + * rule that makes them EVM, but their coins are ETH and BNB. Reading this off the family table + * renders a BNB balance as ETH, which is a wallet naming the wrong currency. The family still + * owns what is genuinely family-wide — the base-unit name (wei) and its decimals. + */ + nativeSymbol: string; feeModel?: FeeModel; capabilities: string[]; } @@ -28,9 +36,21 @@ export interface TronNetworkDescriptor extends NetworkBase { gasfree?: GasFreeNetworkConfig; } -/** Single family today (TRON). Kept as a named alias so adding a family later means re-introducing - * a discriminated union here without churn at every reference. */ -export type NetworkDescriptor = TronNetworkDescriptor; +/** EVM network. Reached over JSON-RPC; `chainId` is the EIP-155 chain id as a decimal string — + * the same value the canonical id's second segment carries. */ +export interface EvmNetworkDescriptor extends NetworkBase { + family: "evm"; + httpEndpoint?: string; +} + +/** The discriminated union every chain-facing type narrows on via `family`. */ +export type NetworkDescriptor = TronNetworkDescriptor | EvmNetworkDescriptor; + +/** Narrows to the TRON descriptor. TRON-only features (GasFree, TronLink multi-sign) read fields + * that simply do not exist on other families, so they must narrow before reaching for them. */ +export function isTronNetwork(network: NetworkDescriptor): network is TronNetworkDescriptor { + return network.family === "tron"; +} export interface CapabilityDescriptor { key: string; @@ -45,6 +65,9 @@ export interface Config { /** default polling cap for broadcast commands' --wait, in ms (overridden by --wait-timeout). */ waitTimeoutMs: number; networks: Record; + /** short human-typed names for canonical ids (ADR-0010). Consulted ONLY when resolving + * `--network`; nothing downstream ever sees an alias. */ + aliases: Record; /** USD-valuation source for `account portfolio`. Missing → builtin CoinGecko. */ price?: PriceConfig; /** TronLink collaboration credentials for the currently selected service environment. */ diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index fa35fb6a1..d82932303 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -231,6 +231,7 @@ export interface TxReceiptView { blockNumber?: number; energyUsed?: number; feeSun?: string | number; + feeWei?: string; withdrawnSun?: string | number; result?: string; failed?: boolean; @@ -243,7 +244,11 @@ export interface TxInfoView extends TxParties { status?: string; blockNumber?: number | string; energyUsed?: number; // tron execution resource + gasUsed?: number; // 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). + feeWei?: string; transaction: unknown; info?: unknown; // tron receipt?: unknown; // tron diff --git a/ts/src/domain/types/wallet.ts b/ts/src/domain/types/wallet.ts index eab247af3..dacb25fea 100644 --- a/ts/src/domain/types/wallet.ts +++ b/ts/src/domain/types/wallet.ts @@ -51,6 +51,13 @@ export interface AccountDescriptor { /** HD only: the seed id (wallet id, `wlt_…`) this account was derived from — the value `derive * --seed` takes. Combined with `index`, tells which seed an account belongs to and its slot. */ seedId?: string; + /** + * Which BIP44 template each of this account's addresses came from — one entry per family it + * has. `null` for an account that was never derived (watch, private-key), which is a different + * statement from an omitted field: it says "there is no path", not "we did not look". + * The two families use different templates (§1.2), so without this a user cannot tell which. + */ + derivationPath?: Record | null; } /** mutators that may hit an existing account report whether they actually created one. */ diff --git a/ts/src/domain/wallet/wallet.test.ts b/ts/src/domain/wallet/wallet.test.ts index e1de5e2eb..3ca54b460 100644 --- a/ts/src/domain/wallet/wallet.test.ts +++ b/ts/src/domain/wallet/wallet.test.ts @@ -1,5 +1,17 @@ import { describe, it, expect } from "vitest"; -import { walletAddress, accountIndices } from "./index.js"; +import { Derivation } from "../derivation/index.js"; +import { + TronAddress, + evmAddressFromPublicKey, + evmChecksumAddress, + tronAddressBytes, +} from "../address/index.js"; +import { + walletAddress, + accountIndices, + deriveSeedAddresses, + derivePrivAddresses, +} from "./index.js"; import type { Wallet } from "../types/index.js"; const seedWallet: Wallet = { @@ -8,15 +20,15 @@ const seedWallet: Wallet = { type: "seed", vaultId: "vlt_1", addresses: { - "0": { tron: "Tron0" }, - "2": { tron: "Tron2" }, + "0": { tron: "Tron0", evm: "0xEvm0" }, + "2": { tron: "Tron2", evm: "0xEvm2" }, }, }, }; const pkWallet: Wallet = { id: "wlt_k", - source: { type: "privateKey", keyId: "key_1", addresses: { tron: "TronK" } }, + source: { type: "privateKey", keyId: "key_1", addresses: { tron: "TronK", evm: "0xEvmK" } }, }; const ledgerWallet: Wallet = { @@ -70,3 +82,29 @@ describe("accountIndices", () => { expect(accountIndices(watchWallet.source)).toEqual([]); }); }); + +describe("address derivation covers every family", () => { + const MNEMONIC = "test test test test test test test test test test test junk"; + const seed = Derivation.mnemonicToSeed(MNEMONIC); + + it("derives a seed account at each family's own template", () => { + const addresses = deriveSeedAddresses(seed, 2); + + expect(addresses.tron).toBe( + new TronAddress().fromPublicKey(Derivation.derive(seed, "m/44'/195'/2'/0/0").publicKey), + ); + expect(addresses.evm).toBe( + evmAddressFromPublicKey(Derivation.derive(seed, "m/44'/60'/0'/0/2").publicKey), + ); + }); + + // A privateKey account is ONE key wearing two encodings, which is why derivePrivAddresses + // feeds the same public key to every family codec. (The migration deliberately does NOT + // exploit this to skip decryption — see ADR-0008.) + it("derives a private-key account's two addresses from the same key", () => { + const priv = Derivation.derive(seed, "m/44'/195'/0'/0/0").privateKey; + const addresses = derivePrivAddresses(priv); + + expect(evmChecksumAddress(tronAddressBytes(addresses.tron).slice(1))).toBe(addresses.evm); + }); +}); diff --git a/ts/test/contract-deploy.test.ts b/ts/test/contract-deploy.test.ts index 1febb1094..47d0670d5 100644 --- a/ts/test/contract-deploy.test.ts +++ b/ts/test/contract-deploy.test.ts @@ -11,10 +11,13 @@ import { DETACHED } from "./detached.js"; // Regression coverage for issue #2: `contract deploy` constructor params. // • --constructor-sig was a dead flag (types come from the ABI); it was removed. -// • --params must be RAW positional values ([100, "T..."]) — the {type,value} form that -// contract call/send use is rejected by TronWeb's createSmartContract ABI encoder, and is now -// named as a format error at the command boundary before it gets there (see -// commands/contract.deploy.test.ts for that guard's own alignment coverage). +// • The format is named at the COMMAND BOUNDARY rather than reaching TronWeb, which reports a +// mismatch in ethers' internals (`invalid BigNumberish value (argument="value")`) — an +// argument name that collides with the user's own key and explains nothing. +// +// §7.3 inverted WHICH form is correct: `--params` (bare positional values) became +// `--constructor-params` ({type,value}), unifying deploy with contract call/send, which always +// took the typed form. Issue #2's protection is unchanged — only its direction is. // // The negative case fails at client-side ABI encoding *before* any node call, so it runs // hermetically (random key, no network, no funds). The positive/broadcast cases hit real Nile @@ -69,11 +72,11 @@ function deploy( "deploy", "--abi", ABI, - "--bytecode", + "--code", BYTECODE, "--fee-limit", "1000000000", - "--params", + "--constructor-params", params, ]; if (opts.dryRun) local.push("--dry-run"); @@ -93,24 +96,22 @@ describe("contract deploy — constructor params (issue #2)", () => { HOME = mkdtempSync(join(tmpdir(), "wcli-deploy-")); }); - it("rejects the {type,value} param form (raw positional values are required)", () => { + it("rejects the bare positional form (--constructor-params takes {type,value})", () => { seed(randomBytes(32).toString("hex")); // rejected at the command boundary → hermetic - const out = deploy(TYPED_PARAMS, { dryRun: true }); + const out = deploy(RAW_PARAMS, { dryRun: true }); expect(out.success).toBe(false); // A malformed call, not a failed execution: deterministic on retry, so exit 2 / invalid_value. - // (Before the guard this reached TronWeb and came back as rpc_error / `invalid BigNumberish - // value (argument="value")` — same refusal, worded in ethers' internals.) expect(out.error.code).toBe("invalid_value"); - expect(out.error.message).toMatch(/raw positional values/i); + expect(out.error.message).toMatch(/type/i); }); const PK = loadTestPrivateKey(); const LIVE = process.env.RUN_LIVE === "1" || process.env.RUN_LIVE_BROADCAST === "1"; describe.runIf(LIVE && !!PK)("on Nile (live)", () => { - it("builds a constructor-arg deploy (dry-run) with raw positional params", () => { + it("builds a constructor-arg deploy (dry-run) with typed constructor params", () => { seed(PK!); - const out = deploy(RAW_PARAMS, { dryRun: true }); + const out = deploy(TYPED_PARAMS, { dryRun: true }); expect(out.success).toBe(true); expect(out.data.mode).toBe("dry-run"); const bytecode: string = @@ -124,7 +125,7 @@ describe("contract deploy — constructor params (issue #2)", () => { "deploys and confirms a constructor-arg contract on-chain", () => { seed(PK!); - const out = deploy(RAW_PARAMS, { wait: true, timeoutMs: 120_000 }); + const out = deploy(TYPED_PARAMS, { wait: true, timeoutMs: 120_000 }); expect(out.success).toBe(true); expect(out.data.stage).toBe("confirmed"); expect(out.data.confirmed).toBe(true); diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index ef861435c..9931d6c5f 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process"; -import { mkdtempSync, readFileSync, statSync } from "node:fs"; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Keystore } from "../src/adapters/outbound/keystore/index.js"; @@ -112,10 +112,23 @@ describe("golden CLI — meta & introspection", () => { expect(r.json.success).toBe(true); expect(r.json.chain).toBeUndefined(); const ids = r.json.data.map((n: { id: string }) => n.id); - // only the 3 TRON networks ship - expect(ids).toEqual(expect.arrayContaining(["tron:mainnet", "tron:nile", "tron:shasta"])); - expect(ids).toHaveLength(3); - expect(ids.some((id: string) => id.startsWith("evm:"))).toBe(false); + // Both families ship as of v4.13.0 (§2.2): 3 TRON + 4 EVM, each mainnet paired with a + // testnet. This assertion previously read "only the 3 TRON networks ship" — EVM was + // deliberately hidden then, and is deliberately exposed now. + expect(ids).toEqual( + expect.arrayContaining([ + "tron:mainnet", + "tron:nile", + "tron:shasta", + "evm:1", + "evm:11155111", + "evm:56", + "evm:97", + ]), + ); + expect(ids).toHaveLength(7); + // machine surfaces carry canonical ids only, never aliases (ADR-0010) + expect(ids.every((id: string) => /^(tron|evm):/.test(id))).toBe(true); }); it("--json-schema emits an agent schema for a command", () => { @@ -844,3 +857,66 @@ describe("golden CLI — v4.12 governance surface", () => { expect(energy.json.error.code).toBe("invalid_value"); }); }); + +// ADR-0008. Every other migration test fakes something: the gate's unit tests fake the password +// source, the step test calls migrate() directly. This is the only one that runs the real binary, +// against a real encrypted vault, with the password arriving down fd 0 the way CI supplies it. +describe("golden CLI — startup migration", () => { + /** a real v2 keystore wound back to what a pre-EVM one looked like on disk */ + function windBackToV1() { + seedWallet(); + const path = join(HOME, "wallets.json"); + const doc = JSON.parse(readFileSync(path, "utf8")); + doc.version = 1; + for (const byIndex of Object.values( + doc.wallets[0].source.addresses as Record>, + )) { + delete byIndex.evm; + } + writeFileSync(path, JSON.stringify(doc)); + return path; + } + + it("migrates with the master password piped in, then runs the command", () => { + const path = windBackToV1(); + + const r = run(["--output", "json", "list"], { password: DEFAULT_PW }); + + expect(r.status).toBe(0); + const doc = JSON.parse(readFileSync(path, "utf8")); + expect(doc.version).toBe(2); + expect(doc.wallets[0].source.addresses["0"].evm).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + it("leaves a pre-migration copy the user can fall back to", () => { + const path = windBackToV1(); + run(["--output", "json", "list"], { password: DEFAULT_PW }); + + expect(JSON.parse(readFileSync(`${path}.v1.bak`, "utf8")).version).toBe(1); + }); + + it("refuses with migration_required when no password source is supplied", () => { + const path = windBackToV1(); + + const r = run(["--output", "json", "list"], { password: null }); + + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("migration_required"); + expect(JSON.parse(readFileSync(path, "utf8")).version).toBe(1); + }); + + it("reports auth_failed for a wrong password rather than writing garbage", () => { + const path = windBackToV1(); + + const r = run(["--output", "json", "list"], { password: "wrongpw123A" }); + + expect(r.status).not.toBe(0); + expect(r.json.error.code).toBe("auth_failed"); + expect(JSON.parse(readFileSync(path, "utf8")).version).toBe(1); + }); + + it("runs --help on a stale keystore without demanding anything", () => { + windBackToV1(); + expect(run(["--help"], { password: null }).status).toBe(0); + }); +}); From 58c391619898b2a69d93a80e628c09c151fb87a2 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 24 Aug 2026 16:08:41 +0800 Subject: [PATCH 02/23] feat: help review and update & known issues fix --- .../adapters/inbound/cli/commands/account.ts | 31 +- .../adapters/inbound/cli/commands/address.ts | 3 +- ts/src/adapters/inbound/cli/commands/block.ts | 11 +- ts/src/adapters/inbound/cli/commands/chain.ts | 14 +- .../adapters/inbound/cli/commands/contact.ts | 6 +- .../cli/commands/contract.artifact.test.ts | 331 +++++++++++++++++ .../adapters/inbound/cli/commands/contract.ts | 340 ++++++++++++++++-- .../adapters/inbound/cli/commands/encoding.ts | 3 +- .../adapters/inbound/cli/commands/shared.ts | 5 +- ts/src/adapters/inbound/cli/commands/stake.ts | 10 +- .../cli/commands/text-formatters.test.ts | 13 +- ts/src/adapters/inbound/cli/commands/token.ts | 35 +- ts/src/adapters/inbound/cli/commands/tx.ts | 79 ++-- .../inbound/cli/commands/typed-data.ts | 7 +- .../adapters/inbound/cli/commands/wallet.ts | 15 +- .../adapters/inbound/cli/commands/witness.ts | 2 +- .../adapters/inbound/cli/contracts/command.ts | 5 + .../cli/help/examples-are-runnable.test.ts | 75 ++++ .../cli/help/group-family-tags.test.ts | 107 ++++++ ts/src/adapters/inbound/cli/help/help.test.ts | 16 +- ts/src/adapters/inbound/cli/help/index.ts | 170 +++++++-- ts/src/adapters/inbound/cli/render/account.ts | 63 +--- ts/src/adapters/inbound/cli/render/chain.ts | 17 +- .../inbound/cli/render/family-render.test.ts | 112 ++++++ ts/src/adapters/inbound/cli/render/family.ts | 93 ++++- ts/src/adapters/inbound/cli/render/scalars.ts | 6 + ts/src/adapters/inbound/cli/render/tx.ts | 16 +- ts/src/adapters/inbound/cli/shell/index.ts | 9 +- .../inbound/cli/shell/shell.chain.test.ts | 90 +++++ .../chain/broadcast-guard-coverage.test.ts | 60 ++++ .../adapters/outbound/chain/evm/evm.test.ts | 114 +++++- ts/src/adapters/outbound/chain/evm/evm.ts | 76 +++- ts/src/adapters/outbound/chain/tron/tron.ts | 3 + .../ports/chain/gateway-provider.ts | 20 +- .../services/broadcast-guard.test.ts | 42 +++ .../application/services/broadcast-guard.ts | 37 ++ .../services/evm-gas-estimate.test.ts | 57 +++ .../application/services/evm-gas-estimate.ts | 38 ++ .../use-cases/evm/contract-service.test.ts | 35 +- .../use-cases/evm/contract-service.ts | 28 +- .../use-cases/evm/transaction-service.test.ts | 281 ++++++++++++++- .../use-cases/evm/transaction-service.ts | 148 +++++++- ts/src/bootstrap/families/evm.ts | 7 +- ts/src/bootstrap/families/tron.ts | 18 +- ts/src/bootstrap/migration-gate.test.ts | 145 +++++++- ts/src/bootstrap/migration-gate.ts | 59 ++- ts/src/bootstrap/migration-wiring.test.ts | 39 ++ ts/src/bootstrap/runner.ts | 42 ++- ts/src/domain/types/tx.ts | 2 + ts/test/golden.test.ts | 11 +- ts/test/unknown-command.test.ts | 133 +++++++ 51 files changed, 2777 insertions(+), 302 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts create mode 100644 ts/src/adapters/inbound/cli/help/examples-are-runnable.test.ts create mode 100644 ts/src/adapters/inbound/cli/help/group-family-tags.test.ts create mode 100644 ts/src/adapters/outbound/chain/broadcast-guard-coverage.test.ts create mode 100644 ts/src/application/services/broadcast-guard.test.ts create mode 100644 ts/src/application/services/broadcast-guard.ts create mode 100644 ts/src/application/services/evm-gas-estimate.test.ts create mode 100644 ts/src/application/services/evm-gas-estimate.ts create mode 100644 ts/test/unknown-command.test.ts diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts index a19f3df2f..1a777f645 100644 --- a/ts/src/adapters/inbound/cli/commands/account.ts +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -40,9 +40,9 @@ export const accountActivateSpec: ChainSpec = { auth: "conditional", broadcasts: true, capability: "account.activate", - summary: "Activate a new TRON account", + summary: "Activate an unactivated account", description: - "Create an AccountCreateContract funded by the active account. The target must not already be\n" + + "Create the account on chain, funded by the active account. The target must not already be\n" + "active; use --dry-run to inspect current creation fees. Note: a plain transfer also activates\n" + "the recipient, so use this command only when the address just needs to exist.", baseFields: z.object({ @@ -68,7 +68,7 @@ export const accountSetSpec: ChainSpec = { auth: "conditional", broadcasts: true, capability: "account.set", - summary: "Set the one-time on-chain account name or ID", + summary: "Set the on-chain account name / id", description: "Set exactly one immutable account field. Names are 1-32 UTF-8 bytes; IDs are unique and 8-32\n" + "UTF-8 bytes. Each can be set only once and can never be changed afterwards — rehearse with\n" + @@ -110,9 +110,12 @@ export const accountBalanceSpec: ChainSpec = { wallet: "optional", auth: "none", capability: "account.balance.native", - summary: "Show native balance (TRX/SUN)", + summary: "Show the native coin balance", baseFields: z.object({}), - examples: [{ cmd: "wallet-cli account balance" }], + examples: [ + { cmd: "wallet-cli account balance --network nile" }, + { cmd: "wallet-cli account balance --network sepolia" }, + ], formatText: TextFormatters.accountBalance, }; @@ -127,9 +130,12 @@ export const accountInfoSpec: ChainSpec = { network: "optional", wallet: "optional", auth: "none", - summary: "Show raw account data (getAccount; TRON includes resources)", + summary: "Show the account's on-chain state", baseFields: z.object({}), - examples: [{ cmd: "wallet-cli account info" }], + examples: [ + { cmd: "wallet-cli account info --network nile" }, + { cmd: "wallet-cli account info --network sepolia" }, + ], formatText: TextFormatters.accountInfo, }; @@ -150,7 +156,7 @@ export const accountHistorySpec: ChainSpec = { network: "optional", wallet: "optional", auth: "none", - summary: "Show transaction history (requires TronGrid)", + summary: "Show transaction history", baseFields: z.object({ limit: z.coerce .number() @@ -178,8 +184,15 @@ export const accountPortfolioSpec: ChainSpec = { auth: "none", capability: "account.portfolio", summary: "Show native + token balances with best-effort USD value", + description: + "Show the native coin balance plus every token in the address book for the selected\n" + + "network, with a best-effort USD value. A token whose balance cannot be read is listed\n" + + "as unavailable rather than dropped, and valuation is skipped where no price is known.", baseFields: z.object({}), - examples: [{ cmd: "wallet-cli account portfolio" }], + examples: [ + { cmd: "wallet-cli account portfolio --network nile" }, + { cmd: "wallet-cli account portfolio --network sepolia" }, + ], formatText: TextFormatters.accountPortfolio, }; diff --git a/ts/src/adapters/inbound/cli/commands/address.ts b/ts/src/adapters/inbound/cli/commands/address.ts index 22f21dab3..bd12e61b6 100644 --- a/ts/src/adapters/inbound/cli/commands/address.ts +++ b/ts/src/adapters/inbound/cli/commands/address.ts @@ -29,7 +29,8 @@ export function registerAddressCommands(registry: CommandRegistry, service: Addr 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.", + "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.\n" + + "The TRON and EVM addresses shown are two encodings of the same generated key.", fields, input: fields, examples: [ diff --git a/ts/src/adapters/inbound/cli/commands/block.ts b/ts/src/adapters/inbound/cli/commands/block.ts index 2874c48b8..84db291c4 100644 --- a/ts/src/adapters/inbound/cli/commands/block.ts +++ b/ts/src/adapters/inbound/cli/commands/block.ts @@ -12,12 +12,21 @@ export const blockSpec: ChainSpec = { auth: "none", positionals: [{ field: "number" }], summary: "Get a block (latest if omitted)", + description: + "Get a block, or the latest block when no height is given.\n" + + "JSON output is the node's own block object, so its shape differs by family: EVM reports\n" + + "hex quantities and second-precision timestamps, TRON decimal values and milliseconds.\n" + + "Text output is normalised across both.", baseFields: z.object({ number: Schemas.uintString() .optional() .describe("block number to fetch, in block height; omit to fetch the latest block"), }), - examples: [{ cmd: "wallet-cli block" }, { cmd: "wallet-cli block 12345" }], + examples: [ + { cmd: "wallet-cli block" }, + { cmd: "wallet-cli block 12345 --network nile" }, + { cmd: "wallet-cli block 12345 --network sepolia" }, + ], formatText: TextFormatters.block, }; diff --git a/ts/src/adapters/inbound/cli/commands/chain.ts b/ts/src/adapters/inbound/cli/commands/chain.ts index 93a52c28d..ae8c0fa71 100644 --- a/ts/src/adapters/inbound/cli/commands/chain.ts +++ b/ts/src/adapters/inbound/cli/commands/chain.ts @@ -12,13 +12,16 @@ export const chainPricesSpec: ChainSpec = { network: "optional", wallet: "none", auth: "none", - summary: "Transaction pricing for the selected network", + summary: "Current transaction unit prices", description: "Show what a transaction costs to send on this network. The fields are family-shaped:\n" + "TRON reports energy/bandwidth unit prices (in SUN; 1 TRX = 1,000,000 SUN) and the memo\n" + "fee. An EVM chain reports its fee model plus base/priority/gas price (in wei).", baseFields: z.object({}), - examples: [{ cmd: "wallet-cli chain prices" }], + examples: [ + { cmd: "wallet-cli chain prices --network nile" }, + { cmd: "wallet-cli chain prices --network sepolia" }, + ], formatText: TextFormatters.chainPrices, }; @@ -35,13 +38,16 @@ export const chainNodeSpec: ChainSpec = { network: "optional", wallet: "none", auth: "none", - summary: "Connected node status (version / sync / peers)", + summary: "Connected node status", description: "Show the connected node's status: version, head/solid block height, sync state,\n" + 'and peer connections. Useful to tell "node out of sync" from "problem with my\n' + 'transaction". Fields the endpoint does not expose are shown as "—" (null in json).', baseFields: z.object({}), - examples: [{ cmd: "wallet-cli chain node" }], + examples: [ + { cmd: "wallet-cli chain node --network nile" }, + { cmd: "wallet-cli chain node --network sepolia" }, + ], formatText: TextFormatters.chainNode, }; diff --git a/ts/src/adapters/inbound/cli/commands/contact.ts b/ts/src/adapters/inbound/cli/commands/contact.ts index 165be5bf2..f892f6b61 100644 --- a/ts/src/adapters/inbound/cli/commands/contact.ts +++ b/ts/src/adapters/inbound/cli/commands/contact.ts @@ -20,7 +20,7 @@ export function registerContactCommands(registry: CommandRegistry, service: Cont wallet: "none", auth: "none", positionals: [{ field: "name" }, { field: "address" }], - summary: "Add a recipient", + summary: "Add a payee to the address book", description: "Add a locally stored recipient. The address is validated against the family it belongs to (T… = TRON, 0x… = EVM), and the name can then be used anywhere an address is accepted.", fields: addFields, @@ -40,7 +40,7 @@ export function registerContactCommands(registry: CommandRegistry, service: Cont network: "none", wallet: "none", auth: "none", - summary: "List recipients", + summary: "List every contact", description: "List every recipient in the local plaintext address book.", fields: empty, input: empty, @@ -58,7 +58,7 @@ export function registerContactCommands(registry: CommandRegistry, service: Cont wallet: "none", auth: "none", positionals: [{ field: "name" }], - summary: "Remove a recipient", + summary: "Remove a contact", description: "Remove one recipient from the local address book without changing any on-chain state.", fields: removeFields, diff --git a/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts b/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts new file mode 100644 index 000000000..98591a38e --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it, vi } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + contractDeployEvmBinding, + contractDeploySpec, + contractDeployTronBinding, +} from "./contract.js"; +import type { EvmContractService } from "../../../../application/use-cases/evm/contract-service.js"; +import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; + +/** + * `--artifact`, `--constructor-signature` and `--constructor-args`. + * + * The defect these replace: `--constructor-params` was the only way to pass constructor arguments + * on EVM, and it could not work — the encoder was handed an empty ABI, so every argument failed + * with "expectedCount=0", and `--abi` (which would have supplied one) is a TRON-only flag. There + * was no input that deployed a contract with constructor arguments on an EVM network. + * + * The rule that came out of it: the constructor's TYPES come from the compiler's ABI, or from a + * signature the caller states — never from the values. A mistyped argument encodes cleanly and + * deploys a contract built from the wrong arguments, and a deployment cannot be taken back. + */ + +const CONSTRUCTOR_ABI = [ + { + type: "constructor", + stateMutability: "nonpayable", + inputs: [ + { name: "value", type: "uint256" }, + { name: "label", type: "string" }, + ], + }, +]; + +function artifactFile(body: unknown, name = "Counter.json"): string { + const dir = mkdtempSync(join(tmpdir(), "wallet-cli-artifact-")); + const path = join(dir, name); + writeFileSync(path, typeof body === "string" ? body : JSON.stringify(body)); + return path; +} + +/** Foundry nests the bytecode under `{object}`; Hardhat, sunhat and TronBox store the string. */ +const foundryArtifact = () => artifactFile({ abi: CONSTRUCTOR_ABI, bytecode: { object: "0x6080" } }); +const hardhatArtifact = () => + artifactFile({ contractName: "Counter", abi: CONSTRUCTOR_ABI, bytecode: "0x6080" }); + +function evmHarness() { + const deploy = vi.fn(async (_c: unknown, _n: unknown, _i: any) => ({ + kind: "contract-deploy" as const, + })); + const binding = contractDeployEvmBinding({ deploy } as unknown as EvmContractService); + const run = (input: Record) => + binding.run({} as never, {} as never, input as never); + return { run, deploy }; +} + +function tronHarness() { + const deploy = vi.fn(async (_c: unknown, _n: unknown, _i: Record) => ({ + kind: "contract-deploy" as const, + })); + const binding = contractDeployTronBinding({ deploy } as unknown as TronContractService); + const run = (input: Record) => + binding.run({} as never, {} as never, { feeLimit: "1000000", ...input } as never); + return { run, deploy }; +} + +describe("contract deploy — reading a compiler artifact", () => { + it("takes the bytecode and the ABI from a Foundry artifact", async () => { + const { run, deploy } = evmHarness(); + await run({ artifact: foundryArtifact(), constructorArgs: '["42","hello"]' }); + + expect(deploy.mock.calls[0]![2]).toMatchObject({ + bytecode: "0x6080", + constructorArgs: { source: "abi", abi: CONSTRUCTOR_ABI, values: ["42", "hello"] }, + }); + }); + + it("takes them from a Hardhat / sunhat / TronBox artifact too", async () => { + const { run, deploy } = evmHarness(); + await run({ artifact: hardhatArtifact(), constructorArgs: '["42","hello"]' }); + + expect(deploy.mock.calls[0]![2]).toMatchObject({ bytecode: "0x6080" }); + }); + + it("reports a missing artifact as a missing file, not as bad JSON", async () => { + const { run } = evmHarness(); + + await expect(run({ artifact: "/nope/Counter.json" })).rejects.toMatchObject({ + code: "file_not_found", + }); + }); + + it("reports an artifact that is not JSON", async () => { + const { run } = evmHarness(); + + await expect(run({ artifact: artifactFile("{not json") })).rejects.toMatchObject({ + code: "invalid_value", + message: /not valid JSON/, + }); + }); + + it("names the fields it looked at when there is no bytecode", async () => { + const { run } = evmHarness(); + + await expect(run({ artifact: artifactFile({ abi: [] }) })).rejects.toMatchObject({ + code: "invalid_value", + message: /bytecode\.object/, + }); + }); + + // solc emits "0x" for an interface or an abstract contract: a real artifact for something that + // cannot be deployed. Deploying it would succeed and produce a contract with no code. + it("refuses an interface or abstract contract instead of deploying nothing", async () => { + const { run } = evmHarness(); + + await expect( + run({ artifact: artifactFile({ abi: [], bytecode: "0x" }) }), + ).rejects.toMatchObject({ code: "invalid_value", message: /abstract|interface/ }); + }); +}); + +describe("contract deploy — where the constructor's types come from", () => { + it("uses the artifact's ABI when there is one", async () => { + const { run, deploy } = evmHarness(); + await run({ artifact: foundryArtifact(), constructorArgs: '["42","hello"]' }); + + expect(deploy.mock.calls[0]![2].constructorArgs.source).toBe("abi"); + }); + + it("uses --constructor-signature when there is no ABI", async () => { + const { run, deploy } = evmHarness(); + await run({ + code: "6080", + constructorSignature: "constructor(uint256,string)", + constructorArgs: '["42","hello"]', + }); + + expect(deploy.mock.calls[0]![2].constructorArgs).toEqual({ + source: "signature", + signature: "constructor(uint256,string)", + values: ["42", "hello"], + flag: "--constructor-signature", + }); + }); + + // The shape this command shipped with. It still works — the types are simply read off the + // entries and turned into the signature they describe, instead of being discarded. + it("builds the signature from --constructor-params' inline types", async () => { + const { run, deploy } = evmHarness(); + await run({ + code: "6080", + constructorParams: '[{"type":"uint256","value":"42"},{"type":"string","value":"hello"}]', + }); + + expect(deploy.mock.calls[0]![2].constructorArgs).toEqual({ + source: "signature", + signature: "constructor(uint256,string)", + values: ["42", "hello"], + flag: "--constructor-params", + }); + }); + + it("passes no arguments at all when none were given", async () => { + const { run, deploy } = evmHarness(); + await run({ code: "6080" }); + + expect(deploy.mock.calls[0]![2].constructorArgs).toEqual({ source: "none" }); + }); +}); + +/** + * Schema rules are asserted against the schema: calling a binding directly bypasses zod, so a + * refine could say anything and the call would still succeed. + */ +describe("contract deploy — input rules", () => { + const parse = (input: Record) => + contractDeploySpec.baseFields + .superRefine(contractDeploySpec.baseRefine!) + .safeParse({ dryRun: false, signOnly: false, buildOnly: false, ...input }); + + const message = (input: Record) => + parse(input).error?.issues.map((i) => i.message).join(" | ") ?? ""; + + it("accepts --artifact as a bytecode source", () => { + expect(parse({ artifact: "./out/Counter.sol/Counter.json" }).success).toBe(true); + }); + + it("refuses --artifact together with --code or --code-file", () => { + expect(parse({ artifact: "./a.json", code: "6080" }).success).toBe(false); + expect(parse({ artifact: "./a.json", codeFile: "./a.bin" }).success).toBe(false); + }); + + it("refuses two argument lists at once", () => { + expect(message({ code: "6080", constructorArgs: "[]", constructorParams: "[]" })).toMatch( + /mutually exclusive/, + ); + }); + + // Both of these would mean encoding against one type source while the caller supplied two. + it("refuses inline types beside an artifact rather than picking one", () => { + expect(message({ artifact: "./a.json", constructorParams: "[]" })).toMatch( + /types come from its ABI/, + ); + }); + + it("refuses a signature beside an artifact", () => { + expect(message({ artifact: "./a.json", constructorSignature: "constructor()" })).toMatch( + /not needed with --artifact/, + ); + }); + + it("refuses bare values with no type source, and says which flags supply one", () => { + expect(message({ code: "6080", constructorArgs: '["42"]' })).toMatch( + /--artifact.*--constructor-signature/, + ); + }); + + // --abi is TRON-only and IS the type source there. Leaving it out of this rule sent a TRON + // caller to --constructor-signature, the one flag TRON refuses. + it("counts --abi as a type source, since TRON encodes bare values against it", () => { + // The shell parses baseFields EXTENDED with the family's own fields, so the refine sees --abi + // on TRON. Parsing the base fields alone would strip it and prove nothing. + const tronParse = contractDeploySpec.baseFields + .extend(contractDeployTronBinding({} as never).fields!.shape) + .superRefine(contractDeploySpec.baseRefine!) + .safeParse({ + dryRun: false, + signOnly: false, + buildOnly: false, + code: "6080", + abi: "[]", + constructorArgs: '["42"]', + }); + + expect(tronParse.success).toBe(true); + expect(message({ code: "6080", constructorArgs: '["42"]' })).toMatch(/--abi also declares/); + }); + + it("accepts bare values once a type source is present", () => { + expect( + parse({ code: "6080", constructorSignature: "constructor(uint256)", constructorArgs: '["42"]' }) + .success, + ).toBe(true); + expect(parse({ artifact: "./a.json", constructorArgs: '["42"]' }).success).toBe(true); + }); +}); + +/** + * TRON is the one place the families genuinely differ: TronWeb's createSmartContract needs the + * whole ABI, not just the constructor's types, so a signature cannot stand in for it. `--abi` + * therefore stays required — but an artifact now satisfies it, which is the point: a TRON + * developer using TronBox or sunhat already has that ABI in a file. + */ +describe("contract deploy — TRON's ABI requirement", () => { + const tronRefine = contractDeployTronBinding({} as never).refine!; + const check = (input: Record) => { + const issues: { message: string }[] = []; + tronRefine(input, { addIssue: (i: { message: string }) => issues.push(i) } as never); + return issues.map((i) => i.message).join(" | "); + }; + + it("still demands an ABI when neither flag supplies one", () => { + expect(check({ code: "6080" })).toMatch(/TRON needs the contract's ABI/); + }); + + it("is satisfied by --artifact", () => { + expect(check({ artifact: "./build/contracts/Counter.json" })).toBe(""); + }); + + it("is satisfied by --abi", () => { + expect(check({ abi: "[]" })).toBe(""); + }); + + it("refuses both at once rather than choosing", () => { + expect(check({ abi: "[]", artifact: "./a.json" })).toMatch(/pass one/); + }); + + it("says plainly that a signature cannot replace the ABI here", () => { + expect(check({ abi: "[]", constructorSignature: "constructor(uint256)" })).toMatch( + /full ABI/, + ); + }); + + it("takes the ABI out of the artifact and passes bare values to TronWeb", async () => { + const { run, deploy } = tronHarness(); + await run({ artifact: hardhatArtifact(), constructorArgs: '["42","hello"]' }); + + expect(deploy.mock.calls[0]![2]).toMatchObject({ + abi: CONSTRUCTOR_ABI, + bytecode: "0x6080", + parameters: ["42", "hello"], + }); + }); + + it("keeps working with --abi and --constructor-params, the shape it shipped with", async () => { + const { run, deploy } = tronHarness(); + await run({ + code: "6080", + abi: JSON.stringify(CONSTRUCTOR_ABI), + constructorParams: '[{"type":"uint256","value":"42"},{"type":"string","value":"hello"}]', + }); + + expect(deploy.mock.calls[0]![2]).toMatchObject({ parameters: ["42", "hello"] }); + }); +}); + +/** + * `--permission-id` and `--expiration` are TRON's multi-signature concepts. They sat in the + * shared base fields, so an EVM `--help` listed them untagged beside the flags that are tagged + * `(tron)` — a reader had no way to tell they do nothing here. + */ +describe("contract deploy — TRON-only transaction flags are tagged", () => { + it("keeps them off the EVM binding", () => { + const keys = Object.keys(contractDeployEvmBinding({} as never).fields?.shape ?? {}); + expect(keys).not.toContain("permissionId"); + expect(keys).not.toContain("expiration"); + }); + + it("keeps them out of the family-neutral base fields", () => { + const keys = Object.keys(contractDeploySpec.baseFields.shape); + expect(keys).not.toContain("permissionId"); + expect(keys).not.toContain("expiration"); + }); + + it("offers them on the TRON binding", () => { + const keys = Object.keys(contractDeployTronBinding({} as never).fields?.shape ?? {}); + expect(keys).toEqual(expect.arrayContaining(["permissionId", "expiration"])); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index 60be2c5c4..d289484cd 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -3,11 +3,12 @@ import { readFile } from "node:fs/promises"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import { UsageError } from "../../../../domain/errors/index.js"; import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; +import type { DeployConstructorArgs } from "../../../../application/ports/chain/gateway-provider.js"; import type { EvmContractService } from "../../../../application/use-cases/evm/contract-service.js"; import type { TronContractParameter } from "../../../../application/ports/chain/tron-gateway.js"; import { Schemas, addressFieldsFor, allRefines } from "../schemas/index.js"; import { gweiToWei } from "../../../../domain/fees/evm-gas.js"; -import { governanceTxModeFields, governanceTxRefine } from "./shared.js"; +import { governanceTxModeFields, governanceTxRefine, tronTxModeFields, txModeFields } from "./shared.js"; import { TextFormatters } from "../render/index.js"; function jsonArray(raw: string | undefined, flag = "--params"): unknown[] { @@ -122,11 +123,14 @@ export const contractCallSpec: ChainSpec = { wallet: "none", auth: "none", capability: "contract.call", - summary: "Read-only call (triggerConstantContract)", + summary: "Read-only contract call", baseFields: callFields, examples: [ { - cmd: `wallet-cli contract call --contract TR7... --method "balanceOf(address)" --params '[{"type":"address","value":"T..."}]'`, + cmd: `wallet-cli contract call --contract TR7... --method "balanceOf(address)" --params '[{"type":"address","value":"T..."}]' --network nile`, + }, + { + cmd: `wallet-cli contract call --contract 0xA0b8... --method "balanceOf(address)" --params '[{"type":"address","value":"0x742d..."}]' --network sepolia`, }, ], formatText: TextFormatters.contractCall, @@ -151,7 +155,13 @@ const sendFields = z.object({ .string() .optional() .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), - ...governanceTxModeFields, + ...txModeFields, + buildOnly: z + .boolean() + .default(false) + .describe( + "build an unsigned transaction without signing or broadcasting; mutually exclusive with --dry-run/--sign-only", + ), }); /** TRON prices a contract call in SUN and burns energy up to a fee limit; both flag names say so. */ @@ -162,6 +172,7 @@ const tronContractWriteFields = z.object({ feeLimit: Schemas.positiveIntString() .default("100000000") .describe("maximum energy fee to burn, in SUN"), + ...tronTxModeFields, }); /** EVM prices it in gas. `--call-value` is in whole coins, matching `tx send --amount`; the @@ -208,12 +219,18 @@ export const contractSendSpec: ChainSpec = { auth: "conditional", broadcasts: true, capability: "contract.call", - summary: "State-changing call (triggerSmartContract)", + summary: "State-changing contract call", + description: + "Call a contract method that changes state, signing and broadcasting the transaction.\n" + + "Flags marked (tron) or (evm) apply only on networks of that family; using one on the other family is rejected.", baseFields: sendFields, baseRefine: governanceTxRefine, examples: [ { - cmd: `wallet-cli contract send --contract TR7... --method "transfer(address,uint256)" --params '[...]'`, + cmd: `wallet-cli contract send --contract TR7... --method "transfer(address,uint256)" --params '[...]' --network nile`, + }, + { + cmd: `wallet-cli contract send --contract 0xA0b8... --method "transfer(address,uint256)" --params '[...]' --network sepolia`, }, ], formatText: TextFormatters.txReceipt, @@ -238,15 +255,119 @@ async function creationBytecode(input: { code?: string; codeFile?: string }): Pr } } +interface DeploySource { + bytecode: string; + /** present only when the source was an artifact; it is the compiler's own ABI. */ + abi?: unknown; +} + +/** + * A compiler artifact — the bytecode and the ABI, from the file the compiler already wrote. + * + * Every toolchain in both families emits the same two fields: Foundry (`out/X.sol/X.json`), + * Hardhat and its TRON plugin sunhat (`artifacts/…/X.json`), and TronBox + * (`build/contracts/X.json`). Only the bytecode's shape differs — Foundry nests it under + * `{object}`, the others store the string directly — so both are accepted. + * + * This matters most on TRON, where `--abi` is required: without it the caller has to open the + * artifact and paste a multi-kilobyte ABI onto the command line, which is transcription, not + * input. It also removes the one way a correct deployment can still go wrong — types typed by + * hand — because the ABI comes from the compiler that produced the bytecode. + */ +async function readArtifact(path: string): Promise { + let text: string; + try { + text = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new UsageError("file_not_found", `artifact not found: ${path}`); + } + throw new UsageError("invalid_value", `cannot read artifact: ${path}`); + } + let artifact: Record; + try { + artifact = JSON.parse(text); + } catch { + throw new UsageError("invalid_value", `artifact is not valid JSON: ${path}`); + } + const bytecode = + artifact?.bytecode?.object ?? artifact?.bytecode ?? artifact?.evm?.bytecode?.object; + if (typeof bytecode !== "string") { + throw new UsageError( + "invalid_value", + `artifact has no creation bytecode: ${path} (looked at .bytecode.object, .bytecode and .evm.bytecode.object)`, + ); + } + // solc emits "0x" for an interface or an abstract contract: a real artifact for something that + // cannot be deployed. Saying so beats letting an empty deployment reach the chain. + if (bytecode.replace(/^0x/, "") === "") { + throw new UsageError( + "invalid_value", + `artifact holds no deployable bytecode: ${path} — an interface or abstract contract cannot be deployed`, + ); + } + return { bytecode, ...(artifact.abi === undefined ? {} : { abi: artifact.abi }) }; +} + +/** the bytecode, and the ABI when the caller pointed at an artifact. */ +async function deploySource(input: { + code?: string; + codeFile?: string; + artifact?: string; +}): Promise { + if (input.artifact) return readArtifact(input.artifact); + return { bytecode: await creationBytecode(input) }; +} + +interface DeployArgInput { + artifact?: string; + constructorSignature?: string; + constructorArgs?: string; + constructorParams?: string; +} + +/** bare constructor values, from `--constructor-args` or unwrapped from `--constructor-params`. */ +function constructorValues(input: DeployArgInput): unknown[] { + if (input.constructorArgs !== undefined) return jsonArray(input.constructorArgs, "--constructor-args"); + return typedConstructorParams(input.constructorParams).map((entry) => entry.value); +} + +/** + * Where the constructor's TYPES come from, in order of authority: the compiler's ABI, then a + * signature the caller stated, then — only because it is the shape this command shipped with — + * the types inlined beside each value. + */ +function deployConstructorArgs(input: DeployArgInput, abi: unknown): DeployConstructorArgs { + const values = constructorValues(input); + if (abi !== undefined) return { source: "abi", abi, values }; + if (input.constructorSignature !== undefined) { + return { + source: "signature", + signature: input.constructorSignature, + values, + flag: "--constructor-signature", + }; + } + if (input.constructorParams === undefined) return { source: "none" }; + const types = typedConstructorParams(input.constructorParams).map((entry) => entry.type); + return { + source: "signature", + signature: `constructor(${types.join(",")})`, + values, + flag: "--constructor-params", + }; +} + export const contractDeployEvmBinding = (svc: EvmContractService): FamilyBinding => ({ fields: evmGasFields, - run: async (ctx, net, input) => - svc.deploy(ctx, net, { + run: async (ctx, net, input) => { + const source = await deploySource(input); + return svc.deploy(ctx, net, { ...withEvmFees(input), - bytecode: await creationBytecode(input), - // ethers encodes straight from the inline types; no ABI is involved. - params: typedConstructorParams(input.constructorParams), - }), + bytecode: source.bytecode, + constructorArgs: deployConstructorArgs(input, source.abi), + }); + }, }); export const contractSendTronBinding = (svc: TronContractService): FamilyBinding => ({ @@ -260,43 +381,142 @@ export const contractSendTronBinding = (svc: TronContractService): FamilyBinding }); const deployFields = z.object({ + artifact: z + .string() + .min(1) + .optional() + .describe( + "path to a compiler artifact (Foundry, Hardhat/sunhat, TronBox) holding both the bytecode and the ABI; the preferred source, because the constructor's types then come from the compiler", + ), code: z .string() .min(1) .optional() - .describe("contract creation bytecode, hex-encoded; provide exactly one of --code or --code-file"), + .describe("contract creation bytecode, hex-encoded; provide exactly one of --artifact, --code or --code-file"), codeFile: z .string() .min(1) .optional() .describe("path to a file holding the creation bytecode; bytecode often exceeds the shell's argument limit"), + constructorSignature: z + .string() + .min(1) + .optional() + .describe( + 'the constructor\'s types when there is no ABI, e.g. "constructor(uint256,string)"; not needed with --artifact, and not accepted on TRON, which needs the full ABI', + ), + constructorArgs: z + .string() + .optional() + .describe( + 'constructor arguments as a JSON array of bare values, e.g. ["18","MyToken"]; the types come from --artifact, --constructor-signature, or --abi on TRON', + ), constructorParams: z .string() .optional() .describe( - 'constructor arguments as a JSON array of {type,value} entries, e.g. [{"type":"uint8","value":"18"}]; omit to pass none', + 'constructor arguments as a JSON array of {type,value} entries, e.g. [{"type":"uint8","value":"18"}]; prefer --constructor-args with --artifact', + ), + ...txModeFields, + buildOnly: z + .boolean() + .default(false) + .describe( + "build an unsigned transaction without signing or broadcasting; mutually exclusive with --dry-run/--sign-only", ), - ...governanceTxModeFields, }); /** the spec's two base rules: the shared governance modes, plus exactly one bytecode source. * Written out rather than composed generically because the two refines read different field * sets, and a generic combinator would have to erase one of their types to fit them together. */ function deployRefine( - value: { code?: string; codeFile?: string; expiration?: number; buildOnly?: boolean }, + value: { + artifact?: string; + code?: string; + codeFile?: string; + constructorSignature?: string; + constructorArgs?: string; + constructorParams?: string; + expiration?: number; + buildOnly?: boolean; + }, ctx: z.RefinementCtx, ): void { governanceTxRefine(value as never, ctx); codeSourceRefine(value, ctx); + constructorArgsRefine(value, ctx); +} + +/** + * The constructor's arguments must have exactly one form, and their types exactly one source. + * + * Both rules exist because the alternative is silence: two argument lists means one is ignored, + * and an ABI beside hand-written types means one of the two is not being used to encode. A + * deployment cannot be undone, so neither is left to a precedence rule the caller cannot see. + */ +function constructorArgsRefine( + value: { + artifact?: string; + abi?: string; + constructorSignature?: string; + constructorArgs?: string; + constructorParams?: string; + }, + ctx: z.RefinementCtx, +): void { + if (value.constructorArgs !== undefined && value.constructorParams !== undefined) { + ctx.addIssue({ + code: "custom", + path: ["constructorArgs"], + message: "--constructor-args and --constructor-params are mutually exclusive", + }); + } + if (value.constructorParams !== undefined && value.artifact !== undefined) { + ctx.addIssue({ + code: "custom", + path: ["constructorParams"], + message: + "with --artifact the types come from its ABI; pass the values with --constructor-args", + }); + } + if (value.constructorSignature !== undefined && value.artifact !== undefined) { + ctx.addIssue({ + code: "custom", + path: ["constructorSignature"], + message: "--constructor-signature is not needed with --artifact; its ABI declares the types", + }); + } + // `--abi` counts here: it is TRON-only, and on TRON it is the type source — naming only the + // family-neutral flags would send a TRON caller to --constructor-signature, which TRON refuses. + if ( + value.constructorArgs !== undefined && + value.artifact === undefined && + value.constructorSignature === undefined && + value.abi === undefined + ) { + ctx.addIssue({ + code: "custom", + path: ["constructorArgs"], + message: + "--constructor-args needs the constructor's types: pass --artifact, or state them with --constructor-signature (--abi also declares them on TRON)", + }); + } } /** exactly one bytecode source, matching the rule `contract create2` already applies. */ -function codeSourceRefine(value: { code?: string; codeFile?: string }, ctx: z.RefinementCtx): void { - if ([value.code !== undefined, value.codeFile !== undefined].filter(Boolean).length !== 1) { +function codeSourceRefine( + value: { code?: string; codeFile?: string; artifact?: string }, + ctx: z.RefinementCtx, +): void { + if ( + [value.code !== undefined, value.codeFile !== undefined, value.artifact !== undefined].filter( + Boolean, + ).length !== 1 + ) { ctx.addIssue({ code: "custom", path: ["code"], - message: "provide exactly one of --code or --code-file", + message: "provide exactly one of --artifact, --code or --code-file", }); } } @@ -311,12 +531,48 @@ function codeSourceRefine(value: { code?: string; codeFile?: string }, ctx: z.Re * contract built from the wrong arguments. ethers needs no ABI, which is why this is `(tron)`. */ const tronDeployFields = z.object({ - abi: z.string().min(1).describe("contract ABI as a JSON array string"), + abi: z + .string() + .min(1) + .optional() + .describe("contract ABI as a JSON array string; required unless --artifact supplies one"), feeLimit: Schemas.positiveIntString() .default("100000000") .describe("maximum energy fee to burn, in SUN"), + ...tronTxModeFields, }); +/** TronWeb needs the whole ABI, not just the constructor's types, so a signature cannot stand in + * for it — the one place where the two families genuinely need different inputs. */ +function tronDeployRefine( + value: { abi?: string; artifact?: string; constructorSignature?: string }, + ctx: z.RefinementCtx, +): void { + if (value.abi === undefined && value.artifact === undefined) { + ctx.addIssue({ + code: "custom", + path: ["abi"], + message: + "TRON needs the contract's ABI to encode a deployment: pass --artifact, or --abi with the JSON", + }); + } + if (value.abi !== undefined && value.artifact !== undefined) { + ctx.addIssue({ + code: "custom", + path: ["abi"], + message: "--abi and --artifact both supply the ABI; pass one", + }); + } + if (value.constructorSignature !== undefined) { + ctx.addIssue({ + code: "custom", + path: ["constructorSignature"], + message: + "--constructor-signature has no effect on TRON: the node needs the full ABI, so pass --artifact or --abi", + }); + } +} + export const contractDeploySpec: ChainSpec = { path: ["contract", "deploy"], network: "optional", @@ -324,17 +580,25 @@ export const contractDeploySpec: ChainSpec = { auth: "conditional", broadcasts: true, capability: "contract.deploy", - summary: "Deploy a smart contract", + summary: "Deploy contract bytecode", + 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 — the Ledger TRON app cannot sign this transaction type", + "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: [ { - cmd: "wallet-cli contract deploy --abi '[...]' --bytecode 60... --fee-limit 1000000000 --params '[100, \"T...\"]'", + cmd: "wallet-cli contract deploy --artifact ./build/contracts/Token.json --constructor-args '[\"18\",\"MyToken\"]' --network nile", + }, + { + cmd: "wallet-cli contract deploy --artifact ./out/Token.sol/Token.json --constructor-args '[\"18\",\"MyToken\"]' --network sepolia", + }, + { + cmd: "wallet-cli contract deploy --code-file ./Token.bin --constructor-signature 'constructor(uint8,string)' --constructor-args '[\"18\",\"MyToken\"]' --network sepolia", }, ], formatText: TextFormatters.txReceipt, @@ -342,21 +606,25 @@ export const contractDeploySpec: ChainSpec = { export const contractDeployTronBinding = (svc: TronContractService): FamilyBinding => ({ fields: tronDeployFields, + refine: tronDeployRefine, run: async (ctx, net, input) => { - let abi: unknown; - try { - abi = JSON.parse(input.abi); - } catch { - throw new UsageError("invalid_value", "--abi must be valid JSON"); + const source = await deploySource(input); + let abi = source.abi; + if (abi === undefined) { + try { + abi = JSON.parse(input.abi); + } catch { + throw new UsageError("invalid_value", "--abi must be valid JSON"); + } } assertConstructorEncodable(abi); return svc.deploy(ctx, net, { ...input, abi, - bytecode: await creationBytecode(input), - // TronWeb takes bare values beside the ABI, so the typed entries are unwrapped here. The - // TYPES still come from the ABI — the inline ones only decide what the caller meant. - parameters: typedConstructorParams(input.constructorParams).map((entry) => entry.value), + bytecode: source.bytecode, + // TronWeb takes bare values beside the ABI, so only the values travel. The TYPES come from + // the ABI in every case — which is why --artifact is the better way in. + parameters: constructorValues(input), }); }, }); @@ -399,7 +667,7 @@ export const contractClearAbiSpec: ChainSpec = { path: ["contract", "clear-abi"], ...contractGovernanceBase, positionals: [{ field: "address" }], - summary: "Irreversibly clear a contract's on-chain ABI", + summary: "Clear a contract's on-chain ABI", description: "Clear the ABI metadata stored on-chain. This is irreversible, but does not change the\n" + "contract bytecode or state. Only the contract deployer may perform the operation.", @@ -416,7 +684,7 @@ export const contractSetOriginEnergyLimitSpec: ChainSpec = { path: ["contract", "set-origin-energy-limit"], ...contractGovernanceBase, positionals: [{ field: "address" }, { field: "energy" }], - summary: "Set the deployer's per-call energy contribution cap", + summary: "Set the deployer's energy cap", description: "Set origin_energy_limit, the maximum energy the deployer covers per call. The actual\n" + "contribution is also limited by the deployer's available staked energy.", @@ -444,7 +712,7 @@ export const contractSetUserResourcePercentSpec: ChainSpec = { path: ["contract", "set-user-resource-percent"], ...contractGovernanceBase, positionals: [{ field: "address" }, { field: "percent" }], - summary: "Set the caller-paid energy percentage", + summary: "Set the caller-paid resource share", description: "Set consume_user_resource_percent. 100 means the caller pays all energy; 0 means the\n" + "deployer pays, subject to origin_energy_limit and available staked energy.", @@ -480,7 +748,7 @@ export const contractCreate2Spec: ChainSpec = { wallet: "none", auth: "none", capability: "contract.create2", - summary: "Compute a TVM CREATE2 contract address locally", + summary: "Precompute a CREATE2 address", description: "Compute the TRON CREATE2 address locally without contacting a node. code must be creation\n" + "bytecode with constructor arguments appended; salt is a signed decimal 64-bit integer.", diff --git a/ts/src/adapters/inbound/cli/commands/encoding.ts b/ts/src/adapters/inbound/cli/commands/encoding.ts index 4a020faca..d5d1d654b 100644 --- a/ts/src/adapters/inbound/cli/commands/encoding.ts +++ b/ts/src/adapters/inbound/cli/commands/encoding.ts @@ -25,7 +25,8 @@ export function registerEncodingCommands( 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.", + "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.\n" + + "The two address forms are encodings of one 20-byte key hash, not two derived accounts.", fields, input: fields, examples: [ diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts index bd856260b..98087ea96 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -132,7 +132,10 @@ export const messageSignSpec: ChainSpec = { exclusive: [ { label: "the message to sign", flags: ["message", "message-stdin"], select: "exactly-one" }, ], - examples: [{ cmd: `wallet-cli message sign --message "hello"` }], + examples: [ + { cmd: `wallet-cli message sign --message "hello" --network nile` }, + { cmd: `wallet-cli message sign --message "hello" --network sepolia` }, + ], formatText: TextFormatters.messageSign, }; diff --git a/ts/src/adapters/inbound/cli/commands/stake.ts b/ts/src/adapters/inbound/cli/commands/stake.ts index e7d3cb5a9..7448af508 100644 --- a/ts/src/adapters/inbound/cli/commands/stake.ts +++ b/ts/src/adapters/inbound/cli/commands/stake.ts @@ -56,7 +56,7 @@ export function stakeDefinitions( return [ stakeCommand( "freeze", - "Stake TRX for energy/bandwidth (FreezeBalanceV2)", + "Stake TRX for energy/bandwidth", (context, network, input) => service.freeze(context, network, input), { amountSun: Schemas.positiveIntString().describe("amount to freeze as staked TRX, in SUN"), @@ -65,7 +65,7 @@ export function stakeDefinitions( ), stakeCommand( "unfreeze", - "Unstake TRX (UnfreezeBalanceV2)", + "Unstake TRX", (context, network, input) => service.unfreeze(context, network, input), { amountSun: Schemas.positiveIntString().describe("amount to unfreeze as staked TRX, in SUN"), @@ -74,7 +74,7 @@ export function stakeDefinitions( ), stakeCommand( "withdraw", - "Withdraw expired unfrozen TRX (WithdrawExpireUnfreeze)", + "Withdraw expired unfrozen TRX", (context, network, input) => service.withdraw(context, network, input), ), stakeCommand( @@ -92,7 +92,7 @@ export function stakeDefinitions( ), stakeCommand( "delegate", - "Delegate resource to another address (DelegateResourceV2)", + "Delegate resource to another address", (context, network, input) => service.delegate(context, network, input), { amountSun: Schemas.positiveIntString().describe( @@ -125,7 +125,7 @@ export function stakeDefinitions( ), stakeCommand( "undelegate", - "Reclaim delegated resource (UnDelegateResourceV2)", + "Reclaim delegated resource", (context, network, input) => service.undelegate(context, network, input), { amountSun: Schemas.positiveIntString().describe( 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 36c9ef17b..d69d3940d 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -173,11 +173,14 @@ describe("stake/chain TRX amount formatting", () => { }, ctx(), ); - const chain = TextFormatters.chainPrices({ - energy: { currentSunPerUnit: 210 }, - bandwidth: { currentSunPerUnit: 1000 }, - memoFeeSun: "1234456789", - }); + const chain = TextFormatters.chainPrices( + { + energy: { currentSunPerUnit: 210 }, + bandwidth: { currentSunPerUnit: 1000 }, + memoFeeSun: "1234456789", + }, + ctx(), + ); expect(stake).toContain("1,234.456789 TRX"); expect(chain).toContain("1,234.456789 TRX"); }); diff --git a/ts/src/adapters/inbound/cli/commands/token.ts b/ts/src/adapters/inbound/cli/commands/token.ts index 1fb2c5614..f2a05a1c7 100644 --- a/ts/src/adapters/inbound/cli/commands/token.ts +++ b/ts/src/adapters/inbound/cli/commands/token.ts @@ -45,9 +45,12 @@ export const tokenBalanceSpec: ChainSpec = { wallet: "optional", auth: "none", capability: "account.balance.token", - summary: "Show a single token balance (--contract / --asset-id)", + summary: "Show a single token balance", baseFields: selectorFields, - examples: [{ cmd: "wallet-cli token balance --contract TR7..." }], + examples: [ + { cmd: "wallet-cli token balance --contract TR7... --network nile" }, + { cmd: "wallet-cli token balance --contract 0xA0b8... --network sepolia" }, + ], formatText: TextFormatters.tokenBalance, }; @@ -82,9 +85,12 @@ export const tokenInfoSpec: ChainSpec = { wallet: "none", auth: "none", capability: "account.balance.token", - summary: "Show token metadata (name/symbol/decimals/totalSupply)", + summary: "Show token metadata", baseFields: selectorFields, - examples: [{ cmd: "wallet-cli token info --contract TR7..." }], + examples: [ + { cmd: "wallet-cli token info --contract TR7... --network nile" }, + { cmd: "wallet-cli token info --contract 0xA0b8... --network sepolia" }, + ], formatText: TextFormatters.tokenInfo, }; @@ -99,9 +105,12 @@ export const tokenAddSpec: ChainSpec = { wallet: "optional", auth: "none", capability: "token.tokenbook", - summary: "Add a token to the address book (fetches symbol/decimals)", + summary: "Add a token to the address book", baseFields: selectorFields, - examples: [{ cmd: "wallet-cli token add --contract TR7..." }], + examples: [ + { cmd: "wallet-cli token add --contract TR7... --network nile" }, + { cmd: "wallet-cli token add --contract 0xA0b8... --network sepolia" }, + ], formatText: TextFormatters.tokenBookAdd, }; @@ -116,9 +125,12 @@ export const tokenListSpec: ChainSpec = { wallet: "optional", auth: "none", capability: "token.tokenbook", - summary: "List the address book (official + user)", + summary: "List the address book", baseFields: z.object({}), - examples: [{ cmd: "wallet-cli token list" }], + examples: [ + { cmd: "wallet-cli token list --network nile" }, + { cmd: "wallet-cli token list --network sepolia" }, + ], formatText: TextFormatters.tokenBookList, }; @@ -133,9 +145,12 @@ export const tokenRemoveSpec: ChainSpec = { wallet: "optional", auth: "none", capability: "token.tokenbook", - summary: "Remove a user-added token from the address book", + summary: "Remove a user-added token", baseFields: selectorFields, - examples: [{ cmd: "wallet-cli token remove --contract TR7..." }], + examples: [ + { cmd: "wallet-cli token remove --contract TR7... --network nile" }, + { cmd: "wallet-cli token remove --contract 0xA0b8... --network sepolia" }, + ], formatText: TextFormatters.tokenBookRemove, }; diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index d5d50faf6..691067180 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -41,7 +41,12 @@ export const txSendSpec: ChainSpec = { auth: "conditional", broadcasts: true, capability: "tx.send", - summary: "Send native TRX or TRC20/TRC10 tokens with human --amount", + summary: "Send the native coin or a token", + 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 — + // help has to be readable on its own, without the reader having seen the spec. + "Flags marked (tron) or (evm) apply only on networks of that family; using one on the other family is rejected.", baseFields: sendFields, exclusive: [ { label: "the amount to send", flags: ["amount", "raw-amount"], select: "exactly-one" }, @@ -56,10 +61,11 @@ export const txSendSpec: ChainSpec = { ], baseRefine: amountSelector, examples: [ - { cmd: "wallet-cli tx send --to T... --amount 1" }, - { cmd: "wallet-cli tx send --to T... --token USDT --amount 5" }, - { cmd: "wallet-cli tx send --to T... --contract TR7... --amount 5" }, - { cmd: "wallet-cli tx send --to T... --asset-id 1002000 --raw-amount 1000000" }, + { cmd: "wallet-cli tx send --to T... --amount 1 --network nile" }, + { cmd: "wallet-cli tx send --to 0x742d... --amount 1 --network sepolia" }, + { cmd: "wallet-cli tx send --to T... --token USDT --amount 5 --network nile" }, + { cmd: "wallet-cli tx send --to 0x742d... --token USDC --amount 5 --network sepolia" }, + { cmd: "wallet-cli tx send --to T... --asset-id 1002000 --raw-amount 1000000 --network nile" }, ], formatText: TextFormatters.txReceipt, }; @@ -115,7 +121,12 @@ export const txSignEvmBinding = (svc: EvmTransactionService): FamilyBinding => ( }); export const txBroadcastEvmBinding = (svc: EvmTransactionService): FamilyBinding => ({ - run: async (ctx, net, input) => svc.broadcast(ctx, net, evmHexOnly(input)), + run: async (ctx, net, input) => { + if (input.dryRun && ctx.wait) { + throw new UsageError("invalid_option", "--wait cannot be used with --dry-run"); + } + return svc.broadcast(ctx, net, evmHexOnly(input), input.dryRun === true); + }, }); export const txSendEvmBinding = (svc: EvmTransactionService): FamilyBinding => ({ @@ -137,13 +148,13 @@ export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => }); const broadcastFields = z.object({ - transaction: z.string().optional().describe("signed TRON transaction JSON"), - hex: z.string().min(2).optional().describe("complete signed protocol.Transaction hex"), + transaction: z.string().optional().describe("signed transaction JSON"), + hex: z.string().min(2).optional().describe("signed transaction hex: protobuf hex for TRON, RLP for EVM"), file: z .string() .min(1) .optional() - .describe("file containing complete signed protocol.Transaction hex"), + .describe("file containing the signed transaction hex"), dryRun: z .boolean() .default(false) @@ -160,7 +171,7 @@ export const txBroadcastSpec: ChainSpec = { auth: "none", broadcasts: true, capability: "tx.broadcast", - summary: "Validate and broadcast a presigned JSON or protobuf-hex transaction", + summary: "Broadcast a presigned transaction", baseFields: broadcastFields, exclusive: [ { @@ -180,8 +191,9 @@ export const txBroadcastSpec: ChainSpec = { } }, examples: [ - { cmd: "wallet-cli tx broadcast --tx-stdin < signed.json" }, - { cmd: "wallet-cli tx broadcast --file signed.hex" }, + { cmd: "wallet-cli tx broadcast --file signed.hex --network nile" }, + { cmd: "wallet-cli tx broadcast --file signed.hex --network sepolia" }, + { cmd: "wallet-cli tx broadcast --tx-stdin < signed.json --network nile" }, ], formatText: TextFormatters.txReceipt, }; @@ -214,8 +226,8 @@ export const txBroadcastTronBinding = (service: TronMultisigService): FamilyBind }); 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"), + hex: z.string().min(2).optional().describe("transaction hex: protobuf hex for TRON, RLP for EVM"), + file: z.string().min(1).optional().describe("file containing the transaction hex"), }; const approvalsFields = z.object(artifactFields); @@ -226,7 +238,7 @@ export const txApprovalsSpec: ChainSpec = { wallet: "none", auth: "none", capability: "tx.multisig.local", - summary: "Show permission, signature approvals, current weight, and expiration", + summary: "Show collected signatures on a multi-sig transaction", description: "Inspect the transaction, selected permission group, approved signers, accumulated weight, missing weight, and expiration without signing.", baseFields: approvalsFields, @@ -245,7 +257,7 @@ const signFields = z.object({ .string() .min(1) .optional() - .describe("unsigned TRON transaction JSON; retained for direct single-signature compatibility"), + .describe("unsigned transaction JSON; TRON compatibility path, never checked online"), ...artifactFields, offline: z .boolean() @@ -267,13 +279,17 @@ export const txSignSpec: ChainSpec = { auth: "required", broadcasts: false, capability: "tx.sign", - summary: "Sign transaction JSON or append a signature to transaction hex", + summary: "Sign a transaction built elsewhere", + // NOTE: the §6.2 spec block also promises "one built for another chain is rejected before it + // is signed". That check (`chain_id_mismatch`) is NOT implemented yet, so the sentence is + // deliberately absent — help must not promise a guard the code does not enforce. description: - "With --transaction, preserve the direct JSON signing flow. With --hex/--file, append exactly\n" + - "one signature while preserving prior signatures, verifying online that this account is in the\n" + - "transaction's permission group and has not already signed, and reporting the resulting\n" + - "approval weight. Add --offline to sign without contacting a node, which skips those checks.\n" + - "This command never broadcasts.", + "Sign a transaction that was built elsewhere and output the signed result; broadcast it\n" + + "later with `tx broadcast`. This command never broadcasts.\n" + + "On TRON, --hex/--file append one signature while preserving any already collected,\n" + + "checking online that this account is in the transaction's permission group and has not\n" + + "already signed, and reporting the resulting approval weight; --offline skips those checks.\n" + + "On EVM a transaction carries exactly one signature, so an already-signed one is refused.", baseFields: signFields, // --hex/--file first: --transaction is the compatibility path, not the co-signing one. exclusive: [{ label: "the transaction to co-sign", flags: ["hex", "file", "transaction"] }], @@ -306,7 +322,8 @@ export const txSignSpec: ChainSpec = { { 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 unsigned.hex --out signed.hex --network nile --password-stdin" }, + { cmd: "wallet-cli tx sign --file unsigned.hex --out signed.hex --network sepolia --password-stdin" }, { cmd: "wallet-cli tx sign --file partially-signed.hex --offline --password-stdin" }, ], formatText: TextFormatters.txSign, @@ -380,7 +397,7 @@ export const txTronLinkMultisigSpec: ChainSpec = { wallet: "optional", auth: "conditional", capability: "tx.multisig.tronlink", - summary: "Coordinate multi-signature collection through the TronLink service", + summary: "Create / co-sign a multi-sig transaction", description: "With no mode flag, list service-managed transactions for the selected account. --create signs\n" + "an UNSIGNED transaction locally and submits it, which opens the collection at the first\n" + @@ -434,7 +451,7 @@ export const txTronLinkMultisigBinding = ( }, }); -const statusFields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") }); +const statusFields = z.object({ txid: z.string().min(1).describe("transaction id/hash") }); export const txStatusSpec: ChainSpec = { path: ["tx", "status"], @@ -443,7 +460,10 @@ export const txStatusSpec: ChainSpec = { auth: "none", summary: "Show confirmation status of a transaction", baseFields: statusFields, - examples: [{ cmd: "wallet-cli tx status --txid abc123" }], + examples: [ + { cmd: "wallet-cli tx status --txid abc123 --network nile" }, + { cmd: "wallet-cli tx status --txid 0x9c4e... --network sepolia" }, + ], formatText: TextFormatters.txStatus, }; @@ -455,7 +475,7 @@ export const txStatusEvmBinding = (svc: EvmTransactionService): FamilyBinding => run: async (ctx, net, input) => svc.status(ctx, net, input.txid), }); -const infoFields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") }); +const infoFields = z.object({ txid: z.string().min(1).describe("transaction id/hash") }); export const txInfoSpec: ChainSpec = { path: ["tx", "info"], @@ -464,7 +484,10 @@ export const txInfoSpec: ChainSpec = { auth: "none", summary: "Show full transaction detail + receipt", baseFields: infoFields, - examples: [{ cmd: "wallet-cli tx info --txid abc123" }], + examples: [ + { cmd: "wallet-cli tx info --txid abc123 --network nile" }, + { cmd: "wallet-cli tx info --txid 0x9c4e... --network sepolia" }, + ], formatText: TextFormatters.txInfo, }; diff --git a/ts/src/adapters/inbound/cli/commands/typed-data.ts b/ts/src/adapters/inbound/cli/commands/typed-data.ts index 3aa001c05..bc1620a3d 100644 --- a/ts/src/adapters/inbound/cli/commands/typed-data.ts +++ b/ts/src/adapters/inbound/cli/commands/typed-data.ts @@ -21,13 +21,16 @@ export const typedDataSignSpec: ChainSpec = { capability: "typedData.sign", summary: "Sign EIP-712 / TIP-712 structured data", description: - "Prints the signature, the digest that was signed, and the primary type.\n" + + "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.", baseFields: typedDataFields, examples: [ { - cmd: `wallet-cli typed-data sign --typed-data '{"domain":{...},"types":{...},"message":{...}}'`, + cmd: `wallet-cli typed-data sign --typed-data '{"domain":{...},"types":{...},"message":{...}}' --network nile`, + }, + { + cmd: `wallet-cli typed-data sign --typed-data '{"domain":{...},"types":{...},"message":{...}}' --network sepolia`, }, ], formatText: TextFormatters.typedDataSign, diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index 8166da443..8f97c2d78 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -259,7 +259,7 @@ export function registerWalletCommands( positionals: [{ field: "path" }], promptHints: { label: "default-label" }, requires: ["the keystore file's own password — entered interactively in a TTY"], - summary: "Import an account from a standard Web3 keystore JSON", + summary: "Import a Web3 keystore file", description: "Import a single account from a standard Web3 keystore JSON (as exported by TronLink or\n" + "'backup --keystore'), stored encrypted under your master password and made active. It carries\n" + @@ -302,7 +302,7 @@ export function registerWalletCommands( scanLimit: "skip", }, requires: ["a connected, unlocked Ledger with the selected app (--app) open"], - summary: "Register a Ledger account (watch-only; signs on device)", + summary: "Register a Ledger account", fields: walletImportLedgerFields, input: walletImportLedgerInput, examples: [{ cmd: "wallet-cli import ledger --app tron --index 0 --label cold" }], @@ -325,7 +325,7 @@ export function registerWalletCommands( address: z .string() .min(1) - .describe("watch-only address to track; format: TRON base58 T...; family is auto-detected"), + .describe("watch-only address to track; TRON base58 (T...) or EVM hex (0x...), detected from the value"), label: Schemas.label() .optional() .describe("human-friendly unique account label, 1-64 chars; omit to auto-generate"), @@ -337,7 +337,7 @@ export function registerWalletCommands( auth: "none", interactive: true, promptHints: { label: "default-label" }, - summary: "Register a watch-only address (no secret)", + summary: "Register a watch-only address", fields: importWatchFields, input: importWatchFields, examples: [{ cmd: "wallet-cli import watch --address T... --label team-vault" }], @@ -416,7 +416,7 @@ export function registerWalletCommands( .boolean() .default(false) .describe( - "render a terminal receive QR containing exactly the selected TRON address; text TTY only", + "render a terminal receive QR containing exactly the receive address for the selected network; text TTY only", ), }); reg.add({ @@ -732,7 +732,10 @@ export function registerWalletCommands( passwordMode: "verify", interactive: true, secretsTtyOnly: true, - requires: ["the new master password — entered interactively in a TTY"], + // The prompt order is current-then-new, and §10.1 rule 4 makes Requires follow the order the + // user actually types. The generated line covers the current password, so the new one has to + // come after it. + requiresAfterAuth: ["the new master password — entered interactively in a TTY"], summary: "Change the master password (re-encrypt keystores)", description: "Change the master password. Re-encrypts every software wallet keystore with the\n" + diff --git a/ts/src/adapters/inbound/cli/commands/witness.ts b/ts/src/adapters/inbound/cli/commands/witness.ts index c8b9b8c67..27410c41a 100644 --- a/ts/src/adapters/inbound/cli/commands/witness.ts +++ b/ts/src/adapters/inbound/cli/commands/witness.ts @@ -26,7 +26,7 @@ export const witnessCreateSpec: ChainSpec = { ...witnessWriteBase, summary: "Register as a super representative candidate", description: - "Register the account as an SR candidate. The chain burns getAccountUpgradeCost\n" + + "Register the account as an SR candidate. The chain burns a fee set by an on-chain parameter\n" + "from the account balance; the fee is irreversible and registration cannot be undone.", requires: ["an activated account funded for the on-chain registration burn"], baseFields: z.object({ url: witnessUrl, ...governanceTxModeFields }), diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index 5b45bac4b..a30c864da 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -87,6 +87,11 @@ interface CommandDefinitionBase { /** extra command-specific preconditions rendered in the help "Requires:" block, ahead of the * auto-derived network/auth/account lines (e.g. a connected Ledger for `import ledger`). */ requires?: string[]; + /** preconditions that must render AFTER the auto-derived master-password line rather than + * before it. §10.1 rule 4 orders same-class prerequisites by the order the user supplies + * them, and `change-password` asks for the current password before the new one — so its + * "new master password" line has to follow the generated one, not lead it. */ + requiresAfterAuth?: string[]; /** mutually-exclusive option sets, surfaced in help; see ExclusiveGroup. */ exclusive?: ExclusiveGroup[]; /** per-field zod object; feeds the arity adapter + HelpService. */ diff --git a/ts/src/adapters/inbound/cli/help/examples-are-runnable.test.ts b/ts/src/adapters/inbound/cli/help/examples-are-runnable.test.ts new file mode 100644 index 000000000..2eb7b9126 --- /dev/null +++ b/ts/src/adapters/inbound/cli/help/examples-are-runnable.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { z } from "zod"; +import { isChainCommand } from "../contracts/index.js"; +import { GLOBAL_FLAGS, inputFlagsFor } from "./catalog.js"; +import { composeCliRuntime } from "../../../../bootstrap/composition.js"; + +/** + * Every flag used in a help Example must be a flag the command actually declares. + * + * Examples are the part of help people copy verbatim, and nothing keeps them honest when a flag + * is renamed: `contract deploy` advertised `--bytecode` and `--params` for a whole release after + * the v4.13.0 rename moved them to `--code` / `--code-file` / `--constructor-params`, so the one + * line a reader was most likely to paste was the one line guaranteed to fail with + * `unknown option`. A renamed flag now fails here instead of in someone's terminal. + */ +describe("help examples only use flags the command declares", () => { + let previousHome: string | undefined; + + beforeAll(() => { + previousHome = process.env.WALLET_CLI_HOME; + process.env.WALLET_CLI_HOME = mkdtempSync(join(tmpdir(), "wallet-cli-examples-")); + }); + + afterAll(() => { + if (previousHome === undefined) delete process.env.WALLET_CLI_HOME; + else process.env.WALLET_CLI_HOME = previousHome; + }); + + /** zod field names are camelCase; the CLI spells them kebab-case. */ + const kebab = (name: string): string => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); + + function declaredFlags(cmd: ReturnType["registry"] extends never + ? never + : any): Set { + const out = new Set(); + for (const g of GLOBAL_FLAGS) out.add(g.flag.replace(/^--/, "")); + for (const g of inputFlagsFor(isChainCommand(cmd) ? cmd.spec : cmd)) + out.add(g.flag.replace(/^--/, "")); + const shapes: z.ZodRawShape[] = []; + if (isChainCommand(cmd)) { + shapes.push(cmd.spec.baseFields.shape); + for (const binding of Object.values(cmd.families)) + if (binding?.fields) shapes.push(binding.fields.shape); + } else { + shapes.push(cmd.fields.shape); + } + for (const shape of shapes) for (const name of Object.keys(shape)) out.add(kebab(name)); + return out; + } + + it("names no flag that does not exist on the command", () => { + const runtime = composeCliRuntime({ + globals: { output: "text", verbose: false }, + secretPaths: {}, + startedAt: Date.now(), + }); + + const offenders: string[] = []; + for (const cmd of runtime.registry.all()) { + const path = (isChainCommand(cmd) ? cmd.spec.path : cmd.path).join(" "); + const examples = (isChainCommand(cmd) ? cmd.spec.examples : cmd.examples) ?? []; + const allowed = declaredFlags(cmd); + for (const example of examples) { + // Long flags only. Short aliases (-o) and shell redirection are not command flags. + for (const [, flag] of example.cmd.matchAll(/(?:^|\s)--([a-z0-9][a-z0-9-]*)/g)) { + if (!allowed.has(flag!)) offenders.push(`${path}: --${flag} (in "${example.cmd}")`); + } + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/ts/src/adapters/inbound/cli/help/group-family-tags.test.ts b/ts/src/adapters/inbound/cli/help/group-family-tags.test.ts new file mode 100644 index 000000000..81053f14c --- /dev/null +++ b/ts/src/adapters/inbound/cli/help/group-family-tags.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { HelpService } from "./index.js"; +import { isChainCommand, type StreamManager } from "../contracts/index.js"; +import { composeCliRuntime } from "../../../../bootstrap/composition.js"; + +/** + * Group help tags a sub-command with `(tron)` / `(evm)` when only that family can serve it. + * + * The tag is DERIVED from the registry, never written by hand, because §10.1 defines it as a + * statement about the current bindings ("補齊後標註即摘掉"). A hand-maintained tag goes stale + * silently and then lies: the root listing kept `chain (tron)` long after `chain node` and + * `chain prices` gained EVM bindings. These tests pin the derivation, not a copy of the text. + */ +describe("group help family tags are derived from the registry", () => { + let previousHome: string | undefined; + + beforeAll(() => { + previousHome = process.env.WALLET_CLI_HOME; + process.env.WALLET_CLI_HOME = mkdtempSync(join(tmpdir(), "wallet-cli-group-tags-")); + }); + + afterAll(() => { + if (previousHome === undefined) delete process.env.WALLET_CLI_HOME; + else process.env.WALLET_CLI_HOME = previousHome; + }); + + function groupHelp(group: string): { rows: Map; families: Map } { + const runtime = composeCliRuntime({ + globals: { output: "text", verbose: false }, + secretPaths: {}, + startedAt: Date.now(), + }); + let text = ""; + const stream = { + result(t: string) { + text = t; + }, + diagnostic() {}, + errorLine() {}, + event() {}, + readStdinOnce: () => "", + warnings: () => [], + } as unknown as StreamManager; + new HelpService(runtime.registry, stream, "0.0.0").handleMeta([group, "--help"]); + + const rows = new Map(); + let inCommands = false; + for (const line of text.split("\n")) { + if (line.startsWith("Commands:")) { + inCommands = true; + continue; + } + if (inCommands) { + if (!line.trim()) break; + const verb = line.trim().split(/\s+/)[0]!; + rows.set(verb, /\((tron|evm)\)$/.exec(line.trimEnd())?.[1] ?? ""); + } + } + + const families = new Map(); + for (const c of runtime.registry.all()) { + if (!isChainCommand(c) || c.spec.path[0] !== group) continue; + families.set( + c.spec.path[1]!, + Object.entries(c.families) + .filter(([, b]) => b !== undefined) + .map(([f]) => f), + ); + } + return { rows, families }; + } + + it("tags a row only when exactly one family is bound to it", () => { + for (const group of ["account", "chain", "tx", "contract", "token"]) { + const { rows, families } = groupHelp(group); + expect(rows.size).toBeGreaterThan(0); + for (const [verb, tag] of rows) { + const bound = families.get(verb) ?? []; + expect(bound.length, `${group} ${verb} has no binding`).toBeGreaterThan(0); + expect(tag, `${group} ${verb} bound to ${bound.join("+")}`).toBe( + bound.length === 1 ? bound[0]! : "", + ); + } + } + }); + + it("does not repeat a group-level tag on every row of a single-family group", () => { + // `asset` is TRON-only end to end, and the root listing already says so. A column whose + // value never varies is noise, not information (§10.3: 其組 help 內部不再逐條重複). + const { rows } = groupHelp("asset"); + expect(rows.size).toBeGreaterThan(1); + expect([...rows.values()].every((tag) => tag === "")).toBe(true); + }); + + it("tags the mixed groups that actually need it", () => { + // Spot-check the discriminating rows: these are the ones a reader relies on. + expect(groupHelp("chain").rows.get("params")).toBe("tron"); + expect(groupHelp("chain").rows.get("node")).toBe(""); + expect(groupHelp("tx").rows.get("multisig")).toBe("tron"); + expect(groupHelp("tx").rows.get("send")).toBe(""); + expect(groupHelp("account").rows.get("history")).toBe("tron"); + expect(groupHelp("account").rows.get("portfolio")).toBe(""); + }); +}); diff --git a/ts/src/adapters/inbound/cli/help/help.test.ts b/ts/src/adapters/inbound/cli/help/help.test.ts index 403d42295..b04da035b 100644 --- a/ts/src/adapters/inbound/cli/help/help.test.ts +++ b/ts/src/adapters/inbound/cli/help/help.test.ts @@ -139,8 +139,10 @@ describe("shipped exclusive groups actually render", () => { }); // The (tron) tag on the root listing tells a reader which groups disappear on a non-TRON network. -// `chain` is assembled only in the tron family (bootstrap/families/tron.ts), like permission / -// gasfree / stake / vote / reward — it was the one family-scoped group left untagged. +// It is therefore a claim about the CURRENT bindings, and a stale one actively misleads: `chain` +// carried (tron) for a whole release after `chain node` and `chain prices` gained EVM bindings, +// telling every EVM reader that a group they could in fact use was closed to them. A group is +// tagged only while EVERY command under it is bound to that one family. describe("root help family tags", () => { function rootRow(name: string): string { const stream = makeStream(); @@ -148,8 +150,14 @@ describe("root help family tags", () => { return (stream.last ?? "").split("\n").find((l) => l.trimStart().startsWith(`${name} `)) ?? ""; } - it("tags chain as TRON-only, like every other family-scoped group", () => { - expect(rootRow("chain")).toMatch(/\(tron\)$/); + it("leaves chain untagged, because chain node / chain prices serve EVM too", () => { + expect(rootRow("chain")).not.toMatch(/\(tron\)$/); + }); + + it("still tags the groups that really are TRON-only", () => { + for (const group of ["permission", "gasfree", "stake", "vote", "reward"]) { + expect(rootRow(group)).toMatch(/\(tron\)$/); + } }); it("leaves family-neutral groups untagged", () => { diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index 2a44290a0..dd7d241c4 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -17,6 +17,7 @@ import type { StreamManager, } from "../contracts/index.js"; import { CommandRegistry } from "../registry/index.js"; +import { UsageError } from "../../../../domain/errors/index.js"; import { introspectFields, type FieldInfo } from "../arity/index.js"; import { GLOBAL_FLAGS, type GlobalFlag, inputFlagsFor, buildCatalog } from "./catalog.js"; @@ -48,8 +49,9 @@ export class HelpService { this.streams.result(JSON.stringify(z.toJSONSchema(input))); return 0; } - // no concrete command → machine catalog (every command + flags), optionally scoped to a - // chain family (`tron --json-schema`). Mirrors the help tree. + this.#assertResolvable(family, path); + // no path → machine catalog (every command + flags), optionally scoped to a chain family + // (`tron --json-schema`). Mirrors the help tree. this.streams.result(this.#catalog(family)); return 0; } @@ -66,10 +68,37 @@ export class HelpService { this.streams.result(this.#renderNeutralGroup(path[0]!)); return 0; } + this.#assertResolvable(family, path); this.streams.result(this.#renderTree(path[0])); return 0; } + /** + * A path that names nothing is an error here, exactly as it is at dispatch. + * + * Before this, ANY unresolved path fell through to the root listing (or the full catalog) and + * returned 0 — so `wallet-cli tx snd --help` answered a question nobody asked and called it + * success. A typo has to fail the same way with `--help` on the line as without it, or the + * meta flags become a hole in the CLI's own exit-code contract. + */ + #assertResolvable(family: ChainFamily | undefined, path: string[]): void { + if (path.length === 0) return; + const head = path[0]!; + // A bare group name is legitimate — that is what renders the group page. + if (path.length === 1 && (this.#isChainGroup(head) || this.#isNeutralGroup(head))) return; + + // Distinguish "no such command" from "that command exists, just not for this family": + // the second is what a family-prefixed query (`evm account history --help`) really hit, + // and answering it with unknown_command would send the reader looking for a typo. + if (family && this.registry.resolveChain(path)) { + throw new UsageError( + "family_mismatch", + `${path.join(" ")} has no ${family} implementation`, + ); + } + throw new UsageError("unknown_command", `unknown command: ${path.join(" ")}`); + } + /** strip an optional leading family token (e.g. tron) — a help/catalog addressing prefix. */ #split(positionals: string[]): { family?: ChainFamily; path: string[] } { const head = positionals[0]; @@ -79,16 +108,27 @@ export class HelpService { return { path: positionals }; } - /** resolve to a single command: a neutral command by full path, or a family-pinned chain command. */ + /** + * Resolve to a single command: the LONGEST prefix of the path that names one. + * + * People reach help by appending --help to the line they were already typing, so the path + * still carries arguments: `tx send --to T... --help` arrives as ["tx","send","T..."] because + * `metaPositionals` only knows which GLOBAL flags consume a value, and positionals + * (`block 123`, `contract clear-abi TQ5...`) are genuinely part of the path. Everything past + * the command is an argument, so the prefix is what we resolve. + * + * A prefix that names only a GROUP does not count — otherwise `tx bogus` would resolve to + * `tx` and a mistyped verb would silently get someone else's help page. + */ #resolveConcrete(family: ChainFamily | undefined, path: string[]): StoredCommand | null { - if (path.length === 0) return null; - const chain = this.registry.resolveChain(path); - if (chain && (!family || chain.families[family])) return chain; - const chainHeadLeaf = this.registry.resolveChain([path[0]!]); - if (chainHeadLeaf && (!family || chainHeadLeaf.families[family])) return chainHeadLeaf; - if (family) return null; - const neutral = this.registry.resolveNeutral(path); - if (neutral) return neutral; + for (let end = path.length; end > 0; end -= 1) { + const prefix = path.slice(0, end); + const chain = this.registry.resolveChain(prefix); + if (chain && (!family || chain.families[family])) return chain; + if (family) continue; + const neutral = this.registry.resolveNeutral(prefix); + if (neutral) return neutral; + } return null; } @@ -108,28 +148,36 @@ export class HelpService { ["import", "Import a wallet", ""], ["list", "List wallets / accounts", ""], ] as const; + // Rows, order and wording follow the §10.2 spec block, with two deliberate departures + // recorded in needs-doc §U-3: `exchange` keeps a verb phrase (the spec's "On-chain Bancor + // exchange" is a noun phrase, which §10.1 rule 1 forbids), and `contract` keeps "govern" + // (the spec's "send" drops any mention of the four governance sub-commands). + // Descriptions are verb summaries and must NOT name sub-commands — a TRON-only verb named + // here (`chain`'s old "params") sends EVM readers hunting for a command they cannot run. const management = [ - ["account", "Query on-chain account state, activate & name accounts", ""], - ["permission", "View and update account multi-sign permissions", "tron"], + ["account", "Query on-chain account state", ""], + ["permission", "View / update account permissions (multi-sig)", "tron"], ["token", "Manage the token address book and query tokens", ""], - ["asset", "Issue and manage TRC10 tokens", "tron"], - ["exchange", "Create and trade Bancor exchange pairs", "tron"], ["tx", "Build, send, broadcast, and inspect transactions", ""], - ["contract", "Call, deploy, govern, and inspect smart contracts", ""], ["gasfree", "Gas-free token transfers via the GasFree service", "tron"], - ["proposal", "Create and vote on governance proposals", "tron"], - ["witness", "Register and operate an SR candidacy", "tron"], + ["contract", "Call, deploy, govern, and inspect smart contracts", ""], + ["proposal", "Create / vote on governance proposals", "tron"], + ["witness", "Register / operate a super representative", "tron"], + ["asset", "Issue & manage TRC10 tokens", "tron"], + ["exchange", "Create and trade Bancor exchange pairs", "tron"], ["stake", "Stake / delegate resources & query state", "tron"], ["vote", "Vote for super representatives", "tron"], ["reward", "Query / withdraw voting rewards", "tron"], - ["chain", "Query chain params, prices & node info", "tron"], + // No (tron) tag: `chain node` and `chain prices` both serve EVM. Only `chain params` + // is TRON-only, and that difference belongs on the sub-command row in the group help. + ["chain", "Query chain and node state", ""], ["message", "Sign arbitrary messages", ""], ["typed-data", "Sign EIP-712 / TIP-712 structured data", ""], ["block", "Get a block (latest if omitted)", ""], ] as const; const commands = [ ["use", "Set the active account", ""], - ["current", "Show the current account (--qr for a receive QR code)", ""], + ["current", "Show the current (active) account", ""], ["rename", "Rename an account label", ""], ["derive", "Derive the next HD account from a seed wallet", ""], ["backup", "Export an account's secret + metadata (0600)", ""], @@ -137,7 +185,7 @@ 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", ""], + ["encoding", "Convert / validate addresses & encodings", ""], ["address", "Generate a random keypair (local, not stored)", ""], ["contact", "Manage the recipient address book", ""], ] as const; @@ -157,7 +205,7 @@ export class HelpService { ` ${name.padEnd(width)}${desc ? dim(desc) : ""}`.trimEnd(); const optionRows = [ ["-o, --output string", 'Output format ("text", "json") (default from config)'], - ["--network string", 'Canonical network id, e.g. "tron:mainnet", "tron:nile", "tron:shasta"'], + ["--network string", 'Network id or alias, e.g. "tron", "ethereum", "sepolia"'], ["--account string", "Account label or address to act as (overrides active)"], ["--timeout int", "Request timeout in milliseconds"], ["-v, --verbose", "Verbose / debug logging"], @@ -170,7 +218,7 @@ export class HelpService { const lines = [ `${bold("Usage:")} wallet-cli [OPTIONS] COMMAND`, "", - `${bold("wallet-cli")} — CLI wallet for TRON.`, + `${bold("wallet-cli")} — CLI wallet for TRON and EVM networks.`, "Agent-first: deterministic exit codes, JSON output.", "", bold("Common Commands:"), @@ -189,31 +237,47 @@ export class HelpService { return lines.join("\n"); } - /** neutral group (`import --help`): list the group's sub-commands. Derived from the registry. */ + /** neutral group (`import --help`): list the group's sub-commands. Derived from the registry. + * Neutral commands are not chain-bound at all, so no row carries a family tag. */ #renderNeutralGroup(head: string): string { const cmds = this.#neutralGroupCommands(head); - const rows = cmds.map((c) => [c.path[1] ?? "", c.summary ?? ""] as const); + const rows = cmds.map((c) => [c.path[1] ?? "", c.summary ?? "", ""] as const); return this.#renderGroup(head, rows); } /** logical resource group (`account --help`): default surface, implementations chosen by --network/defaultNetwork. */ #renderLogicalNs(group: string): string { const commands = this.#chainGroupCommands(group); - const rows = commands.map((c) => [c.path[1] ?? "", c.summary ?? ""] as const); + const tags = commands.map((c) => groupRowTag(c.families)); + // A group whose every command belongs to the same single family is already tagged as a whole + // at the root (`stake … (tron)`). Repeating it on all six rows adds a column that never + // varies — §10.3: "其組 help 內部不再逐條重複". Tag rows only where they DISCRIMINATE. + const uniform = tags.length > 0 && tags.every((t) => t !== "" && t === tags[0]); + const rows = commands.map( + (c, i) => [c.path[1] ?? "", c.summary ?? "", uniform ? "" : tags[i]!] as const, + ); return this.#renderGroup(group, rows); } /** shared group skeleton: inline Usage → description → verb list → footer. */ - #renderGroup(group: string, rows: ReadonlyArray): string { + #renderGroup(group: string, rows: ReadonlyArray): string { // Width is the longest verb, uncapped: a cap cannot shorten an over-long verb, it only stops // padEnd from reaching it — so every summary in the group loses its column the moment one verb // exceeds the cap (`contract set-user-resource-percent`, 25 chars, did exactly that). const width = Math.max(0, ...rows.map(([verb]) => verb.length)) + 2; + // Family tags share one column, aligned past the widest summary, so they read as a column + // rather than as trailing prose. Two spaces minimum, matching the leaf Options tags. + const tagCol = Math.max(0, ...rows.map(([, summary]) => summary.length)) + 2; const lines = [`${bold("Usage:")} wallet-cli ${group} COMMAND`, ""]; const desc = GROUP_DESCRIPTIONS[group]; if (desc) lines.push(desc, ""); lines.push(bold("Commands:")); - for (const [verb, summary] of rows) lines.push(` ${verb.padEnd(width)} ${summary}`.trimEnd()); + for (const [verb, summary, tag] of rows) { + const body = ` ${verb.padEnd(width)} ${summary}`; + lines.push( + tag ? `${body}${" ".repeat(Math.max(2, tagCol - summary.length))}(${tag})` : body.trimEnd(), + ); + } lines.push("", `Run 'wallet-cli ${group} COMMAND --help' for more information on a command.`); return lines.join("\n"); } @@ -235,6 +299,7 @@ export class HelpService { positionals: cmd.positionals, secretsTtyOnly: cmd.secretsTtyOnly, interactive: cmd.interactive, + requiresAfterAuth: cmd.requiresAfterAuth, }); } @@ -275,6 +340,7 @@ export class HelpService { exclusive?: ChainSpec["exclusive"]; examples: CommandDefinition["examples"]; requires?: string[]; + requiresAfterAuth?: string[]; positionals?: { field: string; placeholder?: string }[]; secretsTtyOnly?: boolean; interactive?: boolean; @@ -310,9 +376,10 @@ export class HelpService { 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", + ? "the master password — pass --password-stdin, or enter it interactively in a TTY" + : "the master password — pass --password-stdin; this command never prompts", ); + requires.push(...(c.requiresAfterAuth ?? [])); } 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", @@ -339,7 +406,7 @@ export class HelpService { key: f.kebab, head: flagHead(f), desc: f.description ?? "", - tag: family ? `${flagTag(f)} (${family})` : flagTag(f), + tag: family ? `${flagTag(f)}${flagTag(f) ? " " : ""}(${family})` : flagTag(f), }; }), ...c.inputFlags.map((g) => ({ @@ -422,11 +489,21 @@ export class HelpService { } /** chain group sub-commands, one row per logical chain definition. */ - #chainGroupCommands(group: string): Array<{ path: string[]; summary?: string }> { - const out: Array<{ path: string[]; summary?: string }> = []; + #chainGroupCommands( + group: string, + ): Array<{ path: string[]; summary?: string; families: string[] }> { + const out: Array<{ path: string[]; summary?: string; families: string[] }> = []; for (const c of this.registry.all()) { if (isChainCommand(c) && c.spec.path[0] === group) { - out.push({ path: c.spec.path, summary: c.spec.summary }); + out.push({ + path: c.spec.path, + summary: c.spec.summary, + // Which families actually have a binding — the tag is DERIVED from that, never + // hand-written, so it disappears on its own the day the second family is bound. + families: Object.entries(c.families) + .filter(([, binding]) => binding !== undefined) + .map(([family]) => family), + }); } } return out; @@ -552,6 +629,20 @@ function globalFlagsForText( }); } +/** + * The `(tron)` / `(evm)` tag for one sub-command row in a group help page. + * + * §10.1: the tag means "only this family can serve this command IN THE CURRENT VERSION" — it is + * not a promise about the future. So it is derived from the registry rather than written down: + * a command bound to exactly one family is tagged, one bound to both is not, and the tag drops + * off by itself the day the missing binding lands (`contract info` will, once EVM gets an + * indexer). Hand-written tags are how `chain` came to be labelled `(tron)` at the root long + * after `chain node` and `chain prices` started serving EVM. + */ +function groupRowTag(families: readonly string[]): string { + return families.length === 1 ? families[0]! : ""; +} + /** one rendered " --flag description [tag]" line, used by the Global options section. */ function globalFlagLine(g: GlobalFlag): string { const tag = globalFlagTag(g); @@ -562,8 +653,8 @@ function globalFlagLine(g: GlobalFlag): string { // behavior warrants it may span multiple lines (embed "\n"). Only groups that surface a // ` --help` page need an entry; absent → the description line is omitted. const GROUP_DESCRIPTIONS: Record = { - import: "Import a wallet from an existing secret or device.", - account: "Query on-chain account state, activate accounts, and set on-chain identity fields.", + import: "Import a wallet.", + account: "Query on-chain account state.", token: "Manage the token address book and query tokens.", tx: "Build, send, broadcast, and inspect transactions.", contract: "Call, deploy, govern, and inspect smart contracts.", @@ -578,14 +669,15 @@ const GROUP_DESCRIPTIONS: Record = { // pinned to mainnet rather than stated as an absolute. permission: "View and update account permissions (TRON multi-sign).\nAn account has one owner permission (full control), up to 8 active permissions (scoped operations),\nand — for SRs — one witness permission. Replacing the structure burns a chain-set fee (100 TRX on mainnet).\nMisconfiguring owner permission can permanently lock the account.", - chain: "Query on-chain parameters, resource prices, and node status.", + chain: "Query chain and node state.", message: "Sign arbitrary messages.", "typed-data": "Sign EIP-712 / TIP-712 structured data.", + asset: "Issue and manage TRC10 tokens.", + exchange: "Create and trade Bancor exchange pairs.", 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'.", + contact: "Manage the recipient address book.", }; /** "--output, -o " style header for text help. */ diff --git a/ts/src/adapters/inbound/cli/render/account.ts b/ts/src/adapters/inbound/cli/render/account.ts index 4eb0e033a..84911cd4f 100644 --- a/ts/src/adapters/inbound/cli/render/account.ts +++ b/ts/src/adapters/inbound/cli/render/account.ts @@ -1,5 +1,4 @@ import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; -import { RESOURCES, resourceOfRpcCode, type Resource } from "../../../../domain/resources/index.js"; import { fromBaseUnits } from "../../../../domain/amounts/index.js"; import { formatScalar, @@ -12,6 +11,7 @@ import { quote, } from "./scalars.js"; import { type Obj, type Pair, asObj, query, receipt, table, ok, fail, warn } from "./layout.js"; +import { FAMILY_RENDER, renderFamily, renderSymbol } from "./family.js"; /** humanize a raw base-unit balance: scale by `decimals` when known, else show the raw integer. */ function humanBalance(d: Obj): string { @@ -111,63 +111,22 @@ export const AccountFormatters = { }) satisfies TextFormatter, }; +/** + * `account info` — family-shaped. + * + * TRON returns the node's account object (permissions, resources, stakes); EVM has no equivalent + * RPC and returns a flat `{balance, nonce, isContract}`. These are not the same field set with + * different values, so the rows come from the family table rather than from one formatter reading + * whichever keys happen to be present — the TRON reader applied to an EVM payload found nothing + * and printed "Balance 0 TRX" for an account holding ETH. + */ function renderAccountInfo(d: Obj, ctx: TextRenderContext): string { - const account = asObj(d.account); - const owner = asObj(account.owner_permission); - const active = Array.isArray(account.active_permission) ? account.active_permission.length : 0; - const created = account.create_time - ? new Date(Number(account.create_time)).toISOString().slice(0, 10) - : ""; - const ownerKeys = Array.isArray(owner.keys) ? owner.keys.length : "?"; - const resources = asObj(d.resources); - const bandwidth = asObj(resources.bandwidth); - const energy = asObj(resources.energy); const pairs: Pair[] = []; if (ctx.accountLabel) pairs.push(["Label", ctx.accountLabel]); - pairs.push(["Address", String(d.address ?? "")]); - pairs.push(["Balance", `${formatSun(account.balance)} TRX`]); - const staked = stakedSummary(account); - if (staked) pairs.push(["Staked", staked]); - if (resources.energy) - pairs.push(["Energy", `used ${formatInt(energy.used)} / ${formatInt(energy.limit)}`]); - if (resources.bandwidth) - pairs.push(["Bandwidth", `used ${formatInt(bandwidth.used)} / ${formatInt(bandwidth.limit)}`]); - pairs.push(["Created", created]); - pairs.push([ - "Permissions", - `owner ${String(owner.threshold ?? "?")}-of-${ownerKeys}, ${active} active group${active === 1 ? "" : "s"}`, - ]); + pairs.push(...FAMILY_RENDER[renderFamily(ctx)].accountInfoRows(d, renderSymbol(ctx))); return query(pairs); } -/** Sum FreezeBalanceV2 stakes into a " TRX (energy + bandwidth )" summary. */ -function stakedSummary(account: Obj): string { - const frozen = Array.isArray(account.frozenV2) ? account.frozenV2.map(asObj) : []; - // frozenV2's bandwidth entries carry no `type`, so an unrecognized code folds into bandwidth. - const sums = new Map(RESOURCES.map((r) => [r, 0n])); - for (const f of frozen) { - const r = resourceOfRpcCode(String(f.type ?? "")) ?? "bandwidth"; - const amount = safeUnsignedBigInt(f.amount ?? 0); - // An unsafe JS number has already lost precision. Omit the summary instead of presenting a - // plausible but incorrect total; the raw account payload remains available in JSON mode. - if (amount === null) return ""; - sums.set(r, (sums.get(r) ?? 0n) + amount); - } - const total = RESOURCES.reduce((t, r) => t + (sums.get(r) ?? 0n), 0n); - if (total === 0n) return ""; - const parts = RESOURCES.map((r) => `${r} ${formatSun(sums.get(r) ?? 0n)}`).join(" + "); - return `${formatSun(total)} TRX (${parts})`; -} - -function safeUnsignedBigInt(value: unknown): bigint | null { - if (typeof value === "bigint") return value >= 0n ? value : null; - if (typeof value === "number") { - return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null; - } - if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value); - return null; -} - function historyRow(r: Obj): string[] { const ts = r.time ?? r.block_timestamp ?? r.timestamp; const type = r.type ?? r.transfer_type ?? r.direction ?? ""; diff --git a/ts/src/adapters/inbound/cli/render/chain.ts b/ts/src/adapters/inbound/cli/render/chain.ts index 58f083d59..26fc14e1a 100644 --- a/ts/src/adapters/inbound/cli/render/chain.ts +++ b/ts/src/adapters/inbound/cli/render/chain.ts @@ -1,6 +1,7 @@ import type { TextFormatter } from "../contracts/index.js"; -import { formatDecimal, formatInt, formatSun } from "./scalars.js"; +import { formatInt } from "./scalars.js"; import { asObj, query, table } from "./layout.js"; +import { FAMILY_RENDER, renderFamily, renderSymbol } from "./family.js"; const KNOWN_UNITS: Record = { getEnergyFee: "SUN", @@ -40,15 +41,13 @@ export const ChainFormatters = { ); }) satisfies TextFormatter, - chainPrices: ((data) => { + // Both families price transactions, but in disjoint terms — TRON in SUN per energy/bandwidth + // unit, EVM in gwei per gas under a fee model the chain reports. The rows come from the family + // table; reading one family's keys out of the other's payload printed three empty TRON labels + // on EVM and none of the fee data that was there. + chainPrices: ((data, ctx) => { const d = asObj(data); - const energy = asObj(d.energy); - const bandwidth = asObj(d.bandwidth); - return query([ - ["Energy price", `${formatInt(energy.currentSunPerUnit)} SUN / unit (current)`], - ["Bandwidth price", `${formatInt(bandwidth.currentSunPerUnit)} SUN / unit (current)`], - ["Memo fee", `${formatDecimal(formatSun(d.memoFeeSun))} TRX`], - ]); + return query(FAMILY_RENDER[renderFamily(ctx)].chainPricesRows(d, renderSymbol(ctx))); }) satisfies TextFormatter, chainNode: ((data) => { diff --git a/ts/src/adapters/inbound/cli/render/family-render.test.ts b/ts/src/adapters/inbound/cli/render/family-render.test.ts index 95850ed8d..744b0b011 100644 --- a/ts/src/adapters/inbound/cli/render/family-render.test.ts +++ b/ts/src/adapters/inbound/cli/render/family-render.test.ts @@ -107,3 +107,115 @@ describe("the native symbol comes from the network, not the family", () => { expect(Object.fromEntries(rows).Fee).toBe("0.000021 BNB"); }); }); + +/** + * `account info` and `chain prices` — the two commands whose text output was TRON-only. + * + * Both were rendered by a single TRON formatter for every family. On EVM that printed + * "Balance 0 TRX" for an account holding 0.412 ETH, and three empty TRON price labels with none + * of the EVM fee data. The JSON was correct in both cases, so this is purely the text side. + */ +describe("FAMILY_RENDER accountInfoRows", () => { + const EVM_ACCOUNT = { + address: "0xe4aAd11792F7E74f1B5cbce65f9a1E207c952961", + balance: "412090611420465897", + nonce: "16", + decimals: 18, + symbol: "ETH", + isContract: false, + }; + + it("states an EVM balance in the network's own coin", () => { + const rows = FAMILY_RENDER.evm.accountInfoRows(EVM_ACCOUNT, "ETH"); + + expect(rows).toContainEqual(["Balance", "0.41209 ETH"]); + // The exact failure this replaces: a real balance reported as an empty TRON account. + expect(rows.map((r) => r[1])).not.toContain("0 TRX"); + }); + + it("shows the nonce and whether the address holds code", () => { + const rows = FAMILY_RENDER.evm.accountInfoRows(EVM_ACCOUNT, "ETH"); + + expect(rows).toContainEqual(["Nonce", "16"]); + expect(rows).toContainEqual(["Type", "externally owned"]); + expect(FAMILY_RENDER.evm.accountInfoRows({ ...EVM_ACCOUNT, isContract: true }, "ETH")).toContainEqual([ + "Type", + "contract", + ]); + }); + + it("never shows EVM a permission or resource row", () => { + const labels = FAMILY_RENDER.evm.accountInfoRows(EVM_ACCOUNT, "ETH").map((r) => r[0]); + + expect(labels).not.toContain("Permissions"); + expect(labels).not.toContain("Energy"); + expect(labels).not.toContain("Bandwidth"); + expect(labels).not.toContain("Staked"); + }); + + it("keeps the TRON rows intact", () => { + const rows = FAMILY_RENDER.tron.accountInfoRows( + { + address: "TXP3YPS3mgoHRioz42gMhL6x5VvusPTMk6", + account: { + balance: "9000000000", + owner_permission: { threshold: 1, keys: [{}] }, + active_permission: [{}], + }, + resources: { energy: { used: 12, limit: 65 }, bandwidth: { used: 6, limit: 15 } }, + }, + "TRX", + ); + + expect(rows).toContainEqual(["Balance", "9,000 TRX"]); + expect(rows).toContainEqual(["Permissions", "owner 1-of-1, 1 active group"]); + expect(rows.map((r) => r[0])).toContain("Energy"); + }); +}); + +describe("FAMILY_RENDER chainPricesRows", () => { + const EVM_PRICES = { + feeModel: "eip1559", + baseFeeWei: "959341983", + priorityFeeWei: "1000000", + gasPriceWei: "960341983", + }; + + // gwei, not wei: it is the unit --max-fee and --priority-fee accept, and quoting the output in + // a different unit than the input would leave the reader converting nine zeros by hand. + it("prices EVM gas in gwei", () => { + const rows = FAMILY_RENDER.evm.chainPricesRows(EVM_PRICES, "ETH"); + + expect(rows).toContainEqual(["Fee model", "eip1559"]); + expect(rows).toContainEqual(["Base fee", "0.959341 gwei"]); + expect(rows).toContainEqual(["Gas price", "0.960341 gwei"]); + }); + + // A legacy chain reports no base fee. The row is absent rather than blank — an empty value is + // what the TRON formatter produced on EVM, and it says nothing. + it("omits the base fee on a legacy chain instead of printing a blank row", () => { + const rows = FAMILY_RENDER.evm.chainPricesRows( + { feeModel: "legacy", gasPriceWei: "5000000000" }, + "ETH", + ); + + expect(rows.map((r) => r[0])).not.toContain("Base fee"); + expect(rows).toContainEqual(["Gas price", "5 gwei"]); + }); + + it("never shows EVM a SUN-denominated row", () => { + const rendered = FAMILY_RENDER.evm.chainPricesRows(EVM_PRICES, "ETH").flat().join(" "); + + expect(rendered).not.toMatch(/SUN|TRX|Energy|Bandwidth|Memo/); + }); + + it("keeps the TRON rows intact", () => { + const rows = FAMILY_RENDER.tron.chainPricesRows( + { energy: { currentSunPerUnit: 100 }, bandwidth: { currentSunPerUnit: 1000 }, memoFeeSun: "1000000" }, + "TRX", + ); + + expect(rows[0]![1]).toContain("100 SUN / unit"); + expect(rows).toContainEqual(["Memo fee", "1 TRX"]); + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/family.ts b/ts/src/adapters/inbound/cli/render/family.ts index f8c3b2864..7b3b9e077 100644 --- a/ts/src/adapters/inbound/cli/render/family.ts +++ b/ts/src/adapters/inbound/cli/render/family.ts @@ -1,9 +1,10 @@ import type { TxInfoView } from "../../../../domain/types/index.js"; +import { RESOURCES, resourceOfRpcCode, type Resource } from "../../../../domain/resources/index.js"; import type { TextRenderContext } from "../contracts/index.js"; import { ChainFamily } from "../../../../domain/family/index.js"; import { ExecutionError } from "../../../../domain/errors/index.js"; -import { formatScalar, formatInt, formatSun, formatWei } from "./scalars.js"; -import { type Pair } from "./layout.js"; +import { formatScalar, formatInt, formatGwei, formatSun, formatWei } from "./scalars.js"; +import { asObj, type Obj, type Pair } from "./layout.js"; /** * Per-family render hooks — the one table that folds the scattered `r.family === tron ? … : …` @@ -24,6 +25,13 @@ interface FamilyRenderHooks { feeFallback(fee: unknown, symbol: string): string; /** address-type label for the per-family address rows. */ addressLabel: string; + /** `account info` rows below the Label. TRON reports the node's account object — permissions, + * resources, stakes; EVM has no such RPC and reports a flat balance/nonce/code triple. The + * field SETS differ, not just their values, so neither family can read the other's payload. */ + accountInfoRows(d: Obj, symbol: string): Pair[]; + /** `chain prices` rows. TRON prices energy and bandwidth in SUN; EVM prices gas per the fee + * model the chain reports. Same reason as accountInfoRows: disjoint field sets. */ + chainPricesRows(d: Obj, symbol: string): Pair[]; } const txInfoAmount = (v: string | undefined, suffix: string): string => @@ -34,6 +42,41 @@ export const FAMILY_RENDER: Record = { nativeAmount: (raw, symbol) => `${formatSun(raw)} ${symbol}`, feeFallback: (fee, symbol) => `${formatSun(fee)} ${symbol}`, addressLabel: "TRON address", + accountInfoRows: (d, symbol) => { + const account = asObj(d.account); + const owner = asObj(account.owner_permission); + const active = Array.isArray(account.active_permission) ? account.active_permission.length : 0; + const created = account.create_time + ? new Date(Number(account.create_time)).toISOString().slice(0, 10) + : ""; + const ownerKeys = Array.isArray(owner.keys) ? owner.keys.length : "?"; + const resources = asObj(d.resources); + const bandwidth = asObj(resources.bandwidth); + const energy = asObj(resources.energy); + const rows: Pair[] = [["Address", String(d.address ?? "")]]; + rows.push(["Balance", `${formatSun(account.balance)} ${symbol}`]); + const staked = stakedSummary(account, symbol); + if (staked) rows.push(["Staked", staked]); + if (resources.energy) + rows.push(["Energy", `used ${formatInt(energy.used)} / ${formatInt(energy.limit)}`]); + if (resources.bandwidth) + rows.push(["Bandwidth", `used ${formatInt(bandwidth.used)} / ${formatInt(bandwidth.limit)}`]); + rows.push(["Created", created]); + rows.push([ + "Permissions", + `owner ${String(owner.threshold ?? "?")}-of-${ownerKeys}, ${active} active group${active === 1 ? "" : "s"}`, + ]); + return rows; + }, + chainPricesRows: (d, symbol) => { + const energy = asObj(d.energy); + const bandwidth = asObj(d.bandwidth); + return [ + ["Energy price", `${formatInt(energy.currentSunPerUnit)} SUN / unit (current)`], + ["Bandwidth price", `${formatInt(bandwidth.currentSunPerUnit)} SUN / unit (current)`], + ["Memo fee", `${formatSun(d.memoFeeSun)} ${symbol}`], + ]; + }, txInfoRows: (r, symbol) => [ ["TxID", r.txid], ["From", r.from ?? ""], @@ -49,6 +92,24 @@ export const FAMILY_RENDER: Record = { nativeAmount: (raw, symbol) => `${formatWei(raw)} ${symbol}`, feeFallback: (fee, symbol) => `${formatWei(fee)} ${symbol}`, addressLabel: "EVM address", + accountInfoRows: (d, symbol) => [ + ["Address", String(d.address ?? "")], + ["Balance", `${formatWei(d.balance)} ${symbol}`], + ["Nonce", formatInt(d.nonce)], + // The distinction a reader needs before sending: an address with code may reject a plain + // transfer, and "isContract: false" is not a phrase to put in front of a person. + ["Type", d.isContract ? "contract" : "externally owned"], + ], + // Priced in gwei, the unit --max-fee and --priority-fee accept: showing wei here and taking + // gwei there would make the reader do the nine-zero conversion themselves. JSON keeps wei. + chainPricesRows: (d) => { + const rows: Pair[] = [["Fee model", String(d.feeModel ?? "")]]; + if (d.baseFeeWei !== undefined) rows.push(["Base fee", `${formatGwei(d.baseFeeWei)} gwei`]); + if (d.priorityFeeWei !== undefined) + rows.push(["Priority fee", `${formatGwei(d.priorityFeeWei)} gwei`]); + if (d.gasPriceWei !== undefined) rows.push(["Gas price", `${formatGwei(d.gasPriceWei)} gwei`]); + return rows; + }, txInfoRows: (r, symbol) => [ ["TxID", r.txid], ["From", r.from ?? ""], @@ -96,3 +157,31 @@ export function renderFamily(ctx?: TextRenderContext): ChainFamily { } return family; } + +/** Sum FreezeBalanceV2 stakes into a " TRX (energy + bandwidth )" summary. */ +function stakedSummary(account: Obj, symbol: string): string { + const frozen = Array.isArray(account.frozenV2) ? account.frozenV2.map(asObj) : []; + // frozenV2's bandwidth entries carry no `type`, so an unrecognized code folds into bandwidth. + const sums = new Map(RESOURCES.map((r) => [r, 0n])); + for (const f of frozen) { + const r = resourceOfRpcCode(String(f.type ?? "")) ?? "bandwidth"; + const amount = safeUnsignedBigInt(f.amount ?? 0); + // An unsafe JS number has already lost precision. Omit the summary instead of presenting a + // plausible but incorrect total; the raw account payload remains available in JSON mode. + if (amount === null) return ""; + sums.set(r, (sums.get(r) ?? 0n) + amount); + } + const total = RESOURCES.reduce((t, r) => t + (sums.get(r) ?? 0n), 0n); + if (total === 0n) return ""; + const parts = RESOURCES.map((r) => `${r} ${formatSun(sums.get(r) ?? 0n)}`).join(" + "); + return `${formatSun(total)} ${symbol} (${parts})`; +} + +function safeUnsignedBigInt(value: unknown): bigint | null { + if (typeof value === "bigint") return value >= 0n ? value : null; + if (typeof value === "number") { + return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null; + } + if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value); + return null; +} diff --git a/ts/src/adapters/inbound/cli/render/scalars.ts b/ts/src/adapters/inbound/cli/render/scalars.ts index 2e7d91777..f1391b3f6 100644 --- a/ts/src/adapters/inbound/cli/render/scalars.ts +++ b/ts/src/adapters/inbound/cli/render/scalars.ts @@ -82,6 +82,12 @@ export function formatWei(v: unknown): string { return formatAmount(v, 18); } +/** wei → gwei. Gas is quoted in gwei by every wallet and explorer, and by this CLI's own + * --max-fee / --priority-fee flags; wei would be nine zeros longer for the same number. */ +export function formatGwei(v: unknown): string { + return formatAmount(v, 9); +} + export function formatTime(v: unknown): string { const n = Number(v); if (!Number.isFinite(n) || n <= 0) return ""; diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index 9e1050a2a..a514058fd 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -59,7 +59,10 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { ]); // `tx broadcast --dry-run` resolves the full approval state to decide broadcastability; show // it rather than leaving text with a fee line while json carries permission and progress. - return r.transaction ? `${body}\n\n${renderApproval(r.transaction as TxApprovalView)}` : body; + if (r.transaction) return `${body}\n\n${renderApproval(r.transaction as TxApprovalView)}`; + // Families without an approval model (EVM) report which pre-broadcast checks actually ran — + // "skipped" is the row that matters, since a check that did not run proves nothing. + return r.checks?.length ? `${body}\n\n${renderChecks(r.checks)}` : body; } if (r.mode === "build-only") { return ( @@ -407,6 +410,12 @@ function receiptAmount(r: TxReceiptView, family: ChainFamily, symbol: string): s return ""; } +/** Pre-broadcast checks from a dry run, one row each. */ +function renderChecks(checks: NonNullable): string { + const mark = { ok: "✓", warning: "!", skipped: "–" } as const; + return ["Checks", ...checks.map((c) => ` ${mark[c.status]} ${c.name}: ${c.detail}`)].join("\n"); +} + /** human label for an action kind, e.g. "send" → "tx send" (for dry-run/sign-only headers). */ function actionLabel(kind: TxReceiptKind): string { switch (kind) { @@ -498,6 +507,11 @@ function formatFee(fee: unknown, family: ChainFamily, symbol: string): string { const covered = avail !== undefined && avail >= energy ? " (covered by staked energy)" : ""; return `~${energy.toLocaleString()} energy${covered}`; } + // EVM fee plan: gasLimit × the per-gas ceiling. It is the most this transaction CAN cost, + // not what it will, so it is labelled as a ceiling rather than quoted as a charge. + if (f.maxCostWei !== undefined) { + return `\u2264 ${FAMILY_RENDER[family].feeFallback(f.maxCostWei, symbol)}`; + } if (f.note) return String(f.note); // An unrecognised fee object must not reach feeFallback: that formats a scalar sun amount and // would stringify the object into "[object Object]". Saying "unknown" is honest, and it keeps diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts index bbc515926..32db4b542 100644 --- a/ts/src/adapters/inbound/cli/shell/index.ts +++ b/ts/src/adapters/inbound/cli/shell/index.ts @@ -20,6 +20,7 @@ import type { } from "../contracts/index.js"; import { CommandRegistry } from "../registry/index.js"; import { CapabilityRegistry } from "../../../../application/services/capability/index.js"; +import { barBroadcasts } from "../../../../application/services/broadcast-guard.js"; import { buildExecutionContext, type RuntimeDeps } from "../context/index.js"; import { TargetResolver } from "../../../../application/services/target/index.js"; import { OutputFormatter } from "../output/index.js"; @@ -283,7 +284,13 @@ async function executeChainCommand( const ctx = buildExecutionContext(globals, deps); if (spec.wallet !== "none") void ctx.activeAccount; - const data = await binding.run(ctx, net, input); + // --dry-run is declared on the shared spec but honoured by each family binding independently, + // so the promise is also enforced here: nothing reaches a Broadcaster for the duration. + const run = () => binding.run(ctx, net, input); + const data = + spec.broadcasts && (input as { dryRun?: unknown }).dryRun === true + ? await barBroadcasts(`${spec.path.join(" ")} --dry-run`, run) + : await run(); streams.result( formatter.success(id, net, data, spec.formatText, activeAccountLabel(spec, ctx, deps)), ); diff --git a/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts index 730edf1aa..7dc531b54 100644 --- a/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts +++ b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts @@ -15,6 +15,7 @@ import { AtomicFileStore } from "../../../outbound/persistence/fs/index.js"; import { Keystore } from "../../../outbound/keystore/index.js"; import { SecretResolver } from "../input/secret/index.js"; import { Prompter } from "../input/prompt/index.js"; +import { assertBroadcastAllowed } from "../../../../application/services/broadcast-guard.js"; describe("ChainCommandDefinition dispatch", () => { it("routes a positional through the selected family binding", async () => { @@ -225,3 +226,92 @@ describe("grouped chain leaf positionals", () => { }); }); }); + +/** + * The shell's half of the dry-run guarantee. + * + * A family binding is free to forget `--dry-run` — the flag lives on the shared spec and each + * binding decides what to forward — so the shell bars broadcasting for the duration of the run. + * The binding below is exactly the mistake being defended against: it ignores the flag and + * broadcasts anyway. + */ +describe("--dry-run bars broadcasting", () => { + function dryRunFixture(run: (input: any) => Promise) { + const tmpRoot = mkdtempSync(join(tmpdir(), "wallet-cli-dryrun-test-")); + const prompter = new Prompter({ + isTTY: () => false, + async question() { + return ""; + }, + async readKey() { + return { name: "return" }; + }, + write() {}, + beginRaw() {}, + endRaw() {}, + } as any); + const out: string[] = []; + const streams = new StreamManager("json", false, (s) => out.push(s)); + const secrets = new SecretResolver(streams, {}, prompter); + const keystore = new Keystore(tmpRoot, new AtomicFileStore(), () => secrets.masterPassword()); + const config = ConfigLoader.load(); + const networkRegistry = new NetworkRegistry(config); + const formatter = createOutputFormatter("json", streams, Date.now()); + const registry = new CommandRegistry(); + registry.addChain( + { + path: ["tx", "broadcast"], + network: "optional", + wallet: "none", + auth: "none", + broadcasts: true, + examples: [], + baseFields: z.object({ dryRun: z.boolean().default(false) }), + }, + "tron", + { run: async (_ctx: any, _net: any, input: any) => run(input) }, + ); + const globals = { output: "json" as const, verbose: false, network: "tron:mainnet" }; + const deps = { config, networkRegistry, streams, secrets, keystore, prompter, formatter }; + return { + out, + shellOpts: { + registry, + globals, + deps, + targetResolver: new TargetResolver({ networkRegistry, keystore }), + caps: new CapabilityRegistry(), + streams, + formatter, + session: {} as SessionRef, + } as ShellOptions, + }; + } + + it("stops a binding that ignores the flag and broadcasts anyway", async () => { + const submitted: string[] = []; + const { shellOpts } = dryRunFixture(async () => { + assertBroadcastAllowed(); + submitted.push("sent"); + return { stage: "submitted" }; + }); + + await expect(buildCli(shellOpts).parseAsync(["tx", "broadcast", "--dry-run"])).rejects.toMatchObject( + { code: "dry_run_violation" }, + ); + expect(submitted).toEqual([]); + }); + + it("leaves a real broadcast alone", async () => { + const submitted: string[] = []; + const { shellOpts } = dryRunFixture(async () => { + assertBroadcastAllowed(); + submitted.push("sent"); + return { stage: "submitted" }; + }); + + await buildCli(shellOpts).parseAsync(["tx", "broadcast"]); + + expect(submitted).toEqual(["sent"]); + }); +}); diff --git a/ts/src/adapters/outbound/chain/broadcast-guard-coverage.test.ts b/ts/src/adapters/outbound/chain/broadcast-guard-coverage.test.ts new file mode 100644 index 000000000..9266d4493 --- /dev/null +++ b/ts/src/adapters/outbound/chain/broadcast-guard-coverage.test.ts @@ -0,0 +1,60 @@ +/** + * Every family's submit path must consult the broadcast guard. + * + * `--dry-run` is declared once on a command's shared spec and honoured separately by each family + * binding, and nothing in the type system notices a binding that parses the flag and forwards + * only the fields it happens to care about. That is how the EVM `tx broadcast` binding came to + * submit real transactions under a flag documented as not submitting anything. + * + * The guard closes that class of bug — but only for the submit paths that actually ask it. A new + * family gateway that implements `broadcast` without the call would reopen the hole silently, so + * the requirement is checked here rather than left to review. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const CHAIN_DIR = dirname(fileURLToPath(import.meta.url)); + +/** Method names that put bytes on the wire; a new one belongs in this list. */ +const SUBMIT_METHOD = /\basync\s+(broadcast|broadcastHex|sendRawTransaction)\s*\(/g; + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return entry.isFile() && entry.name.endsWith(".ts") && !entry.name.includes(".test.") + ? [path] + : []; + }); +} + +describe("broadcast guard coverage", () => { + const files = sourceFiles(CHAIN_DIR); + + it("finds the family gateways it is meant to be checking", () => { + // A traversal that quietly matched nothing would pass every assertion below. + expect(files.some((f) => f.endsWith("evm/evm.ts"))).toBe(true); + expect(files.some((f) => f.endsWith("tron/tron.ts"))).toBe(true); + }); + + it("guards every submit path in every family gateway", () => { + const unguarded: string[] = []; + for (const file of files) { + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(SUBMIT_METHOD)) { + // The call must come before anything else the method does: a guard placed after the + // first await has already let a request go. + const body = source.slice(match.index + match[0].length, match.index + match[0].length + 400); + const guardAt = body.indexOf("assertBroadcastAllowed()"); + const awaitAt = body.indexOf("await "); + if (guardAt === -1 || (awaitAt !== -1 && awaitAt < guardAt)) { + unguarded.push(`${file}: ${match[1]}`); + } + } + } + + expect(unguarded).toEqual([]); + }); +}); diff --git a/ts/src/adapters/outbound/chain/evm/evm.test.ts b/ts/src/adapters/outbound/chain/evm/evm.test.ts index 9970613cb..c4ab92449 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.test.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, afterEach, vi } from "vitest"; +import { barBroadcasts } from "../../../../application/services/broadcast-guard.js"; import { Transaction } from "ethers"; import { EvmRpcClient } from "./evm.js"; @@ -405,6 +406,48 @@ describe("EvmRpcClient.estimateGas", () => { expect(gas).toBe("21000"); expect(seen[0]).toMatchObject({ method: "eth_estimateGas" }); }); + + /** + * QUANTITY fields must go out as `0x` hex. Everything above this port speaks decimal, and + * go-ethereum rejects a bare decimal while reth accepts it — so a decimal `value` against a + * load-balanced endpoint fails a fraction of requests and reads as a flaky network. + */ + it("hex-encodes decimal quantities before they reach the node", async () => { + const seen = stubRpc("0x5208"); + await new EvmRpcClient("https://node.example", 5_000).estimateGas({ + from: ADDR, + to: TOKEN, + value: "0", + nonce: 15, + maxFeePerGas: "2033933954", + data: "0xa9059cbb", + }); + + expect((seen[0] as any).params[0]).toEqual({ + from: ADDR, + to: TOKEN, + value: "0x0", + nonce: "0xf", + maxFeePerGas: "0x793b5e82", + // DATA, not QUANTITY: hex-encoding an address or calldata would be silent corruption. + data: "0xa9059cbb", + }); + }); + + it("leaves a value that is already hex untouched", async () => { + const seen = stubRpc("0x5208"); + await new EvmRpcClient("https://node.example", 5_000).estimateGas({ value: "0x1c" }); + + expect((seen[0] as any).params[0]).toEqual({ value: "0x1c" }); + }); + + it("reports a quantity field that is not a number rather than sending it", async () => { + stubRpc("0x5208"); + + await expect( + new EvmRpcClient("https://node.example", 5_000).estimateGas({ value: "lots" }), + ).rejects.toMatchObject({ code: "invalid_value" }); + }); }); /** @@ -439,6 +482,19 @@ describe("EvmRpcClient.sendRawTransaction", () => { expect(out).toEqual({ hash: HASH }); }); + // The guard is the backstop for a family binding that drops --dry-run; it has to sit in front + // of the wire call, not merely exist. + it("refuses to reach the wire while broadcasting is barred", async () => { + const seen = stubResponse({ result: HASH }); + + await barBroadcasts("tx broadcast --dry-run", async () => { + await expect( + new EvmRpcClient("https://node.example", 5_000).sendRawTransaction(RAW), + ).rejects.toMatchObject({ code: "dry_run_violation" }); + }); + expect(seen).toHaveLength(0); + }); + it("treats a result that is not a transaction hash as a rejection", async () => { stubResponse({ result: "ok" }); @@ -637,21 +693,65 @@ describe("EvmRpcClient contract-write encoding", () => { expect(data).toHaveLength(2 + 8 + 128); }); + const WORD = (n: bigint) => n.toString(16).padStart(64, "0"); + it("appends ABI-encoded constructor arguments to the bytecode", () => { - const abi = JSON.stringify([{ type: "constructor", inputs: [{ type: "uint256", name: "x" }] }]); - const data = client().encodeDeploy("0x6080", abi, [7]); + const abi = [{ type: "constructor", inputs: [{ type: "uint256", name: "x" }] }]; + const data = client().encodeDeploy("0x6080", { source: "abi", abi, values: [7] }); + + expect(data).toBe(`0x6080${WORD(7n)}`); + }); + + /** + * The defect this replaces: the EVM path encoded against an empty ABI, so ANY constructor + * argument failed with "expectedCount=0" and `--constructor-params` could not be used at all. + * Types now come from a signature when no ABI is available — the source `cast send --create` + * uses for the same situation. + */ + it("encodes from a constructor signature when there is no ABI", () => { + const data = client().encodeDeploy("0x6080", { + source: "signature", + signature: "constructor(uint256)", + values: [7], + flag: "--constructor-signature", + }); - expect(data).toBe(`0x6080${(7n).toString(16).padStart(64, "0")}`); + expect(data).toBe(`0x6080${WORD(7n)}`); + }); + + it.each(["constructor(uint256)", "(uint256)", "uint256"])( + "accepts the signature written as %s", + (signature) => { + const data = client().encodeDeploy("0x6080", { source: "signature", signature, values: [7], flag: "--x" }); + + expect(data).toBe(`0x6080${WORD(7n)}`); + }, + ); + + it("appends nothing when the constructor takes no arguments", () => { + expect(client().encodeDeploy("0x6080", { source: "none" })).toBe("0x6080"); }); it("accepts bare bytecode without a 0x prefix", () => { - const abi = JSON.stringify([{ type: "constructor", inputs: [] }]); - expect(client().encodeDeploy("6080", abi, [])).toBe("0x6080"); + expect(client().encodeDeploy("6080", { source: "none" })).toBe("0x6080"); }); it("rejects constructor arguments that do not match the ABI", () => { - const abi = JSON.stringify([{ type: "constructor", inputs: [{ type: "address", name: "a" }] }]); - expect(() => client().encodeDeploy("0x6080", abi, ["not-an-address"])).toThrow(); + const abi = [{ type: "constructor", inputs: [{ type: "address", name: "a" }] }]; + expect(() => + client().encodeDeploy("0x6080", { source: "abi", abi, values: ["not-an-address"] }), + ).toThrow(/the ABI/); + }); + + it("names the flag a bad signature came from", () => { + expect(() => + client().encodeDeploy("0x6080", { + source: "signature", + signature: "constructor(uint256)", + values: [1, 2], + flag: "--constructor-args", + }), + ).toThrow(/--constructor-args/); }); // CREATE derives the address from the sender and nonce alone, so it is known the moment the diff --git a/ts/src/adapters/outbound/chain/evm/evm.ts b/ts/src/adapters/outbound/chain/evm/evm.ts index dbd5617fa..f16bf5a5c 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.ts @@ -7,6 +7,7 @@ */ import { Interface, + type InterfaceAbi, Transaction, getCreateAddress, toUtf8String, @@ -14,7 +15,11 @@ import { } from "ethers"; import { ChainError } from "../../../../domain/errors/index.js"; import { classifyEvmRejection, isAlreadyKnown } from "./node-errors.js"; -import type { EvmGateway } from "../../../../application/ports/chain/gateway-provider.js"; +import type { + DeployConstructorArgs, + EvmGateway, +} from "../../../../application/ports/chain/gateway-provider.js"; +import { assertBroadcastAllowed } from "../../../../application/services/broadcast-guard.js"; interface JsonRpcResponse { result?: unknown; @@ -110,7 +115,7 @@ export class EvmRpcClient implements EvmGateway { /** the node's gas estimate for a transaction, as a decimal string. */ async estimateGas(tx: Record): Promise { - return toDecimalString(await this.#call("eth_estimateGas", [tx])); + return toDecimalString(await this.#call("eth_estimateGas", [toRpcQuantities(tx)])); } /** @@ -126,6 +131,7 @@ export class EvmRpcClient implements EvmGateway { * standing fact into an error. */ async sendRawTransaction(raw: string): Promise<{ hash?: string; alreadyKnown?: boolean }> { + assertBroadcastAllowed(); const body = await this.#send("eth_sendRawTransaction", [raw]); if (body.error) { const message = body.error.message ?? ""; @@ -212,6 +218,7 @@ export class EvmRpcClient implements EvmGateway { * (see `authoritativeTxId`), which is the whole reason the signer carries it. */ async broadcast(signed: unknown): Promise> { + assertBroadcastAllowed(); const raw = (signed as { raw?: unknown })?.raw; if (typeof raw !== "string" || raw === "") { throw new ChainError( @@ -263,18 +270,22 @@ export class EvmRpcClient implements EvmGateway { } /** deployment calldata: the creation bytecode with the constructor's ABI-encoded arguments. */ - encodeDeploy(bytecode: string, abiJson: string, params: unknown[]): string { - let encodedArgs = ""; + encodeDeploy(bytecode: string, args: DeployConstructorArgs): string { + const body = bytecode.trim().replace(/^0x/, ""); + if (args.source === "none") return `0x${body}`; try { - const iface = new Interface(JSON.parse(abiJson)); - encodedArgs = iface.encodeDeploy(params).replace(/^0x/, ""); + const iface = + args.source === "abi" + ? new Interface(args.abi as InterfaceAbi) + : new Interface([normalizeConstructorSignature(args.signature)]); + return `0x${body}${iface.encodeDeploy(args.values).replace(/^0x/, "")}`; } catch (e) { + const from = args.source === "abi" ? "the ABI" : `${args.flag}`; throw new ChainError( "invalid_value", - `could not encode the constructor arguments: ${(e as Error).message}`, + `could not encode the constructor arguments against ${from}: ${(e as Error).message}`, ); } - return `0x${bytecode.replace(/^0x/, "")}${encodedArgs}`; } /** @@ -431,6 +442,55 @@ export class EvmRpcClient implements EvmGateway { } } +/** Accept `constructor(uint256,string)`, `(uint256,string)` or a bare `uint256,string` — the + * three ways someone writes the same thing — and hand ethers the one form it parses. */ +function normalizeConstructorSignature(signature: string): string { + const s = signature.trim(); + if (s.startsWith("constructor")) return s; + return `constructor${s.startsWith("(") ? s : `(${s})`}`; +} + +/** + * The outbound half of the EIP-1474 split: a transaction object leaving for the node. + * + * Everything above this port speaks decimal (see the EvmGateway doc comment), and a QUANTITY on + * the wire must be `0x`-prefixed. Node clients disagree about enforcing it — go-ethereum rejects + * a bare decimal, reth accepts it — so a load-balanced endpoint fronting both fails a fraction of + * requests and looks like an unreliable network rather than a malformed one. + * + * The field list is explicit rather than "anything that parses as a number": `to`, `from` and + * `data` are DATA, and hex-encoding an address would be silent corruption. + */ +const RPC_QUANTITY_FIELDS = [ + "value", + "gas", + "gasLimit", + "gasPrice", + "maxFeePerGas", + "maxPriorityFeePerGas", + "maxFeePerBlobGas", + "nonce", +] as const; + +function toRpcQuantities(tx: Record): Record { + const out: Record = { ...tx }; + for (const field of RPC_QUANTITY_FIELDS) { + const value = out[field]; + if (value === undefined || value === null) continue; + // Already hex (or something this function has no business rewriting) — leave it alone. + if (typeof value === "string" && value.startsWith("0x")) continue; + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") { + continue; + } + try { + out[field] = `0x${BigInt(value).toString(16)}`; + } catch { + throw new ChainError("invalid_value", `${field} is not a quantity: ${String(value)}`); + } + } + return out; +} + /** * 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/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 10500b45c..22935d30f 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -17,6 +17,7 @@ import type { } from "../../../../domain/types/index.js"; import type { RpcResourceCode } from "../../../../domain/resources/index.js"; import type { Broadcaster } from "../../../../application/ports/chain/broadcaster.js"; +import { assertBroadcastAllowed } from "../../../../application/services/broadcast-guard.js"; import type { DecodedTronTransaction, TronContractParameter, @@ -89,6 +90,7 @@ export class TronRpcClient implements TronGateway, Broadcaster { return account.balance ?? "0"; } async broadcast(signed: SignedTx): Promise { + assertBroadcastAllowed(); let res: Types.BroadcastReturn; try { // bound the RPC so a standalone `tx broadcast` (not routed through the pipeline) can't hang. @@ -308,6 +310,7 @@ export class TronRpcClient implements TronGateway, Broadcaster { } async broadcastHex(input: string): Promise { + assertBroadcastAllowed(); const hex = normalizeTransactionHex(input); decodeTransactionHex(hex); const response = await this.#wrap("broadcast hex", () => this.#tw.trx.sendHexTransaction(hex)); diff --git a/ts/src/application/ports/chain/gateway-provider.ts b/ts/src/application/ports/chain/gateway-provider.ts index 6c131fc65..ef3b275fe 100644 --- a/ts/src/application/ports/chain/gateway-provider.ts +++ b/ts/src/application/ports/chain/gateway-provider.ts @@ -37,7 +37,7 @@ export interface EvmGateway extends NativeBalanceReader, Broadcaster { /** calldata for a `{type, value}` call, encoded without sending it. */ encodeFunctionCall(signature: string, params: Array<{ type: string; value: unknown }>): string; /** deployment calldata: creation bytecode plus the constructor's ABI-encoded arguments. */ - encodeDeploy(bytecode: string, abiJson: string, params: unknown[]): string; + encodeDeploy(bytecode: string, args: DeployConstructorArgs): string; /** where a CREATE deployment will land, from the sender and nonce alone. */ contractAddressFor(from: string, nonce: string): string; /** calldata for an ERC-20 `transfer`; the amount is already in the token's base units. */ @@ -64,6 +64,24 @@ export interface EvmGateway extends NativeBalanceReader, Broadcaster { getErc20Metadata(contract: string): Promise<{ symbol?: string; decimals?: number; name?: string }>; } +/** + * How a deployment's constructor arguments are typed. + * + * The types never come from the values. They come from the compiler's own ABI when one is + * available, and otherwise from a signature the caller states explicitly — the same two sources + * `forge create` and `cast send --create` use. A mistyped argument encodes cleanly and deploys a + * contract built from the wrong arguments, and a deployment cannot be taken back, so the + * authoritative source is preferred and the fallback is an explicit declaration rather than a + * guess made from the shape of the values. + * + * `flag` names the option the signature came from, so an encoding failure can point at the thing + * the caller actually typed. + */ +export type DeployConstructorArgs = + | { source: "none" } + | { source: "abi"; abi: unknown; values: unknown[] } + | { source: "signature"; signature: string; values: unknown[]; flag: string }; + /** Family-keyed extension point. Add each new family gateway here without widening other ports. */ export interface ChainGatewayMap { tron: TronGateway; diff --git a/ts/src/application/services/broadcast-guard.test.ts b/ts/src/application/services/broadcast-guard.test.ts new file mode 100644 index 000000000..185c33fa9 --- /dev/null +++ b/ts/src/application/services/broadcast-guard.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { assertBroadcastAllowed, barBroadcasts } from "./broadcast-guard.js"; + +describe("broadcast guard", () => { + it("allows broadcasting outside a barred section", () => { + expect(() => assertBroadcastAllowed()).not.toThrow(); + }); + + it("rejects a broadcast attempted inside a barred section", async () => { + await barBroadcasts("tx broadcast --dry-run", async () => { + expect(() => assertBroadcastAllowed()).toThrowError(/dry run|reached the broadcast path/i); + }); + }); + + it("names the caller so the report says which command misbehaved", async () => { + await barBroadcasts("tx broadcast --dry-run", async () => { + try { + assertBroadcastAllowed(); + expect.unreachable("the guard should have thrown"); + } catch (e) { + expect((e as Error).message).toContain("tx broadcast --dry-run"); + expect((e as { code?: string }).code).toBe("dry_run_violation"); + } + }); + }); + + // A bar that outlived its section would turn every later broadcast in the same process into a + // false bug report — the failure mode of a guard is that it fires when it should not. + it("lifts the bar once the section returns", async () => { + await barBroadcasts("tx broadcast --dry-run", async () => {}); + expect(() => assertBroadcastAllowed()).not.toThrow(); + }); + + it("lifts the bar when the section throws", async () => { + await expect( + barBroadcasts("tx broadcast --dry-run", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(() => assertBroadcastAllowed()).not.toThrow(); + }); +}); diff --git a/ts/src/application/services/broadcast-guard.ts b/ts/src/application/services/broadcast-guard.ts new file mode 100644 index 000000000..bc65537c9 --- /dev/null +++ b/ts/src/application/services/broadcast-guard.ts @@ -0,0 +1,37 @@ +/** + * Broadcast guard — the structural backstop behind `--dry-run`. + * + * `--dry-run` is declared once, on a command's shared spec, but honoured separately by each + * family binding. Nothing in the type system notices a binding that parses the flag and then + * forwards only the fields it cares about, so a family can silently broadcast under a flag whose + * documented promise is that it will not. That is not hypothetical: the EVM `tx broadcast` + * binding dropped `dryRun` and submitted real transactions. + * + * So the promise is enforced where it can actually be kept: the shell bars broadcasting for the + * duration of a dry run, and every Broadcaster implementation asks before it reaches the wire. + * A binding that forgets the flag now fails loudly on a bug-report error instead of spending + * someone's funds. The bar is process-wide because one CLI invocation runs one command; it is an + * assertion about a mistake, never a control-flow mechanism a command should rely on. + */ +import { ExecutionError } from "../../domain/errors/index.js"; + +let barred: string | undefined; + +/** Run `fn` with broadcasting barred. `reason` names the caller, for the bug report. */ +export async function barBroadcasts(reason: string, fn: () => Promise): Promise { + barred = reason; + try { + return await fn(); + } finally { + barred = undefined; + } +} + +/** Called by every Broadcaster before it submits. Throws when a dry run reached the wire. */ +export function assertBroadcastAllowed(): void { + if (barred === undefined) return; + throw new ExecutionError( + "dry_run_violation", + `${barred} reached the broadcast path; nothing was submitted. This is a bug in the command's family binding, not in your input — please report it.`, + ); +} diff --git a/ts/src/application/services/evm-gas-estimate.test.ts b/ts/src/application/services/evm-gas-estimate.test.ts new file mode 100644 index 000000000..663a175f3 --- /dev/null +++ b/ts/src/application/services/evm-gas-estimate.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveGasLimit } from "./evm-gas-estimate.js"; + +describe("resolveGasLimit", () => { + it("returns the node's estimate", async () => { + const gateway = { estimateGas: vi.fn(async () => "45223") }; + + await expect(resolveGasLimit(gateway, { from: "0xabc" })).resolves.toBe("45223"); + }); + + it("takes --gas-limit without contacting the node", async () => { + const gateway = { estimateGas: vi.fn(async () => "45223") }; + + await expect(resolveGasLimit(gateway, { from: "0xabc" }, "90000")).resolves.toBe("90000"); + expect(gateway.estimateGas).not.toHaveBeenCalled(); + }); + + /** + * The regression this exists for: the estimate used to be swallowed and replaced with 21000 — + * the intrinsic cost of a plain value transfer — so an ERC-20 transfer was signed with a gas + * limit that cannot execute it, and the failure surfaced only at broadcast. + */ + it("never substitutes a guess for a failed estimate", async () => { + const gateway = { + estimateGas: vi.fn(async () => { + throw new Error("insufficient funds for transfer"); + }), + }; + + const error = await resolveGasLimit(gateway, { from: "0xabc" }).catch((e) => e); + + expect(error).toMatchObject({ code: "invalid_option" }); + expect(error.message).not.toContain("21000"); + }); + + it("carries the node's own words, which are the useful part", async () => { + const gateway = { + estimateGas: vi.fn(async () => { + throw new Error("execution reverted: ERC20: transfer amount exceeds balance"); + }), + }; + + await expect(resolveGasLimit(gateway, { from: "0xabc" })).rejects.toThrowError( + /transfer amount exceeds balance/, + ); + }); + + it("points at the way out", async () => { + const gateway = { + estimateGas: vi.fn(async () => { + throw new Error("nope"); + }), + }; + + await expect(resolveGasLimit(gateway, {})).rejects.toThrowError(/--gas-limit/); + }); +}); diff --git a/ts/src/application/services/evm-gas-estimate.ts b/ts/src/application/services/evm-gas-estimate.ts new file mode 100644 index 000000000..b619fbfd1 --- /dev/null +++ b/ts/src/application/services/evm-gas-estimate.ts @@ -0,0 +1,38 @@ +/** + * Resolving an EVM gas limit — one place, because there is one correct answer to "the node would + * not estimate this". + * + * The estimate used to be wrapped in `.catch(() => undefined)`, with `tx send` falling back to + * 21000 and `contract send` reporting a bare "could not estimate". Both hid the node's reply, and + * 21000 is the intrinsic cost of a plain value transfer — for anything carrying calldata it is a + * transaction that cannot succeed, signed and reported as if it could. + * + * A failed estimate is almost always the node telling you something true: the call reverts, the + * account cannot cover it, the contract is not what you think. That message is the useful part, + * so it is carried through rather than replaced by a guess. + */ +import { UsageError } from "../../domain/errors/index.js"; + +interface GasEstimator { + estimateGas(tx: Record): Promise; +} + +/** + * `override` (from `--gas-limit`) wins without contacting the node — it is the documented way to + * proceed when an estimate is impossible, and asking anyway would fail for a value nobody uses. + */ +export async function resolveGasLimit( + gateway: GasEstimator, + request: Record, + override?: string, +): Promise { + if (override !== undefined) return override; + try { + return await gateway.estimateGas(request); + } catch (e) { + throw new UsageError( + "invalid_option", + `the node could not estimate gas for this transaction; pass --gas-limit to proceed. The node said: ${(e as Error).message}`, + ); + } +} 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 110d97537..ff8040877 100644 --- a/ts/src/application/use-cases/evm/contract-service.test.ts +++ b/ts/src/application/use-cases/evm/contract-service.test.ts @@ -138,11 +138,9 @@ describe("EvmContractService.send", () => { }); describe("EvmContractService.deploy", () => { - const ABI = JSON.stringify([{ type: "constructor", inputs: [] }]); - it("builds a transaction with no recipient", async () => { const { service, built } = writeHarness(); - await service.deploy(scope(), net, { abi: ABI, bytecode: "0x6080", params: [] } as never); + await service.deploy(scope(), net, { bytecode: "0x6080" } as never); expect(built[0]!.to).toBeUndefined(); expect(built[0]!.data).toBe("0xdeploydata"); @@ -151,20 +149,37 @@ describe("EvmContractService.deploy", () => { it("reports the CREATE address derived from sender and nonce", async () => { const { service, gateway } = writeHarness(); const out = (await service.deploy(scope(), net, { - abi: ABI, bytecode: "0x6080", - params: [], } as never)) as { contractAddress?: string }; expect(gateway.contractAddressFor).toHaveBeenCalledWith(OWNER, "9"); expect(out.contractAddress).toBe("0xDEPLOYED"); }); - it("refuses an ABI that is not JSON rather than deploying blind", async () => { - const { service } = writeHarness(); + /** + * The service decides nothing about how the arguments are typed — it forwards the resolved + * source to the gateway. The defect this replaces was exactly a decision made here: the service + * substituted an empty ABI (`input.abi ?? "[]"`) whenever none was supplied, which on EVM was + * always, so every constructor argument failed with "expectedCount=0". + */ + it("forwards the resolved constructor arguments to the encoder", async () => { + const { service, gateway } = writeHarness(); + const constructorArgs = { + source: "signature" as const, + signature: "constructor(uint256)", + values: [42], + flag: "--constructor-args", + }; + + await service.deploy(scope(), net, { bytecode: "0x6080", constructorArgs } as never); + + expect(gateway.encodeDeploy).toHaveBeenCalledWith("0x6080", constructorArgs); + }); + + it("says 'no arguments' rather than inventing an empty ABI when none were given", async () => { + const { service, gateway } = writeHarness(); + await service.deploy(scope(), net, { bytecode: "0x6080" } as never); - await expect( - service.deploy(scope(), net, { abi: "{not json", bytecode: "0x60" } as never), - ).rejects.toMatchObject({ code: "invalid_value" }); + expect(gateway.encodeDeploy).toHaveBeenCalledWith("0x6080", { source: "none" }); }); }); diff --git a/ts/src/application/use-cases/evm/contract-service.ts b/ts/src/application/use-cases/evm/contract-service.ts index 0c0ff42c8..78ce75680 100644 --- a/ts/src/application/use-cases/evm/contract-service.ts +++ b/ts/src/application/use-cases/evm/contract-service.ts @@ -1,11 +1,16 @@ import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index.js"; +import { resolveGasLimit } from "../../services/evm-gas-estimate.js"; import { UsageError } from "../../../domain/errors/index.js"; import { FAMILIES } from "../../../domain/family/index.js"; import { toBaseUnits } from "../../../domain/amounts/index.js"; import { planEvmFee } from "../../../domain/fees/evm-gas.js"; import { evmConfirmation } from "../../services/evm-confirmation.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; -import type { ChainGatewayProvider, EvmGateway } from "../../ports/chain/gateway-provider.js"; +import type { + ChainGatewayProvider, + DeployConstructorArgs, + EvmGateway, +} from "../../ports/chain/gateway-provider.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; import { outcomeData, @@ -21,8 +26,9 @@ export interface EvmContractWriteInput extends TransactionModeInput { params?: unknown[]; /** native coin sent along with the call, in whole coins (as `tx send --amount` is). */ callValue?: string; - abi?: string; bytecode?: string; + /** how the constructor's arguments are typed and what they are; see DeployConstructorArgs. */ + constructorArgs?: DeployConstructorArgs; gasLimit?: string; maxFee?: string; priorityFee?: string; @@ -99,14 +105,7 @@ export class EvmContractService { */ async deploy(scope: TransactionScope, network: NetworkDescriptor, input: EvmContractWriteInput) { const gateway = this.gateways.get(network, "evm"); - if (input.abi !== undefined) { - try { - JSON.parse(input.abi); - } catch { - throw new UsageError("invalid_value", "--abi must be valid JSON"); - } - } - const data = gateway.encodeDeploy(input.bytecode!, input.abi ?? "[]", input.params ?? []); + const data = gateway.encodeDeploy(input.bytecode!, input.constructorArgs ?? { source: "none" }); let contractAddress: string | undefined; const outcome = await this.#run( @@ -153,14 +152,7 @@ export class EvmContractService { gateway.feeData(), ]); onNonce?.(from, nonce); - const gasEstimate = - input.gasLimit ?? (await gateway.estimateGas({ from, ...call }).catch(() => undefined)); - if (gasEstimate === undefined) { - throw new UsageError( - "invalid_option", - "the node could not estimate gas for this call; pass --gas-limit to proceed", - ); - } + const gasEstimate = await resolveGasLimit(gateway, { from, ...call }, input.gasLimit); const resolved = planEvmFee({ ...fee, gasLimit: gasEstimate, 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 901012a63..620502e8f 100644 --- a/ts/src/application/use-cases/evm/transaction-service.test.ts +++ b/ts/src/application/use-cases/evm/transaction-service.test.ts @@ -46,6 +46,7 @@ function harness(over: Partial> = {}) { }), estimateGas: vi.fn(async () => (over.gasEstimate as string) ?? "21000"), encodeErc20Transfer: vi.fn(() => "0xa9059cbb-encoded"), + getErc20Metadata: vi.fn(async () => (over.metadata as object) ?? { symbol: "TKN", decimals: 6 }), }; const built: Record[] = []; const pipeline = { @@ -172,8 +173,11 @@ describe("EvmTransactionService.send — ERC-20 transfer", () => { expect(gateway.encodeErc20Transfer).toHaveBeenCalledWith(RECEIVER, "5000000"); }); + // This used to assert that a bare --contract ALWAYS failed, which is what the flag actually did + // — nothing resolved its decimals. The rule it should have been asserting is narrower: refuse + // when decimals cannot be established, from the book or from the contract itself. it("refuses a token transfer whose decimals it could not establish", async () => { - const { service } = harness(); + const { service } = harness({ metadata: {} }); await expect( service.send(scope(), SEPOLIA, { to: RECEIVER, contract: USDT, amount: "5" } as never), @@ -207,6 +211,142 @@ describe("EvmTransactionService.send — the transaction it hands over", () => { }); }); +/** + * `--contract` without the token in the address book. + * + * The flag is offered on EVM but nothing resolved its decimals: the inbound layer has no + * --decimals flag and only `--token ` consulted the book, so `--contract 0x… --amount N` + * always failed as token_metadata_unavailable — even for a contract whose decimals() answers. + * TRON has always asked the contract in this case; EVM now does the same. + */ +describe("EvmTransactionService.send — --contract without a book entry", () => { + it("asks the contract for its decimals and scales by them", async () => { + const { service, built, gateway } = harness({ + metadata: { symbol: "USDC", decimals: 6, name: "USD Coin" }, + }); + + const out = (await service.send(scope(), SEPOLIA, { + to: RECEIVER, + contract: USDT, + amount: "0.5", + dryRun: true, + } as never)) as Record; + + expect(gateway.getErc20Metadata).toHaveBeenCalledWith(USDT); + // 0.5 at six decimals — scaled by the TOKEN's decimals, never the chain's eighteen. + expect(out.rawAmount).toBe("500000"); + expect(out.decimals).toBe(6); + expect(out.symbol ?? out.token).toBe("USDC"); + expect(built[0]).toMatchObject({ to: USDT }); + }); + + it("refuses to guess when the contract does not answer decimals()", async () => { + const { service } = harness({ metadata: {} }); + + const error = await service + .send(scope(), SEPOLIA, { + to: RECEIVER, + contract: USDT, + amount: "0.5", + dryRun: true, + } as never) + .catch((e) => e); + + expect(error).toMatchObject({ code: "token_metadata_unavailable" }); + expect(error.message).toMatch(/--raw-amount|token add/); + }); + + it("does not need decimals at all for --raw-amount", async () => { + const { service, gateway } = harness({ metadata: {} }); + + const out = (await service.send(scope(), SEPOLIA, { + to: RECEIVER, + contract: USDT, + rawAmount: "500000", + dryRun: true, + } as never)) as Record; + + expect(out.rawAmount).toBe("500000"); + expect(gateway.getErc20Metadata).not.toHaveBeenCalled(); + }); +}); + +/** + * A failed gas estimate. + * + * It used to be swallowed and replaced with 21000, so an ERC-20 transfer was signed with the gas + * limit of a plain value transfer and failed at broadcast — or, on a node that accepts an + * under-limit transaction, on-chain with the fee burned. + */ +describe("EvmTransactionService.send — gas estimation", () => { + it("surfaces the node's refusal instead of signing a guess", async () => { + const gateway = { + getTransactionCount: vi.fn(async () => "5"), + feeData: vi.fn(async () => ({ baseFeeWei: "100", gasPriceWei: "110" })), + estimateGas: vi.fn(async () => { + throw new Error("insufficient funds for transfer"); + }), + encodeErc20Transfer: vi.fn(() => "0xa9059cbb-encoded"), + }; + const failing = new EvmTransactionService( + { get: () => gateway } as unknown as ChainGatewayProvider, + { effective: () => [] } as never, + { + assertCanSign: vi.fn(), + run: vi.fn(async (params: TxPipelineParams) => ({ + stage: "plan" as const, + tx: await params.build(OWNER), + fee: {}, + })), + } as unknown as TxPipeline, + { resolve: vi.fn(() => ({ address: RECEIVER })) } as never, + ); + + const error = await failing + .send(scope(), SEPOLIA, { to: RECEIVER, amount: "1", dryRun: true } as never) + .catch((e) => e); + + expect(error).toMatchObject({ code: "invalid_option" }); + expect(error.message).toMatch(/insufficient funds/); + expect(error.message).toMatch(/--gas-limit/); + }); + + it("takes --gas-limit as the way past a node that cannot estimate", async () => { + const gateway = { + getTransactionCount: vi.fn(async () => "5"), + feeData: vi.fn(async () => ({ baseFeeWei: "100", gasPriceWei: "110" })), + estimateGas: vi.fn(async () => { + throw new Error("execution reverted"); + }), + encodeErc20Transfer: vi.fn(() => "0xa9059cbb-encoded"), + }; + const built: unknown[] = []; + const service = new EvmTransactionService( + { get: () => gateway } as unknown as ChainGatewayProvider, + { effective: () => [] } as never, + { + assertCanSign: vi.fn(), + run: vi.fn(async (params: TxPipelineParams) => { + const tx = await params.build(OWNER); + built.push(tx); + return { stage: "plan" as const, tx, fee: {} }; + }), + } as unknown as TxPipeline, + { resolve: vi.fn(() => ({ address: RECEIVER })) } as never, + ); + + await service.send(scope(), SEPOLIA, { + to: RECEIVER, + amount: "1", + gasLimit: "90000", + dryRun: true, + } as never); + + expect(built[0]).toMatchObject({ gasLimit: "90000" }); + expect(gateway.estimateGas).not.toHaveBeenCalled(); + }); +}); + /** * `tx sign` and `tx broadcast` on EVM. * @@ -322,6 +462,145 @@ describe("EvmTransactionService.broadcast", () => { }); }); +/** + * `tx broadcast --dry-run`. + * + * The flag promises the transaction is validated and NOT submitted; on EVM it used to submit it, + * irreversibly. The first test below is the one that matters — everything else describes what a + * dry run is worth once it stops spending money. + */ +describe("EvmTransactionService.broadcast --dry-run", () => { + const SIGNED = + "0x02f87383aa36a780830f424084793b5e8282520894000000000000000000000000000000000000dead87038d7ea4c6800080c001a02958ee6a65975b5f6c2067d08704bc367375ee3fd54f1a0b4cbbc2643ab6b95ca0044e8cb5dea54b08c8b43b68a842e75e4f6627caa3911e4f9e5119ca12c01fc9"; + // What the fixture costs: nonce 0, value 1000000000000000 wei, gasLimit 21000 × + // maxFeePerGas 2033933954 = 42712613034000 wei. Read off the fixture, not chosen. + const MAX_COST = 21000n * 2033933954n; + const VALUE = 1000000000000000n; + // The same transaction signed at nonce 5, for the gap case (ethers' own signTransaction). + const SIGNED_NONCE_5 = + "0x02f87383aa36a705830f4240847936a08282520894000000000000000000000000000000000000dead87038d7ea4c6800080c001a0c6bd6e2d48486d0f3cfc0afe941906ed4d2b1e0ddf0d7c420ce309cb71de850da0343d3b1e4e2818e022ad953505750198158dcbd378a50046758b24aafad18c9b"; + + function dryHarness(node: Partial> = {}) { + const gateway = { + sendRawTransaction: vi.fn(async () => ({ hash: `0x${"cd".repeat(32)}` })), + getTransactionCount: vi.fn(async (_a: string, block?: string) => + block === "pending" ? "0" : "0", + ), + getNativeBalance: vi.fn(async () => String(VALUE + MAX_COST)), + ...node, + }; + const warn = vi.fn(); + const service = new EvmTransactionService( + { get: () => gateway } as unknown as ChainGatewayProvider, + { effective: () => [] } as never, + {} as never, + { resolve: vi.fn() } as never, + ); + return { service, gateway, warn, scope: () => ({ ...scope(), warn }) as never }; + } + + it("does not submit the transaction", async () => { + const { service, gateway, scope } = dryHarness(); + const out = (await service.broadcast(scope(), SEPOLIA, SIGNED, true)) as Record; + + expect(gateway.sendRawTransaction).not.toHaveBeenCalled(); + expect(out.mode).toBe("dry-run"); + expect(out.stage).toBeUndefined(); + }); + + it("reports the transaction it validated, without asking the node for its identity", async () => { + const { service, scope } = dryHarness(); + const out = (await service.broadcast(scope(), SEPOLIA, SIGNED, true)) as Record; + + expect(out.txId).toBe("0x6bfa290e4749ac903192c155d9b0f534ec9a8c8ab9dbb55bd155a91e3c0d7026"); + expect(out.rawAmount).toBe(String(VALUE)); + expect(out.fee).toMatchObject({ feeModel: "eip1559", maxCostWei: String(MAX_COST) }); + expect(out.checks).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "chainId", status: "ok" })]), + ); + }); + + it("rejects a transaction signed for another chain", async () => { + const { service, scope } = dryHarness(); + const mainnet = { ...SEPOLIA, id: "evm:1", chainId: "1" }; + + await expect(service.broadcast(scope(), mainnet as never, SIGNED, true)).rejects.toMatchObject({ + code: "chain_mismatch", + }); + }); + + it("rejects a nonce the account has already spent", async () => { + const { service, scope } = dryHarness({ + getTransactionCount: vi.fn(async () => "3"), + }); + + await expect(service.broadcast(scope(), SEPOLIA, SIGNED, true)).rejects.toMatchObject({ + code: "nonce_too_low", + }); + }); + + it("rejects a balance that cannot cover value plus the fee ceiling", async () => { + const { service, scope } = dryHarness({ + getNativeBalance: vi.fn(async () => String(VALUE + MAX_COST - 1n)), + }); + + await expect(service.broadcast(scope(), SEPOLIA, SIGNED, true)).rejects.toMatchObject({ + code: "insufficient_balance", + }); + }); + + // A gap is not a rejection: the transaction is valid and will be mined once the missing nonce + // arrives. Failing here would deny something that can still happen. + it("warns rather than fails when the nonce leaves a gap", async () => { + const { service, scope, warn } = dryHarness({ + getTransactionCount: vi.fn(async () => "2"), + getNativeBalance: vi.fn(async () => String(VALUE + 21000n * 2033623170n)), + }); + const out = (await service.broadcast( + scope(), + SEPOLIA, + SIGNED_NONCE_5, + true, + )) as Record; + + expect(out.mode).toBe("dry-run"); + expect(out.checks).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "nonce", status: "warning" })]), + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("gap")); + }); + + // A dry run that cannot reach a node is still worth more than no dry run — but it must not + // claim the checks it could not make. + it("degrades to the local checks when the node is unreachable", async () => { + const { service, scope, warn } = dryHarness({ + getTransactionCount: vi.fn(async () => { + throw new Error("connect ECONNREFUSED"); + }), + }); + const out = (await service.broadcast(scope(), SEPOLIA, SIGNED, true)) as Record; + + expect(out.mode).toBe("dry-run"); + expect(out.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "nonce", status: "skipped" }), + expect.objectContaining({ name: "balance", status: "skipped" }), + ]), + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("not checked")); + }); + + it("still refuses an unsigned transaction", async () => { + const { service, scope } = dryHarness(); + const unsigned = + "0x02f083aa36a780830f4240847944848282520894000000000000000000000000000000000000dead87038d7ea4c6800080c0"; + + await expect(service.broadcast(scope(), SEPOLIA, unsigned, true)).rejects.toMatchObject({ + code: "invalid_transaction", + }); + }); +}); + /** * `tx status` and `tx info`. * diff --git a/ts/src/application/use-cases/evm/transaction-service.ts b/ts/src/application/use-cases/evm/transaction-service.ts index a45c4c43e..8fb030bd3 100644 --- a/ts/src/application/use-cases/evm/transaction-service.ts +++ b/ts/src/application/use-cases/evm/transaction-service.ts @@ -14,6 +14,7 @@ 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 { resolveGasLimit } from "../../services/evm-gas-estimate.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"; @@ -27,6 +28,8 @@ import { type TransactionModeInput, } from "../../services/transaction-mode.js"; +type EvmTokenMetadata = Awaited>; + export interface EvmSendInput extends TransactionModeInput { to: string; token?: string; @@ -53,7 +56,7 @@ export class EvmTransactionService { if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "evm"); const gateway = this.gateways.get(network, "evm"); const recipient = this.recipients.resolve("evm", input.to); - const transfer = this.resolveTransfer(network.id, scope.activeAccount, input); + const transfer = await this.resolveTransfer(gateway, network.id, scope.activeAccount, input); // The plan is produced while building and read back by the estimate hook. It is held here // rather than attached to the transaction: --dry-run and --build-only echo that object @@ -100,7 +103,12 @@ export class EvmTransactionService { * 5_000_000 at six decimals, and using the native eighteen would overpay by a factor of a * trillion. `--raw-amount` is already in base units and is passed through untouched. */ - private resolveTransfer(networkId: string, account: AccountRef, input: EvmSendInput) { + private async resolveTransfer( + gateway: EvmGateway, + networkId: string, + account: AccountRef, + input: EvmSendInput, + ) { let contract = input.contract; let decimals = input.decimals; let symbol: string | undefined; @@ -125,10 +133,19 @@ export class EvmTransactionService { const native = FAMILIES.evm.nativeDecimals; return { contract, decimals, symbol, rawAmount: toBaseUnits(input.amount!, native, "amount") }; } + if (decimals === undefined) { + // `--contract` names a token that need not be in the address book, so the contract itself + // is asked — the same fallback the TRON side makes for a bare --contract. Scaling by a + // guessed decimals would move the wrong amount by orders of magnitude, so an unreadable + // contract is an error, never a default. + const meta = await gateway.getErc20Metadata(contract).catch(() => ({}) as EvmTokenMetadata); + decimals = meta.decimals; + if (symbol === undefined) symbol = meta.symbol; + } if (decimals === undefined) { throw new ExecutionError( "token_metadata_unavailable", - `could not establish decimals for ${contract}; add it with \`token add\` first`, + `could not establish decimals for ${contract}: it did not answer decimals() and is not in the address book. Add it with \`token add --contract ${contract}\`, or pass --raw-amount in base units.`, ); } return { @@ -182,10 +199,10 @@ export class EvmTransactionService { : Promise.resolve(String(input.nonce)), gateway.feeData(), ]); - const gasEstimate = - input.gasLimit ?? - (await gateway.estimateGas({ from, ...call }).catch(() => undefined)) ?? - "21000"; + // 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, @@ -242,12 +259,18 @@ export class EvmTransactionService { * transaction is a property of the transaction, and `authoritativeTxId` exists so a node cannot * name a different one for us to poll and quote back. */ - async broadcast(scope: TransactionScope, network: NetworkDescriptor, hex: string) { + async broadcast( + scope: TransactionScope, + network: NetworkDescriptor, + hex: string, + dryRun = false, + ) { const parsed = parseEvmTransaction(hex); if (parsed.signature === null) { throw new ChainError("invalid_transaction", "this transaction carries no signature"); } const gateway = this.gateways.get(network, "evm"); + if (dryRun) return this.#dryRunBroadcast(scope, network, gateway, parsed); const result = await gateway.sendRawTransaction(parsed.serialized); const txId = authoritativeTxId(parsed.hash ?? undefined, result.hash, (m) => scope.warn(m)); const submitted = { @@ -267,6 +290,115 @@ export class EvmTransactionService { return { ...submitted, stage: confirmed.failed ? ("failed" as const) : ("confirmed" as const), ...confirmed }; } + /** + * `tx broadcast --dry-run` — answer "would this go through?" without submitting it. + * + * TRON's dry run resolves the full approval state against the node, so this does the EVM + * equivalent rather than a bare parse: the three things that actually stop a signed EVM + * transaction are the wrong chain, a spent nonce and a balance that cannot cover value plus + * the fee ceiling. A blocker throws, so `--dry-run` exits non-zero on a transaction that would + * fail — the answer a script is asking for. + * + * The node reads are best-effort. An unreachable endpoint downgrades those checks to `skipped` + * with a warning instead of failing the command: a dry run that cannot reach a node is still + * worth more than no dry run, and reporting "cannot broadcast" would be a claim about the + * transaction that this code has not established. + */ + async #dryRunBroadcast( + scope: TransactionScope, + network: NetworkDescriptor, + gateway: EvmGateway, + parsed: Transaction, + ) { + const checks: Array<{ name: string; status: "ok" | "warning" | "skipped"; detail: string }> = [ + { name: "signature", status: "ok", detail: `recovers to ${parsed.from ?? "an unknown signer"}` }, + ]; + + // Local, and the cheapest way to catch a transaction signed for another chain: a replay of it + // here is impossible, so there is nothing to gain by asking a node first. + if (String(parsed.chainId) !== String(network.chainId)) { + throw new ChainError( + "chain_mismatch", + `this transaction is signed for chain ${parsed.chainId}, but ${network.id} is chain ${network.chainId}`, + ); + } + checks.push({ name: "chainId", status: "ok", detail: `matches ${network.id}` }); + + const from = parsed.from; + const perGasCeiling = parsed.maxFeePerGas ?? parsed.gasPrice ?? 0n; + const maxCostWei = parsed.gasLimit * perGasCeiling; + const fee = { + feeModel: parsed.maxFeePerGas === null ? "legacy" : "eip1559", + maxCostWei: maxCostWei.toString(), + gasLimit: parsed.gasLimit.toString(), + }; + + const state = + from === null + ? undefined + : await Promise.all([ + gateway.getTransactionCount(from, "latest"), + gateway.getTransactionCount(from, "pending"), + gateway.getNativeBalance(from), + ]).catch((e: unknown) => { + scope.warn( + `--dry-run: the node could not be reached, so nonce and balance were not checked (${(e as Error).message})`, + ); + return undefined; + }); + + if (state === undefined) { + checks.push({ name: "nonce", status: "skipped", detail: "the node was not reachable" }); + checks.push({ name: "balance", status: "skipped", detail: "the node was not reachable" }); + } else { + const [latest, pending, balance] = state; + if (parsed.nonce < Number(latest)) { + throw new ChainError( + "nonce_too_low", + `nonce ${parsed.nonce} is already used; the account is at ${latest}`, + ); + } + if (parsed.nonce > Number(pending)) { + checks.push({ + name: "nonce", + status: "warning", + detail: `${parsed.nonce} is ahead of the account's next nonce ${pending}; it stays queued until the gap is filled`, + }); + scope.warn( + `--dry-run: nonce ${parsed.nonce} leaves a gap after ${pending}; this transaction cannot be mined until the missing one is broadcast`, + ); + } else { + checks.push({ name: "nonce", status: "ok", detail: `${parsed.nonce} is the next to be mined` }); + } + + const required = parsed.value + maxCostWei; + if (BigInt(balance) < required) { + throw new ChainError( + "insufficient_balance", + `the account holds ${balance} wei but this transaction needs ${required} wei (value ${parsed.value} + fee ceiling ${maxCostWei})`, + ); + } + checks.push({ + name: "balance", + status: "ok", + detail: `${balance} wei covers the ${required} wei this transaction can cost`, + }); + } + + const txId = parsed.hash ?? undefined; + return { + kind: "broadcast" as const, + mode: "dry-run" as const, + ...(txId === undefined ? {} : { txId, hash: txId }), + ...(from === null ? {} : { address: from }), + ...(parsed.to === null ? {} : { to: parsed.to }), + rawAmount: parsed.value.toString(), + fee, + tx: JSON.parse(JSON.stringify(parsed.toJSON())) as UnsignedTx, + checks, + }; + } + /** * Confirmation state, in four kinds. * diff --git a/ts/src/bootstrap/families/evm.ts b/ts/src/bootstrap/families/evm.ts index 406062c4c..43e187a22 100644 --- a/ts/src/bootstrap/families/evm.ts +++ b/ts/src/bootstrap/families/evm.ts @@ -5,9 +5,10 @@ * `registerEvmChainCommands` binds the commands EVM can serve. Paths with no binding here still * refuse cleanly at dispatch (`family_mismatch`). * - * Only the signing commands are bound so far. They need nothing from the chain — the family - * difference is entirely inside `evmSignStrategy` — so they reuse the very same binding objects - * the TRON family registers. Everything else waits on the EVM gateway's JSON-RPC surface. + * Twenty-one commands are bound: the two signing commands (which need nothing from the chain — + * the family difference lives entirely inside `evmSignStrategy` — and so reuse the very binding + * objects the TRON family registers), plus the account, block, chain, tx, token and contract + * commands that sit on the JSON-RPC gateway. */ import { FAMILIES } from "../../domain/family/index.js"; import { evmSignStrategy } from "../../adapters/outbound/chain/evm/signing-strategy.js"; diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index fbf4df11e..34136f73d 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -222,11 +222,13 @@ export function registerTronChainCommands( const witness = new TronWitnessService(deps.gateways, deps.transactions); reg.addChain(blockSpec, "tron", blockTronBinding(new TronBlockService(deps.gateways))); - reg.addChain(accountActivateSpec, "tron", accountActivateTronBinding(account)); + // Registration order is what the group help lists, so these follow the §10.3 running order: + // the two-family read commands first, then the TRON-only ones. reg.addChain(accountBalanceSpec, "tron", accountBalanceBinding(deps.balances)); reg.addChain(accountInfoSpec, "tron", accountInfoTronBinding(account)); - reg.addChain(accountHistorySpec, "tron", accountHistoryTronBinding(account)); reg.addChain(accountPortfolioSpec, "tron", accountPortfolioTronBinding(account)); + reg.addChain(accountHistorySpec, "tron", accountHistoryTronBinding(account)); + reg.addChain(accountActivateSpec, "tron", accountActivateTronBinding(account)); reg.addChain(accountSetSpec, "tron", accountSetTronBinding(account)); reg.addChain(tokenBalanceSpec, "tron", tokenBalanceTronBinding(token)); reg.addChain(tokenInfoSpec, "tron", tokenInfoTronBinding(token)); @@ -241,14 +243,15 @@ export function registerTronChainCommands( "tron", txSignTronBinding(transaction, signing, multisig, new SecureTransactionArtifactWriter()), ); + reg.addChain(txBroadcastSpec, "tron", txBroadcastTronBinding(multisig)); + reg.addChain(txStatusSpec, "tron", txStatusTronBinding(transaction)); + reg.addChain(txInfoSpec, "tron", txInfoTronBinding(transaction)); + // TRON-only, so they sit at the end of the `tx` group listing (§10.3). reg.addChain(txApprovalsSpec, "tron", txApprovalsTronBinding(multisig)); reg.addChain(txTronLinkMultisigSpec, "tron", txTronLinkMultisigBinding(multisigCollaboration)); 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)); reg.addChain(permissionShowSpec, "tron", permissionShowTronBinding(permission)); reg.addChain(permissionUpdateSpec, "tron", permissionUpdateTronBinding(permission)); for (const definition of stakeDefinitions(stake)) { @@ -265,11 +268,12 @@ export function registerTronChainCommands( reg.addChain(voteStatusSpec, "tron", voteStatusTronBinding(vote)); reg.addChain(rewardBalanceSpec, "tron", rewardBalanceTronBinding(reward)); reg.addChain(rewardWithdrawSpec, "tron", rewardWithdrawTronBinding(reward)); + reg.addChain(chainNodeSpec, "tron", chainNodeTronBinding(chain)); + reg.addChain(chainPricesSpec, "tron", chainPricesTronBinding(chain)); + // `chain params` is TRON-only and goes last in the group listing (§10.3). for (const definition of chainDefinitions(chain)) { reg.addChain(definition.spec, "tron", definition.binding); } - reg.addChain(chainNodeSpec, "tron", chainNodeTronBinding(chain)); - reg.addChain(chainPricesSpec, "tron", chainPricesTronBinding(chain)); reg.addChain(contractCallSpec, "tron", contractCallTronBinding(contract)); reg.addChain(contractSendSpec, "tron", contractSendTronBinding(contract)); reg.addChain(contractDeploySpec, "tron", contractDeployTronBinding(contract)); diff --git a/ts/src/bootstrap/migration-gate.test.ts b/ts/src/bootstrap/migration-gate.test.ts index b46c82803..7357d0f0a 100644 --- a/ts/src/bootstrap/migration-gate.test.ts +++ b/ts/src/bootstrap/migration-gate.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { AtomicFileStore } from "../adapters/outbound/persistence/fs/index.js"; import { MigrationRunner, type MigrationStep } from "../adapters/outbound/persistence/migration.js"; import { runMigrationGate } from "./migration-gate.js"; +import { upgradeNotice } from "./runner.js"; import { CliError } from "../domain/errors/index.js"; function stalePasswordStep(path: string, needsPassword: boolean): MigrationStep { @@ -27,7 +28,7 @@ describe("runMigrationGate", () => { const wallets = join(seededRoot(), "wallets.json"); const runner = new MigrationRunner(new AtomicFileStore()); - const error = await runMigrationGate(runner, [stalePasswordStep(wallets, true)], async () => null) + const error = await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { password: async () => null }) .then(() => null) .catch((e: unknown) => e as CliError); @@ -42,7 +43,7 @@ describe("runMigrationGate", () => { const runner = new MigrationRunner(new AtomicFileStore()); const obtain = vi.fn(async () => "should-not-be-asked"); - await runMigrationGate(runner, [stalePasswordStep(wallets, false)], obtain); + await runMigrationGate(runner, [stalePasswordStep(wallets, false)], { password: obtain }); expect(obtain).not.toHaveBeenCalled(); expect(JSON.parse(readFileSync(wallets, "utf8")).version).toBe(2); @@ -52,7 +53,7 @@ describe("runMigrationGate", () => { const wallets = join(seededRoot(), "wallets.json"); const runner = new MigrationRunner(new AtomicFileStore()); - await runMigrationGate(runner, [stalePasswordStep(wallets, true)], async () => "hunter2"); + await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { password: async () => "hunter2" }); expect(JSON.parse(readFileSync(wallets, "utf8")).sawPassword).toBe("hunter2"); }); @@ -64,9 +65,145 @@ describe("runMigrationGate", () => { const runner = new MigrationRunner(new AtomicFileStore()); const obtain = vi.fn(async () => "nope"); - await runMigrationGate(runner, [stalePasswordStep(wallets, true)], obtain); + await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { password: obtain }); expect(obtain).not.toHaveBeenCalled(); expect(JSON.parse(readFileSync(wallets, "utf8"))).toEqual({ version: 2, wallets: [] }); }); }); + +/** + * Consent. The gate rewrites the user's wallet file and needs their master password to do it, so + * in a terminal it must SAY so and take an answer first. Before this it did neither: the whole + * upgrade surfaced as a bare "Master password (hidden):" prompt with no explanation, no mention + * that a file was about to be rewritten, and no way to decline except Ctrl+C. + */ +describe("runMigrationGate consent", () => { + it("asks before touching anything, and asks before asking for the password", async () => { + const wallets = join(seededRoot(), "wallets.json"); + const runner = new MigrationRunner(new AtomicFileStore()); + const order: string[] = []; + + await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { + confirm: async () => { + order.push("confirm"); + return true; + }, + password: async () => { + order.push("password"); + return "hunter2"; + }, + }); + + expect(order).toEqual(["confirm", "password"]); + expect(JSON.parse(readFileSync(wallets, "utf8")).version).toBe(2); + }); + + it("declining leaves the file untouched and never asks for the password", async () => { + const wallets = join(seededRoot(), "wallets.json"); + const runner = new MigrationRunner(new AtomicFileStore()); + const password = vi.fn(async () => "hunter2"); + + const error = await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { + confirm: async () => false, + password, + }) + .then(() => null) + .catch((e: unknown) => e as CliError); + + expect(error?.code).toBe("migration_required"); + expect(error?.exitCode()).toBe(2); + expect(password).not.toHaveBeenCalled(); + expect(JSON.parse(readFileSync(wallets, "utf8"))).toEqual({ version: 1, wallets: [] }); + }); + + it("tells a user who declined how to proceed", async () => { + const wallets = join(seededRoot(), "wallets.json"); + const runner = new MigrationRunner(new AtomicFileStore()); + + const error = await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { + confirm: async () => false, + password: async () => "hunter2", + }).catch((e: unknown) => e as CliError); + + expect(error?.message).toMatch(/declined/i); + expect(error?.message).toMatch(/--password-stdin/); + }); + + it("does not ask consent for a secretless upgrade — ADR-0008 keeps that silent", async () => { + const wallets = join(seededRoot(), "wallets.json"); + const runner = new MigrationRunner(new AtomicFileStore()); + const confirm = vi.fn(async () => true); + + await runMigrationGate(runner, [stalePasswordStep(wallets, false)], { + confirm, + password: async () => null, + }); + + expect(confirm).not.toHaveBeenCalled(); + expect(JSON.parse(readFileSync(wallets, "utf8")).version).toBe(2); + }); + + it("asks nothing at all when every file is current", async () => { + const dir = mkdtempSync(join(tmpdir(), "gate-")); + const wallets = join(dir, "wallets.json"); + writeFileSync(wallets, JSON.stringify({ version: 2, wallets: [] })); + const confirm = vi.fn(async () => true); + + await runMigrationGate(new MigrationRunner(new AtomicFileStore()), [stalePasswordStep(wallets, true)], { + confirm, + password: async () => null, + }); + + expect(confirm).not.toHaveBeenCalled(); + }); + + it("describes what will change, so the caller can show it", async () => { + const wallets = join(seededRoot(), "wallets.json"); + const runner = new MigrationRunner(new AtomicFileStore()); + let seen: { path: string; from: number; to: number; backup: string }[] = []; + + await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { + confirm: async (files) => { + seen = files; + return true; + }, + password: async () => "hunter2", + }); + + expect(seen).toEqual([ + { path: wallets, from: 1, to: 2, backup: `${wallets}.v1.bak` }, + ]); + }); +}); + +describe("the upgrade notice", () => { + const notice = () => + upgradeNotice([ + { path: "/home/u/.wallet-cli/wallets.json", from: 1, to: 2, backup: "/home/u/.wallet-cli/wallets.json.v1.bak" }, + ]).join("\n"); + + it("names the file and shows the version change", () => { + expect(notice()).toContain("/home/u/.wallet-cli/wallets.json"); + expect(notice()).toMatch(/v1\s*→\s*v2/); + }); + + it("explains that the upgrade is required before commands can run", () => { + expect(notice()).toMatch(/must be upgraded/); + expect(notice()).toMatch(/before any command can run/); + }); + + it("explains where the backup is kept", () => { + expect(notice()).toContain("wallets.json.v1.bak"); + expect(notice()).toMatch(/never removed automatically/); + expect(notice()).toMatch(/runs once/); + }); + + it("links to the release details", () => { + expect(notice()).toContain("https://github.com/tronprotocol/wallet-cli/releases"); + }); + + it("does not expose implementation details", () => { + expect(notice()).not.toMatch(/EVM address|master password|decrypt|seed|leaves this machine/i); + }); +}); diff --git a/ts/src/bootstrap/migration-gate.ts b/ts/src/bootstrap/migration-gate.ts index e93c02a73..3cd3b3aa1 100644 --- a/ts/src/bootstrap/migration-gate.ts +++ b/ts/src/bootstrap/migration-gate.ts @@ -4,17 +4,50 @@ * * The gate is absolute: while a registered file lags this binary, no command runs. That is what * lets `ChainAddresses` stay total instead of degrading to a partial map everywhere. + * + * Consent: rewriting someone's wallet file and decrypting their seed to do it is not something to + * spring on them. When the upgrade needs the master password, the gate explains what will change + * and takes an answer BEFORE asking for the password. Previously the entire upgrade surfaced as a + * bare "Master password (hidden):" prompt — no reason given, no mention that a file was about to + * be rewritten, and no way to say no except Ctrl+C. + * + * A secretless upgrade (ledger / watch only) stays silent, as ADR-0008 requires: there is nothing + * to decrypt, nothing to ask for, and no cost to weigh. */ import { UsageError } from "../domain/errors/index.js"; -import type { MigrationRunner, MigrationStep } from "../adapters/outbound/persistence/migration.js"; +import { + backupPathFor, + type MigrationRunner, + type MigrationStep, + type StaleFile, +} from "../adapters/outbound/persistence/migration.js"; + +/** One file the upgrade will rewrite, in the terms a user needs to weigh it. */ +export interface PendingUpgrade { + path: string; + from: number; + to: number; + /** where the pre-upgrade copy is kept; never removed automatically. */ + backup: string; +} -/** Yields the master password, or null when none can be obtained (no TTY and no --password-stdin). */ -export type PasswordSource = () => Promise; +export interface MigrationPrompt { + /** + * Explain the pending upgrade and return the user's answer. Called ONLY when the upgrade needs + * the master password, and always before `password()`. + * + * Non-interactive callers return true: there is no one to ask, and `password()` then produces + * the `migration_required` error on its own. + */ + confirm?(pending: PendingUpgrade[]): Promise; + /** The master password, or null when none can be obtained (no TTY and no --password-stdin). */ + password(): Promise; +} export async function runMigrationGate( runner: MigrationRunner, steps: MigrationStep[], - obtainPassword: PasswordSource, + prompt: MigrationPrompt, ): Promise { // No early exit for "nothing stale" is needed: planMigrations only aggregates needsPassword // over stale files, and apply() no-ops on an empty set. Mutation testing proved the guard dead. @@ -22,7 +55,14 @@ export async function runMigrationGate( let password: string | undefined; if (plan.needsPassword) { - const supplied = await obtainPassword(); + if (prompt.confirm && !(await prompt.confirm(plan.stale.map(pendingUpgrade)))) { + throw new UsageError( + "migration_required", + "upgrade declined; this version cannot run against a wallet file from an older one. " + + "Re-run any command and answer yes, or pipe the master password with --password-stdin", + ); + } + const supplied = await prompt.password(); if (supplied === null) { throw new UsageError( "migration_required", @@ -35,3 +75,12 @@ export async function runMigrationGate( runner.apply(plan.stale, password); } + +function pendingUpgrade(file: StaleFile): PendingUpgrade { + return { + path: file.step.path, + from: file.storedVersion, + to: file.step.currentVersion, + backup: backupPathFor(file.step.path, file.storedVersion), + }; +} diff --git a/ts/src/bootstrap/migration-wiring.test.ts b/ts/src/bootstrap/migration-wiring.test.ts index dc41e2107..818ed9d11 100644 --- a/ts/src/bootstrap/migration-wiring.test.ts +++ b/ts/src/bootstrap/migration-wiring.test.ts @@ -46,6 +46,25 @@ const v1PrivateKeyDoc = { wallets: [{ id: "wlt_k", source: { type: "privateKey", keyId: "key_1", addresses: { tron: TRON_ADDR } } }], }; +/** + * Ledger is the case that matters most here: a real, signing-capable account that holds no local + * secret. Such a user may never have set a master password at all — `import ledger` / `import + * watch` do not ask for one, and a keystore file is never written — so a gate that demanded one + * would leave them with nothing to type and no way in. The upgrade must be silent, not merely + * quiet. + */ +const v1LedgerDoc = { + version: 1, + activeAccount: "wlt_l", + labels: { wlt_l: "nano" }, + wallets: [ + { + id: "wlt_l", + source: { type: "ledger", family: "tron", path: "m/44'/195'/0'/0/0", address: TRON_ADDR }, + }, + ], +}; + // watch and ledger hold no secret anywhere, so this keystore migrates with no prompt at all. const v1WatchDoc = { version: 1, @@ -94,6 +113,26 @@ describe("the startup migration gate is wired into main()", () => { expect(JSON.parse(readFileSync(`${walletsPath}.v1.bak`, "utf8"))).toEqual(v1WatchDoc); }); + it("migrates a Ledger-only keystore silently and runs the command", async () => { + const { code, walletsPath } = await runIn(v1LedgerDoc, ["-o", "json", "list"]); + + expect(code).toBe(0); + expect(JSON.parse(readFileSync(walletsPath, "utf8")).version).toBe(2); + }); + + it("needs the password once ANY wallet in the file holds a secret", async () => { + // needsPassword is per FILE, not per wallet: one seed alongside a Ledger account still means + // the file cannot be rewritten without decrypting something. + const mixed = { + ...v1LedgerDoc, + wallets: [...v1LedgerDoc.wallets, ...v1SeedDoc.wallets], + }; + const { code, stdout } = await runIn(mixed, ["-o", "json", "list"]); + + expect(JSON.parse(stdout).error.code).toBe("migration_required"); + expect(code).toBe(2); + }); + it("leaves --help reachable on a stale keystore", async () => { const { code } = await runIn(v1SeedDoc, ["--help"]); expect(code).toBe(0); diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index 610f4e41f..3c06ce885 100644 --- a/ts/src/bootstrap/runner.ts +++ b/ts/src/bootstrap/runner.ts @@ -1,4 +1,4 @@ -import { runMigrationGate } from "./migration-gate.js"; +import { runMigrationGate, type PendingUpgrade } from "./migration-gate.js"; import { migrationSteps } from "./migration-steps.js"; import { MigrationRunner } from "../adapters/outbound/persistence/migration.js"; import { hideBin } from "yargs/helpers"; @@ -77,11 +77,25 @@ export async function main(argv: string[]): Promise { await runMigrationGate( new MigrationRunner(runtime.store), migrationSteps(runtime.root, runtime.store), - async () => { - const { secrets, keystore, prompter } = runtime.deps; - if (!secrets.hasMasterPassword() && !prompter.isTTY()) return null; - await secrets.primePassword({ mode: "verify", verify: (pw) => keystore.verifyPassword(pw) }); - return secrets.masterPassword(); + { + confirm: async (pending) => { + const { secrets, prompter } = runtime.deps; + // --password-stdin already stated the intent, and there is no one to ask anyway. + if (secrets.hasMasterPassword()) return true; + // No terminal: fall through so password() raises migration_required, unchanged. + if (!prompter.isTTY()) return true; + for (const line of upgradeNotice(pending)) runtime.streams.diagnostic("info", line); + return prompter.confirm({ label: "Upgrade now?" }); + }, + password: async () => { + const { secrets, keystore, prompter } = runtime.deps; + if (!secrets.hasMasterPassword() && !prompter.isTTY()) return null; + await secrets.primePassword({ + mode: "verify", + verify: (pw) => keystore.verifyPassword(pw), + }); + return secrets.masterPassword(); + }, }, ); @@ -112,3 +126,19 @@ export async function main(argv: string[]): Promise { runtime.prompter.close(); } } + +/** Goes to stderr at `info`, so stdout stays reserved for command output. */ +export function upgradeNotice(pending: PendingUpgrade[]): string[] { + return [ + "", + "This wallet was created by an earlier version of wallet-cli and must be upgraded", + "before any command can run.", + "", + ...pending.map((f) => ` ${f.path} v${f.from} \u2192 v${f.to}`), + "", + ...pending.map((f) => `A copy of the current file is kept at\n ${f.backup}\nand is never removed automatically. The upgrade runs once.`), + "", + "Release details: https://github.com/tronprotocol/wallet-cli/releases", + "", + ]; +} diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index d82932303..cb37d23de 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -156,6 +156,8 @@ export interface TxReceiptView { hex?: string; transaction?: import("./multisig.js").TxApprovalView; multiSignFeeSun?: number; + /** pre-broadcast checks a dry run ran; a blocker throws, so these are what held or was skipped. */ + checks?: Array<{ name: string; status: "ok" | "warning" | "skipped"; detail: string }>; // transfer / stake inputs rawAmount?: string; amountSun?: string | number; diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 9931d6c5f..d7c1fe335 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -77,7 +77,7 @@ describe("golden CLI — meta & introspection", () => { it("root --help shows the TRON first-release command surface", () => { const r = run(["--help"], { password: null }); expect(r.status).toBe(0); - expect(r.stdout).toContain("wallet-cli — CLI wallet for TRON."); + expect(r.stdout).toContain("wallet-cli — CLI wallet for TRON and EVM networks."); expect(r.stdout).toContain("Usage: wallet-cli [OPTIONS] COMMAND"); expect(r.stdout).toContain("Common Commands:"); expect(r.stdout).toContain("Management Commands:"); @@ -92,7 +92,9 @@ describe("golden CLI — meta & introspection", () => { 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)"); + // The root row names the command, not its flags (§10.1: root descriptions are verb + // summaries). `--qr` stays discoverable one level down, in `current --help`. + expect(r.stdout).toMatch(/^ current\s+Show the current \(active\) account$/m); 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); @@ -334,7 +336,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 TRX or TRC20/TRC10 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)", () => { diff --git a/ts/test/unknown-command.test.ts b/ts/test/unknown-command.test.ts new file mode 100644 index 000000000..27507afb5 --- /dev/null +++ b/ts/test/unknown-command.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DETACHED } from "./detached.js"; + +const ENTRY = join(process.cwd(), "src", "index.ts"); + +let HOME: string; +beforeEach(() => { + HOME = mkdtempSync(join(tmpdir(), "wcli-unknown-")); +}); + +function run(args: string[]) { + const env = { ...process.env, WALLET_CLI_HOME: HOME } as Record; + delete env.MASTER_PASSWORD; + const r = spawnSync(process.execPath, ["--import", "tsx", ENTRY, ...args], { + encoding: "utf8", + env, + timeout: 18_000, + ...DETACHED, + } as SpawnSyncOptionsWithStringEncoding); + let json: any; + try { + json = JSON.parse(r.stdout); + } catch { + /* not json */ + } + return { stdout: r.stdout, stderr: r.stderr, status: r.status, json }; +} + +/** + * A mistyped command must fail the same way whether or not `--help` is on the line. + * + * Dispatch already got this right (`unknown_command`, exit 2). The meta path did not: any token + * matching --help / --json-schema short-circuited into HelpService, which fell back to the ROOT + * listing and returned 0. So `wallet-cli tx snd --help` printed a plausible page and reported + * success — for an agent-first CLI that is the worst possible answer, because the caller has + * nothing to branch on and a full help page that looks like it answered the question. + */ +describe("unknown commands fail identically with and without --help", () => { + const unknown: string[][] = [["bogus"], ["tx", "bogus"], ["account", "bogus"], ["contract", "nope"]]; + + it("exits 2 with unknown_command for a bad path (no meta flag)", () => { + for (const path of unknown) { + const r = run(path); + expect(r.status, path.join(" ")).toBe(2); + expect(r.stderr, path.join(" ")).toContain("unknown_command"); + } + }); + + it("exits 2 with unknown_command for the same path plus --help", () => { + for (const path of unknown) { + const r = run([...path, "--help"]); + expect(r.status, path.join(" ")).toBe(2); + expect(r.stderr, path.join(" ")).toContain("unknown_command"); + // and it must NOT hand back a help page as if it had understood + expect(r.stdout, path.join(" ")).not.toContain("Usage:"); + } + }); + + it("exits 2 for the same path plus --json-schema, instead of dumping the full catalog", () => { + for (const path of unknown) { + const r = run([...path, "--json-schema"]); + expect(r.status, path.join(" ")).toBe(2); + expect(r.json?.commands, path.join(" ")).toBeUndefined(); + } + }); + + it("names the path the user actually typed", () => { + const r = run(["tx", "bogus", "--help"]); + expect(r.stderr).toContain("tx bogus"); + }); + + it("reports the failure through the JSON envelope under -o json", () => { + const r = run(["-o", "json", "tx", "bogus", "--help"]); + expect(r.status).toBe(2); + expect(r.json?.ok ?? r.json?.success).toBe(false); + expect(JSON.stringify(r.json)).toContain("unknown_command"); + }); + + /** + * Appending --help to a command you were already typing is the most common way anyone reaches + * help, and the line still carries its arguments. `metaPositionals` only knows which GLOBAL + * flags take a value, so a command flag's value (`--to T...` → "T...") stays in the path — as + * do real positionals. The longest prefix that names a command wins; the rest are arguments. + */ + it("serves the command's own help when arguments are still on the line", () => { + for (const [args, usage] of [ + [["tx", "send", "--to", "T...", "--help"], "wallet-cli tx send"], + [["block", "123", "--help"], "wallet-cli block"], + [["contract", "clear-abi", "TQ5...", "--help"], "wallet-cli contract clear-abi"], + [["token", "add", "--contract", "TR7...", "--help"], "wallet-cli token add"], + ] as [string[], string][]) { + const r = run(args); + expect(r.status, args.join(" ")).toBe(0); + expect(r.stdout, args.join(" ")).toContain(usage); + } + }); + + // ...but a prefix that is only a GROUP must not rescue a bad verb: `tx` is not a command, + // so `tx bogus` has no resolvable prefix and stays an error. + it("does not let a group prefix mask a mistyped verb", () => { + for (const args of [["tx", "bogus", "--help"], ["account", "bogus", "--help"]]) { + const r = run(args); + expect(r.status, args.join(" ")).toBe(2); + expect(r.stderr, args.join(" ")).toContain("unknown_command"); + } + }); + + // The paths that legitimately return a listing must keep doing so. + it("still serves root, group and leaf help", () => { + for (const args of [ + ["--help"], + ["tx", "--help"], + ["import", "--help"], + ["tx", "send", "--help"], + ["block", "--help"], + ]) { + const r = run(args); + expect(r.status, args.join(" ")).toBe(0); + expect(r.stdout, args.join(" ")).toContain("Usage:"); + } + }); + + it("still serves the machine catalog and per-command schema", () => { + expect(run(["--json-schema"]).status).toBe(0); + expect(run(["--json-schema"]).json?.commands?.length).toBeGreaterThan(0); + expect(run(["tx", "send", "--json-schema"]).status).toBe(0); + expect(run(["--version"]).status).toBe(0); + }); +}); From 559017ce26e9efed1ed8557732b23ed8748be46d Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 25 Aug 2026 02:38:45 +0800 Subject: [PATCH 03/23] feat: requirement alignment --- .../adapters/inbound/cli/commands/account.ts | 8 + .../cli/commands/contract.deploy.test.ts | 12 +- .../adapters/inbound/cli/commands/contract.ts | 63 ++++-- .../adapters/inbound/cli/commands/network.ts | 16 +- ts/src/adapters/inbound/cli/commands/token.ts | 11 + ts/src/adapters/inbound/cli/commands/tx.ts | 8 +- .../adapters/inbound/cli/commands/wallet.ts | 41 +++- ts/src/adapters/inbound/cli/render/account.ts | 2 +- .../inbound/cli/render/block-render.test.ts | 40 ++++ ts/src/adapters/inbound/cli/render/chain.ts | 35 +-- .../inbound/cli/render/family-render.test.ts | 125 ++++++++++- ts/src/adapters/inbound/cli/render/family.ts | 202 +++++++++++++++++- ts/src/adapters/inbound/cli/render/misc.ts | 25 ++- ts/src/adapters/inbound/cli/render/tx.ts | 37 +++- ts/src/adapters/inbound/cli/render/wallet.ts | 12 +- ts/src/adapters/inbound/cli/shell/index.ts | 7 +- .../adapters/outbound/chain/evm/evm.test.ts | 90 ++++++++ ts/src/adapters/outbound/chain/evm/evm.ts | 82 +++++-- .../chain/tron/tron.token-info.test.ts | 25 ++- ts/src/adapters/outbound/chain/tron/tron.ts | 37 +++- ts/src/adapters/outbound/config/builtins.ts | 4 + ts/src/adapters/outbound/keystore/index.ts | 18 +- .../outbound/keystore/keystore.test.ts | 6 +- .../adapters/outbound/price/coingecko.test.ts | 37 ++-- ts/src/adapters/outbound/price/coingecko.ts | 26 +-- ts/src/adapters/outbound/price/index.ts | 41 +++- ts/src/adapters/outbound/price/price.test.ts | 31 ++- ts/src/application/ports/backup-records.ts | 15 +- .../ports/chain/gateway-provider.ts | 2 + .../services/approve-receipt.test.ts | 90 ++++++++ .../application/services/approve-receipt.ts | 70 ++++++ ts/src/application/services/confirmations.ts | 24 +++ .../application/services/evm-confirmation.ts | 3 + .../services/recipient-resolver.test.ts | 50 ++++- .../services/recipient-resolver.ts | 34 ++- .../application/use-cases/config-service.ts | 11 +- .../use-cases/evm/account-service.test.ts | 15 +- .../use-cases/evm/account-service.ts | 10 +- .../use-cases/evm/chain-service.test.ts | 57 ++++- .../use-cases/evm/chain-service.ts | 39 +++- .../use-cases/evm/contract-service.test.ts | 104 +++++++++ .../use-cases/evm/contract-service.ts | 47 +++- .../use-cases/evm/token-service.test.ts | 21 ++ .../use-cases/evm/token-service.ts | 14 ++ .../use-cases/evm/transaction-service.test.ts | 133 +++++++++++- .../use-cases/evm/transaction-service.ts | 117 +++++++++- .../use-cases/tron/chain-service.test.ts | 17 +- .../use-cases/tron/chain-service.ts | 7 +- .../use-cases/tron/contract-service.ts | 17 ++ .../tron/transaction-service.status.test.ts | 56 ++++- .../use-cases/tron/transaction-service.ts | 46 +++- .../use-cases/wallet-service.keystore.test.ts | 43 ++++ .../application/use-cases/wallet-service.ts | 60 +++++- ts/src/bootstrap/composition.ts | 12 +- ts/src/domain/address/address.test.ts | 36 ++++ ts/src/domain/address/index.ts | 17 ++ ts/src/domain/contact/contact.test.ts | 20 ++ ts/src/domain/contact/index.ts | 6 +- ts/src/domain/family/index.ts | 12 ++ ts/src/domain/fees/evm-gas.test.ts | 40 ++++ ts/src/domain/fees/evm-gas.ts | 26 ++- ts/src/domain/types/network.ts | 28 +++ ts/src/domain/types/tx.ts | 37 ++++ ts/src/domain/wallet/index.ts | 25 ++- ts/test/golden.test.ts | 141 +++++++++++- 65 files changed, 2288 insertions(+), 255 deletions(-) create mode 100644 ts/src/application/services/approve-receipt.test.ts create mode 100644 ts/src/application/services/approve-receipt.ts create mode 100644 ts/src/application/services/confirmations.ts diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts index 1a777f645..12ec865dc 100644 --- a/ts/src/adapters/inbound/cli/commands/account.ts +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -111,6 +111,8 @@ export const accountBalanceSpec: ChainSpec = { auth: "none", capability: "account.balance.native", summary: "Show the native coin balance", + // Which coin, and how much of it, depend entirely on the selected network (§4.1). + description: "Show the native coin balance for the selected network", baseFields: z.object({}), examples: [ { cmd: "wallet-cli account balance --network nile" }, @@ -131,6 +133,12 @@ export const accountInfoSpec: ChainSpec = { wallet: "optional", auth: "none", summary: "Show the account's on-chain state", + // §4.3: the field SETS differ by family — not the same fields with different values — so the + // help says which fields to expect rather than leaving the reader to discover it. + description: + "Show the account's on-chain state for the selected network. Fields differ by\n" + + "family: TRON reports staked amounts, resources and permissions; EVM reports the\n" + + "transaction nonce and whether the address holds code.", baseFields: z.object({}), examples: [ { cmd: "wallet-cli account info --network nile" }, diff --git a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts index 50f2085c6..23574e41c 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts @@ -4,6 +4,7 @@ import { contractDeploySpec, contractDeployTronBinding, contractSendEvmBinding, + contractSendSpec, } from "./contract.js"; import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; @@ -229,9 +230,16 @@ describe("contract deploy — EVM flag surface", () => { expect(keys).toEqual(expect.arrayContaining(["gasLimit", "maxFee", "priorityFee", "nonce"])); }); - it("keeps --call-value on contract send, which does apply it", () => { - expect(Object.keys(contractSendEvmBinding({} as never).fields?.shape ?? {})).toContain( + /** + * The call value moved to the SHARED spec as `--value` (§7.2, 2026-08-24 ruling): the concept + * is the same on every chain, so it is one flag with one unit rather than a per-family name. + * `contract deploy` still offers none — its value is always zero. + */ + it("takes its call value from the shared --value, not a family flag", () => { + expect(Object.keys(contractSendEvmBinding({} as never).fields?.shape ?? {})).not.toContain( "callValue", ); + expect(Object.keys(contractSendSpec.baseFields?.shape ?? {})).toContain("value"); + expect(Object.keys(contractDeploySpec.baseFields?.shape ?? {})).not.toContain("value"); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index d289484cd..73b43a5b2 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -8,6 +8,8 @@ import type { EvmContractService } from "../../../../application/use-cases/evm/c import type { TronContractParameter } from "../../../../application/ports/chain/tron-gateway.js"; import { Schemas, addressFieldsFor, allRefines } from "../schemas/index.js"; import { gweiToWei } from "../../../../domain/fees/evm-gas.js"; +import { toBaseUnits } from "../../../../domain/amounts/index.js"; +import { FAMILIES } from "../../../../domain/family/index.js"; import { governanceTxModeFields, governanceTxRefine, tronTxModeFields, txModeFields } from "./shared.js"; import { TextFormatters } from "../render/index.js"; @@ -124,6 +126,11 @@ export const contractCallSpec: ChainSpec = { auth: "none", capability: "contract.call", summary: "Read-only contract call", + // §7.1: no ABI is fetched — the caller supplies the types. Without this the reader has no way + // to know why a signature is required, or why the result comes back undecoded. + description: + "Read-only contract call. The function signature and parameter types are supplied\n" + + "explicitly; no ABI lookup is performed.", baseFields: callFields, examples: [ { @@ -155,6 +162,16 @@ const sendFields = z.object({ .string() .optional() .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), + // Family-neutral and in WHOLE COINS, like `tx send --amount` (§7.2). The concept — native coin + // attached to a call — is the same on every chain, so it gets one flag and one unit; the unit + // in `--call-value-sun`'s name is what made it unusable off TRON. + // Zero is a legitimate call value (it is the default), so this is not the transfer amount's + // "must be greater than zero" schema. + value: z + .string() + .regex(/^\d+(\.\d+)?$/, "must be a non-negative decimal string") + .optional() + .describe("native coin sent with the call, in whole coins"), ...txModeFields, buildOnly: z .boolean() @@ -167,8 +184,8 @@ const sendFields = z.object({ /** TRON prices a contract call in SUN and burns energy up to a fee limit; both flag names say so. */ const tronContractWriteFields = z.object({ callValueSun: Schemas.uintString() - .default("0") - .describe("native TRX attached to the call, in SUN"), + .optional() + .describe("deprecated alias for --value, in SUN; removed next release"), feeLimit: Schemas.positiveIntString() .default("100000000") .describe("maximum energy fee to burn, in SUN"), @@ -191,17 +208,11 @@ const evmGasFields = z.object({ .describe("transaction nonce; defaults to the account's pending nonce"), }); -/** `contract send` additionally takes a call value; `contract deploy` does not — a deployment's - * value is always zero here, and offering a flag the command ignores is worse than omitting it. */ -const evmContractWriteFields = evmGasFields.extend({ - callValue: z - .string() - .optional() - .describe("native coin to attach to the call, in whole coins (e.g. 0.1)"), -}); - +/** `contract send`'s call value is the shared `--value` (§7.2); `contract deploy` has none — a + * deployment's value is always zero here, and offering a flag the command ignores is worse than + * omitting it. */ const evmContractWrite = { - fields: evmContractWriteFields, + fields: evmGasFields, refine: addressFieldsFor("evm", "contract"), }; @@ -239,7 +250,11 @@ export const contractSendSpec: ChainSpec = { export const contractSendEvmBinding = (svc: EvmContractService): FamilyBinding => ({ ...evmContractWrite, run: async (ctx, net, input) => - svc.send(ctx, net, { ...withEvmFees(input), params: typedParams(input.params) }), + svc.send(ctx, net, { + ...withEvmFees(input), + callValue: input.value, + params: typedParams(input.params), + }), }); /** the creation bytecode, from `--code` or `--code-file`. */ @@ -376,10 +391,32 @@ export const contractSendTronBinding = (svc: TronContractService): FamilyBinding run: async (ctx, net, input) => svc.send(ctx, net, { ...input, + callValueSun: tronCallValueSun(input), parameters: typedParams(input.params), }), }); +/** + * The call value in SUN, from either flag. + * + * `--value` is the family-neutral form and takes whole TRX; `--call-value-sun` is the old + * TRON-only spelling, kept working for one release. Both at once is refused rather than + * silently preferring one — they can disagree, and picking a winner would move an amount of + * money the caller did not ask for. + */ +function tronCallValueSun(input: { value?: string; callValueSun?: string }): string { + if (input.value !== undefined && input.callValueSun !== undefined) { + throw new UsageError( + "invalid_option", + "--value and --call-value-sun set the same thing; pass only --value (--call-value-sun is deprecated)", + ); + } + if (input.value !== undefined) { + return toBaseUnits(input.value, FAMILIES.tron.nativeDecimals, "call value"); + } + return input.callValueSun ?? "0"; +} + const deployFields = z.object({ artifact: z .string() diff --git a/ts/src/adapters/inbound/cli/commands/network.ts b/ts/src/adapters/inbound/cli/commands/network.ts index b396a0fdc..2f3fea3e9 100644 --- a/ts/src/adapters/inbound/cli/commands/network.ts +++ b/ts/src/adapters/inbound/cli/commands/network.ts @@ -5,15 +5,7 @@ import { z } from "zod"; import type { CommandDefinition } from "../contracts/index.js"; import { CommandRegistry } from "../registry/index.js"; import { TextFormatters } from "../render/index.js"; - -function endpointHost(url: string | undefined): string { - if (!url) return ""; - try { - return new URL(url).host; - } catch { - return ""; - } -} +import { endpointHost } from "../../../../domain/types/index.js"; export function registerNetworkCommands(reg: CommandRegistry): void { const empty = z.object({}); @@ -25,6 +17,12 @@ export function registerNetworkCommands(reg: CommandRegistry): void { wallet: "none", auth: "none", summary: "List known networks", + // §2.3, adjusted to the six-column table this actually prints: the canonical id and the + // alias are separate columns, so the description names both rather than only one. + description: + "List known networks with their family, chain id, fee model and endpoint host.\n" + + "Network is the canonical id (family:chain-id); Alias is the short name --network\n" + + "also accepts. Endpoints are shown as hosts only.", fields: empty, input: empty, examples: [{ cmd: "wallet-cli networks" }], diff --git a/ts/src/adapters/inbound/cli/commands/token.ts b/ts/src/adapters/inbound/cli/commands/token.ts index f2a05a1c7..09fcddfea 100644 --- a/ts/src/adapters/inbound/cli/commands/token.ts +++ b/ts/src/adapters/inbound/cli/commands/token.ts @@ -106,6 +106,11 @@ export const tokenAddSpec: ChainSpec = { auth: "none", capability: "token.tokenbook", summary: "Add a token to the address book", + // §5.3: the book is PER NETWORK, and the metadata comes from the contract — the one moment + // decimals are checked against the chain (see the token service). + description: + "Add a token to the address book of the selected network, fetching its name,\n" + + "symbol and decimals from the contract", baseFields: selectorFields, examples: [ { cmd: "wallet-cli token add --contract TR7... --network nile" }, @@ -126,6 +131,8 @@ export const tokenListSpec: ChainSpec = { auth: "none", capability: "token.tokenbook", summary: "List the address book", + // §5.4: which book depends on the network, and it holds two layers. + description: "List the address book of the selected network (official + user entries)", baseFields: z.object({}), examples: [ { cmd: "wallet-cli token list --network nile" }, @@ -146,6 +153,10 @@ export const tokenRemoveSpec: ChainSpec = { auth: "none", capability: "token.tokenbook", summary: "Remove a user-added token", + // §5.5: the refusal on an official entry is a rule worth stating before it is hit. + description: + "Remove a user-added token from the address book. Official entries cannot be\n" + + "removed.", baseFields: selectorFields, examples: [ { cmd: "wallet-cli token remove --contract TR7... --network nile" }, diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index 691067180..fe9196fbc 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -172,6 +172,9 @@ export const txBroadcastSpec: ChainSpec = { broadcasts: true, capability: "tx.broadcast", summary: "Broadcast a presigned transaction", + description: + "Broadcast an already-signed transaction. It must have been built for the network you\n" + + "select — one built for another chain is rejected before it is sent.", baseFields: broadcastFields, exclusive: [ { @@ -280,12 +283,11 @@ export const txSignSpec: ChainSpec = { broadcasts: false, capability: "tx.sign", summary: "Sign a transaction built elsewhere", - // NOTE: the §6.2 spec block also promises "one built for another chain is rejected before it - // is signed". That check (`chain_id_mismatch`) is NOT implemented yet, so the sentence is - // deliberately absent — help must not promise a guard the code does not enforce. description: "Sign a transaction that was built elsewhere and output the signed result; broadcast it\n" + "later with `tx broadcast`. This command never broadcasts.\n" + + "The transaction must have been built for the network you select — one built for another\n" + + "chain is rejected before it is signed, so you cannot sign a mainnet transaction by mistake.\n" + "On TRON, --hex/--file append one signature while preserving any already collected,\n" + "checking online that this account is in the transaction's permission group and has not\n" + "already signed, and reporting the resulting approval weight; --offline skips those checks.\n" + diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index 8f97c2d78..1401f070a 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -35,21 +35,26 @@ const LEDGER_APPS = CHAIN_FAMILIES.map((f) => LEDGER_APP_BY_FAMILY[f]).filter( ) as [string, ...string[]]; export const walletImportLedgerFields = z.object({ app: ciEnum(LEDGER_APPS).describe( - "Ledger app to open on the device, selecting the address-derivation scheme", + // §3.6: the value is the app to open ON THE DEVICE, and what it selects is the account's + // chain family — "address-derivation scheme" named the mechanism instead of the choice. + "Ledger app to open on the device; selects the chain family", ), index: z.coerce .number() .int() .nonnegative() .optional() + // NOT a zod .default(): §3.6 asks for the default in the `[optional, default: 0]` tag, but a + // parsed default makes `index` always present, and the locator rule below counts PRESENCE — + // `--path` alone would then read as two locators. The default stays in the description. .describe( - "HD account index to import; omit with no --path/--address to use index 0; mutually exclusive with --path and --address", + "account index under the app's default path; omit with no --path/--address to use index 0; mutually exclusive with --path and --address", ), path: z .string() .optional() .describe( - "explicit BIP32 derivation path, e.g. m/44'/195'/0'/0/0 for TRON; mutually exclusive with --index and --address", + "explicit derivation path; mutually exclusive with --index and --address", ), address: z .string() @@ -62,9 +67,9 @@ export const walletImportLedgerFields = z.object({ .int() .positive() .optional() - .describe( - "number of account indexes to scan when using --address, in indexes; omit to scan 20 indexes", - ), + // The default lives in the service (DEFAULT_SCAN_LIMIT); stating it here too would be a + // second copy to drift. + .describe("how many indexes to scan when using --address; omit to scan 20"), label: Schemas.label() .optional() .describe("human-friendly unique account label, 1-64 chars; omit to auto-generate"), @@ -156,6 +161,11 @@ export function registerWalletCommands( interactive: true, promptHints: { label: "default-label" }, summary: "Create a new HD wallet (BIP39 seed)", + // §3.1: this release's headline is "one seed, an address per family" — and this is the + // command that performs it, so its help has to say so. + description: + "Create a new HD wallet (BIP39 seed). Derives one address per chain family\n" + + "from the same seed; the recovery phrase is encrypted locally and never printed.", fields: createFields, input: createFields, examples: [{ cmd: "wallet-cli create --label main" }], @@ -222,7 +232,10 @@ export function registerWalletCommands( summary: "Import a raw private key", description: "Import a raw private key. The private key and master password are read\n" + - "interactively from the TTY (hidden input); they never touch argv or stdin.", + "interactively from the TTY (hidden input); they never touch argv or stdin.\n" + + // §3.3 — and the fact that separates this from a seed import: ONE key, every family, + // so the two addresses are two encodings of the same secret rather than two secrets. + "One key yields an address on every chain family.", fields: importPrivateKeyFields, input: importPrivateKeyFields, examples: [{ cmd: "wallet-cli import private-key --label hot" }], @@ -305,7 +318,10 @@ export function registerWalletCommands( summary: "Register a Ledger account", fields: walletImportLedgerFields, input: walletImportLedgerInput, - examples: [{ cmd: "wallet-cli import ledger --app tron --index 0 --label cold" }], + examples: [ + { cmd: "wallet-cli import ledger --app tron --index 0 --label cold" }, + { cmd: "wallet-cli import ledger --app ethereum --index 0 --label cold-evm" }, + ], formatText: TextFormatters.walletLedger, run: async (ctx, _net, input) => { const family: ChainFamily = FAMILY_BY_LEDGER_APP[input.app]!; @@ -338,6 +354,11 @@ export function registerWalletCommands( interactive: true, promptHints: { label: "default-label" }, summary: "Register a watch-only address", + // §3.5: the family is inferred from the address, and that is what limits where the account + // can be used — neither fact is guessable from "register a watch-only address". + description: + "Register a watch-only address (no secret). The chain family is detected from the\n" + + "address format; the account is usable only on networks of that family.", fields: importWatchFields, input: importWatchFields, examples: [{ cmd: "wallet-cli import watch --address T... --label team-vault" }], @@ -520,6 +541,10 @@ export function registerWalletCommands( wallet: "none", auth: "required", summary: "Derive the next HD account from a seed wallet (by --seed-id)", + // §3.9, minus its `--path` sentence (that flag is not in this release — see ADR-0009). + description: + "Derive the next HD account from a seed wallet (by --seed-id). Each family uses\n" + + "its own BIP44 template, so one derive yields an address per family.", fields: addAccountFields, input: addAccountFields, examples: [{ cmd: "wallet-cli derive --seed-id wlt_ab12cd34" }], diff --git a/ts/src/adapters/inbound/cli/render/account.ts b/ts/src/adapters/inbound/cli/render/account.ts index 84911cd4f..3ae0638dc 100644 --- a/ts/src/adapters/inbound/cli/render/account.ts +++ b/ts/src/adapters/inbound/cli/render/account.ts @@ -115,7 +115,7 @@ export const AccountFormatters = { * `account info` — family-shaped. * * TRON returns the node's account object (permissions, resources, stakes); EVM has no equivalent - * RPC and returns a flat `{balance, nonce, isContract}`. These are not the same field set with + * RPC and returns a flat `{balance, nonce, type, codeSize?}`. These are not the same field set with * different values, so the rows come from the family table rather than from one formatter reading * whichever keys happen to be present — the TRON reader applied to an EVM payload found nothing * and printed "Balance 0 TRX" for an account holding ETH. diff --git a/ts/src/adapters/inbound/cli/render/block-render.test.ts b/ts/src/adapters/inbound/cli/render/block-render.test.ts index 48660d1b0..295eed4ad 100644 --- a/ts/src/adapters/inbound/cli/render/block-render.test.ts +++ b/ts/src/adapters/inbound/cli/render/block-render.test.ts @@ -25,6 +25,10 @@ const EVM_BLOCK = { number: "0x12d687", timestamp: "0x66b1c0d0", hash: "0xabc", + parentHash: "0xparent", + gasUsed: "0xc3ed1d", + gasLimit: "0x1c9c380", + baseFeePerGas: "0x448b9b800", transactions: ["0xdead", "0xbeef"], }; @@ -61,4 +65,40 @@ describe("block renderer", () => { expect(out).toContain("unknown"); }); + + /** + * The four rows that say whether a block is full and what it cost. Without them `block` on EVM + * answered "how many transactions" and nothing a reader could act on. + */ + it("reports the EVM hash, parent, gas usage and base fee", () => { + const out = TextFormatters.block!({ block: EVM_BLOCK }, ctxFor("evm"))!; + + expect(out).toContain("Hash"); + expect(out).toContain("Parent hash"); + // decoded and grouped, never the hex the node sent + expect(out).toContain("12,840,221 / 30,000,000"); + expect(out).toContain("18.4 gwei"); + }); + + // Not "a field whose value we do not know" but a concept that does not exist there; an empty + // row would claim the former. + it("omits the base fee row entirely on a pre-1559 chain", () => { + const { baseFeePerGas: _dropped, ...legacy } = EVM_BLOCK; + const out = TextFormatters.block!({ block: legacy }, ctxFor("evm"))!; + + expect(out).not.toContain("Base fee"); + }); + + // The EVM rows are additive; TRON's three stay exactly as they were. + it("leaves the TRON block rows unchanged", () => { + const out = TextFormatters.block!({ block: TRON_BLOCK }, ctxFor("tron"))!; + + expect(out).not.toContain("Base fee"); + expect(out).not.toContain("Gas used"); + expect(out.split("\n").map((line) => line.split(" ")[0])).toEqual([ + "Number", + "Time", + "Transactions", + ]); + }); }); diff --git a/ts/src/adapters/inbound/cli/render/chain.ts b/ts/src/adapters/inbound/cli/render/chain.ts index 26fc14e1a..2000083c8 100644 --- a/ts/src/adapters/inbound/cli/render/chain.ts +++ b/ts/src/adapters/inbound/cli/render/chain.ts @@ -50,34 +50,9 @@ export const ChainFormatters = { return query(FAMILY_RENDER[renderFamily(ctx)].chainPricesRows(d, renderSymbol(ctx))); }) satisfies TextFormatter, - chainNode: ((data) => { - const d = asObj(data); - const head = asObj(d.headBlock); - const solid = asObj(d.solidBlock); - const peers = asObj(d.peers); - const headTimestamp = Number(head.timestamp ?? 0); - const ageSeconds = - headTimestamp > 0 ? Math.max(0, Math.round((Date.now() - headTimestamp) / 1000)) : null; - const sync = d.inSync ? "in sync" : "lagging"; - return query([ - ["Endpoint", d.endpoint === null ? "—" : String(d.endpoint ?? "—")], - ["Version", d.version === null ? "—" : String(d.version ?? "—")], - [ - "Head block", - `#${formatInt(head.number)} ${timestamp(head.timestamp)} (${ageSeconds === null ? "—" : `~${ageSeconds}s ago — ${sync}`})`, - ], - [ - "Solid block", - d.solidBlock === null - ? "—" - : `#${formatInt(solid.number)} (${formatInt(d.lagBlocks)} blocks behind head)`, - ], - [ - "Peers", - d.peers === null - ? "—" - : `${formatInt(peers.connected)} connected / ${formatInt(peers.active)} active`, - ], - ]); - }) satisfies TextFormatter, + // Family-shaped, like `chain prices` and `account info`: TRON has a p2p network to report on, + // EVM has a chain id to check the endpoint against. Reading one family's keys out of the other's + // payload is what printed empty TRON labels on EVM before. + chainNode: ((data, ctx) => + query(FAMILY_RENDER[renderFamily(ctx)].chainNodeRows(asObj(data)))) satisfies TextFormatter, }; diff --git a/ts/src/adapters/inbound/cli/render/family-render.test.ts b/ts/src/adapters/inbound/cli/render/family-render.test.ts index 744b0b011..1b242868e 100644 --- a/ts/src/adapters/inbound/cli/render/family-render.test.ts +++ b/ts/src/adapters/inbound/cli/render/family-render.test.ts @@ -137,11 +137,22 @@ describe("FAMILY_RENDER accountInfoRows", () => { const rows = FAMILY_RENDER.evm.accountInfoRows(EVM_ACCOUNT, "ETH"); expect(rows).toContainEqual(["Nonce", "16"]); + // json says `eoa` — a field value; the text row is the sentence version of the same fact. expect(rows).toContainEqual(["Type", "externally owned"]); - expect(FAMILY_RENDER.evm.accountInfoRows({ ...EVM_ACCOUNT, isContract: true }, "ETH")).toContainEqual([ - "Type", - "contract", - ]); + expect( + FAMILY_RENDER.evm.accountInfoRows({ ...EVM_ACCOUNT, type: "contract" }, "ETH"), + ).toContainEqual(["Type", "contract"]); + }); + + // §4.3 gives a contract its code size and an EOA none: an EOA is not a contract with zero + // bytes, and a row reading "0 bytes" would say it is. + it("sizes a contract's code and leaves the row off an EOA", () => { + expect( + FAMILY_RENDER.evm.accountInfoRows({ ...EVM_ACCOUNT, type: "contract", codeSize: 3124 }, "ETH"), + ).toContainEqual(["Code size", "3,124 bytes"]); + expect(FAMILY_RENDER.evm.accountInfoRows(EVM_ACCOUNT, "ETH").map((r) => r[0])).not.toContain( + "Code size", + ); }); it("never shows EVM a permission or resource row", () => { @@ -219,3 +230,109 @@ describe("FAMILY_RENDER chainPricesRows", () => { expect(rows).toContainEqual(["Memo fee", "1 TRX"]); }); }); + +/** + * The rows this release was missing: what a transaction cost, how deep it is, and what a block or + * a node actually reports. All four were in the JSON already — only the text layer read TRON's + * fields and so printed nothing (or nothing useful) on EVM. + */ +describe("FAMILY_RENDER — receipt settlement rows", () => { + it("states the EVM fee AND what it is the product of", () => { + const rows = FAMILY_RENDER.evm.receiptSettlementRows( + { kind: "send", feeWei: "441000000000000", gasUsed: 21000, effectiveGasPriceWei: "21000000000" } as never, + "ETH", + ); + + expect(rows).toEqual([["Fee", "0.000441 ETH (21,000 gas × 21 gwei)"]]); + }); + + // A receipt from a node that omitted effectiveGasPrice still has to state the total: the fee was + // paid whether or not its breakdown came back. + it("falls back to the bare total when the breakdown is missing", () => { + const rows = FAMILY_RENDER.evm.receiptSettlementRows( + { kind: "send", feeWei: "441000000000000" } as never, + "ETH", + ); + + expect(rows).toEqual([["Fee", "0.000441 ETH"]]); + }); + + it("keeps TRON's energy + SUN fee pair unchanged", () => { + const rows = FAMILY_RENDER.tron.receiptSettlementRows( + { kind: "send", energyUsed: 345, feeSun: "1100000" } as never, + "TRX", + ); + + expect(rows).toEqual([ + ["Energy", "345"], + ["Fee", "1.1 TRX"], + ]); + }); + + // §4.3 calls the nonce the entry point for diagnosing a stuck transaction — the case where no + // receipt ever arrives — so it is a receipt row, not a confirmation one. + it("gives EVM receipts a Nonce row and TRON none", () => { + expect(FAMILY_RENDER.evm.receiptIdentityRows({ kind: "send", nonce: 42 } as never)).toEqual([ + ["Nonce", "42"], + ]); + expect(FAMILY_RENDER.tron.receiptIdentityRows({ kind: "send" } as never)).toEqual([]); + }); +}); + +describe("FAMILY_RENDER — chain node rows", () => { + const EVM_NODE = { + endpoint: "node.example", + version: "Geth/v1.14.0", + chainId: "11155111", + headBlock: { number: 11204149, timestamp: 1722925264000 }, + solidBlock: { number: 11204100 }, + lagBlocks: 49, + inSync: true, + peers: null, + }; + + it("reports the node's chain id and sync state on EVM", () => { + const rows = FAMILY_RENDER.evm.chainNodeRows(EVM_NODE as never); + const labels = rows.map((r) => r[0]); + + expect(rows).toContainEqual(["Chain id", "11155111"]); + expect(rows).toContainEqual(["Syncing", "no"]); + expect(labels).toEqual([ + "Endpoint", + "Version", + "Chain id", + "Head block", + "Solid block", + "Syncing", + "Peers", + ]); + }); + + // "the node would not say" is not the same claim as "it is behind". + it("dashes the sync row when the node did not answer eth_syncing", () => { + const rows = FAMILY_RENDER.evm.chainNodeRows({ ...EVM_NODE, inSync: null } as never); + expect(rows).toContainEqual(["Syncing", "—"]); + }); + + it("leaves TRON's node rows exactly as they were", () => { + const rows = FAMILY_RENDER.tron.chainNodeRows({ + endpoint: "nile.trongrid.io", + version: "java-tron 4.7.7", + headBlock: { number: 84120345, timestamp: 1722925264000 }, + solidBlock: { number: 84120326 }, + lagBlocks: 19, + inSync: true, + peers: { connected: 30, active: 27 }, + } as never); + + expect(rows.map((r) => r[0])).toEqual([ + "Endpoint", + "Version", + "Head block", + "Solid block", + "Peers", + ]); + expect(rows).toContainEqual(["Peers", "30 connected / 27 active"]); + }); +}); + diff --git a/ts/src/adapters/inbound/cli/render/family.ts b/ts/src/adapters/inbound/cli/render/family.ts index 7b3b9e077..f329fb41a 100644 --- a/ts/src/adapters/inbound/cli/render/family.ts +++ b/ts/src/adapters/inbound/cli/render/family.ts @@ -1,9 +1,9 @@ -import type { TxInfoView } from "../../../../domain/types/index.js"; +import type { TxInfoView, TxReceiptView } from "../../../../domain/types/index.js"; import { RESOURCES, resourceOfRpcCode, type Resource } from "../../../../domain/resources/index.js"; import type { TextRenderContext } from "../contracts/index.js"; import { ChainFamily } from "../../../../domain/family/index.js"; import { ExecutionError } from "../../../../domain/errors/index.js"; -import { formatScalar, formatInt, formatGwei, formatSun, formatWei } from "./scalars.js"; +import { formatScalar, formatInt, formatGwei, formatSun, formatUtc, formatWei } from "./scalars.js"; import { asObj, type Obj, type Pair } from "./layout.js"; /** @@ -32,11 +32,55 @@ interface FamilyRenderHooks { /** `chain prices` rows. TRON prices energy and bandwidth in SUN; EVM prices gas per the fee * model the chain reports. Same reason as accountInfoRows: disjoint field sets. */ chainPricesRows(d: Obj, symbol: string): Pair[]; + /** `chain node` rows. TRON has a p2p network to report on, EVM has a chain id to check the + * endpoint against — neither field exists on the other side. */ + chainNodeRows(d: Obj): Pair[]; + /** `block` rows. The payload is the node's own object (§9.1), so the two families arrive in + * different shapes and each picks its own fields out. */ + blockRows(block: Obj, timestampMs: number | undefined): Pair[]; + /** rows a broadcast receipt shows BEFORE the TxID — the transaction's own identifiers. + * EVM has a nonce; TRON's transactions are identified only by their hash. */ + receiptIdentityRows(r: TxReceiptView): Pair[]; + /** rows a CONFIRMED receipt shows after the block: what the transaction actually consumed. + * TRON bills energy and a SUN fee; EVM bills gas at a settled per-gas price. */ + receiptSettlementRows(r: TxReceiptView, symbol: string): Pair[]; } const txInfoAmount = (v: string | undefined, suffix: string): string => v === undefined || v === "" ? "" : `${formatScalar(v)}${suffix}`; +/** "#84,120,345 2026-08-24 12:31:12 (~2s ago — in sync)" — shared by both families' node views. */ +function headBlockRow(d: Obj): Pair { + const head = asObj(d.headBlock); + const headTimestamp = Number(head.timestamp ?? 0); + const ageSeconds = + headTimestamp > 0 ? Math.max(0, Math.round((Date.now() - headTimestamp) / 1000)) : null; + const sync = d.inSync ? "in sync" : "lagging"; + const age = ageSeconds === null ? "—" : `~${ageSeconds}s ago — ${sync}`; + return ["Head block", `#${formatInt(head.number)} ${nodeTime(head.timestamp)} (${age})`]; +} + +/** epoch-ms → "YYYY-MM-DD HH:MM:SS", or "—" for a missing/zero stamp (the node view's convention + * for a field an endpoint did not expose). */ +function nodeTime(v: unknown): string { + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) return "—"; + return new Date(n).toISOString().replace("T", " ").slice(0, 19); +} + +/** a best-effort field the endpoint may not serve: null renders as the dash, per `chain node`. */ +function orDash(v: unknown): string { + return v === null || v === undefined ? "—" : String(v); +} + +/** `Fee ( gas × gwei)` — the total plus what it is the product of. + * Falls back to the bare total when the receipt did not carry the two components. */ +function evmFeeRow(fee: unknown, gasUsed: unknown, priceWei: unknown, symbol: string): string { + const total = `${formatWei(fee)} ${symbol}`; + if (gasUsed === undefined || priceWei === undefined) return total; + return `${total} (${formatInt(gasUsed)} gas × ${formatGwei(priceWei)} gwei)`; +} + export const FAMILY_RENDER: Record = { tron: { nativeAmount: (raw, symbol) => `${formatSun(raw)} ${symbol}`, @@ -77,6 +121,49 @@ export const FAMILY_RENDER: Record = { ["Memo fee", `${formatSun(d.memoFeeSun)} ${symbol}`], ]; }, + chainNodeRows: (d) => { + const solid = asObj(d.solidBlock); + const peers = asObj(d.peers); + return [ + ["Endpoint", orDash(d.endpoint)], + ["Version", orDash(d.version)], + headBlockRow(d), + [ + "Solid block", + d.solidBlock === null + ? "—" + : `#${formatInt(solid.number)} (${formatInt(d.lagBlocks)} blocks behind head)`, + ], + [ + "Peers", + d.peers === null + ? "—" + : `${formatInt(peers.connected)} connected / ${formatInt(peers.active)} active`, + ], + ]; + }, + // The node's protobuf block: header fields live under block_header.raw_data, and the hash is + // the block's own `blockID`. Unchanged from what this command has always printed. + blockRows: (block, timestampMs) => { + const header = asObj(asObj(block.block_header).raw_data); + const number = block.number ?? header.number; + const txs = Array.isArray(block.transactions) ? block.transactions.length : 0; + return [ + ["Number", number === undefined ? "" : `#${formatInt(number)}`], + ["Time", timestampMs ? formatUtc(timestampMs) : "unknown"], + ["Transactions", String(txs)], + ]; + }, + // A TRON transaction is identified by its hash alone — there is no per-account sequence. + receiptIdentityRows: () => [], + receiptSettlementRows: (r, symbol) => { + const rows: Pair[] = []; + if (r.energyUsed !== undefined && r.energyUsed !== null) + rows.push(["Energy", formatInt(r.energyUsed)]); + if (r.feeSun !== undefined && r.feeSun !== null) + rows.push(["Fee", `${formatSun(r.feeSun)} ${symbol}`]); + return rows; + }, txInfoRows: (r, symbol) => [ ["TxID", r.txid], ["From", r.from ?? ""], @@ -84,6 +171,7 @@ export const FAMILY_RENDER: Record = { ["Amount", txInfoAmount(r.amount, r.symbol ? ` ${r.symbol}` : "")], ["Status", r.status ?? "unknown"], ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], + ["Confirmations", r.confirmations === undefined ? "" : formatInt(r.confirmations)], ["Energy", r.energyUsed === undefined ? "" : formatInt(r.energyUsed)], ["Fee", r.feeSun === undefined ? "" : `${formatSun(r.feeSun)} ${symbol}`], ], @@ -92,37 +180,129 @@ export const FAMILY_RENDER: Record = { nativeAmount: (raw, symbol) => `${formatWei(raw)} ${symbol}`, feeFallback: (fee, symbol) => `${formatWei(fee)} ${symbol}`, addressLabel: "EVM address", - accountInfoRows: (d, symbol) => [ - ["Address", String(d.address ?? "")], - ["Balance", `${formatWei(d.balance)} ${symbol}`], - ["Nonce", formatInt(d.nonce)], - // The distinction a reader needs before sending: an address with code may reject a plain - // transfer, and "isContract: false" is not a phrase to put in front of a person. - ["Type", d.isContract ? "contract" : "externally owned"], - ], + accountInfoRows: (d, symbol) => { + const rows: Pair[] = [ + ["Address", String(d.address ?? "")], + ["Balance", `${formatWei(d.balance)} ${symbol}`], + ["Nonce", formatInt(d.nonce)], + // The distinction a reader needs before sending: an address with code may reject a plain + // transfer. json says `eoa`, which is a field value; this is the sentence version of it. + ["Type", d.type === "contract" ? "contract" : "externally owned"], + ]; + // Only a contract has code, so only a contract gets the row (§4.3 — an EOA is not a + // contract with zero bytes). + if (d.codeSize !== undefined) rows.push(["Code size", `${formatInt(d.codeSize)} bytes`]); + return rows; + }, // Priced in gwei, the unit --max-fee and --priority-fee accept: showing wei here and taking // gwei there would make the reader do the nine-zero conversion themselves. JSON keeps wei. - chainPricesRows: (d) => { + chainPricesRows: (d, symbol) => { const rows: Pair[] = [["Fee model", String(d.feeModel ?? "")]]; if (d.baseFeeWei !== undefined) rows.push(["Base fee", `${formatGwei(d.baseFeeWei)} gwei`]); if (d.priorityFeeWei !== undefined) rows.push(["Priority fee", `${formatGwei(d.priorityFeeWei)} gwei`]); if (d.gasPriceWei !== undefined) rows.push(["Gas price", `${formatGwei(d.gasPriceWei)} gwei`]); + // The per-gas numbers above answer "how expensive is gas"; this answers "what will a + // transfer cost me", which is the question most readers actually have. + if (d.transferCostWei !== undefined) { + rows.push([ + "Transfer cost", + `${formatWei(d.transferCostWei)} ${symbol} (${formatInt(d.transferGas)} gas)`, + ]); + } return rows; }, + chainNodeRows: (d) => { + const solid = asObj(d.solidBlock); + const peers = asObj(d.peers); + return [ + ["Endpoint", orDash(d.endpoint)], + ["Version", orDash(d.version)], + // What every signature commits to (EIP-155), and the one field that says whether this + // endpoint is the chain the caller thinks it is. + ["Chain id", orDash(d.chainId)], + headBlockRow(d), + [ + "Solid block", + d.solidBlock === null + ? "—" + : `#${formatInt(solid.number)} (${formatInt(d.lagBlocks)} blocks behind head)`, + ], + // eth_syncing answers this directly; null means the node would not say, which is not the + // same as "out of sync". + ["Syncing", d.inSync === null || d.inSync === undefined ? "—" : d.inSync ? "no" : "yes"], + [ + "Peers", + d.peers === null + ? "—" + : `${formatInt(peers.connected)} connected / ${formatInt(peers.active)} active`, + ], + ]; + }, + // The node's own block object: hex QUANTITIES throughout (§9.1 keeps json verbatim), so every + // number here is converted for display only. Gas and base fee are what say whether the chain + // is busy and what it costs — the reason to look at a block at all. + blockRows: (block, timestampMs) => { + const txs = Array.isArray(block.transactions) ? block.transactions.length : 0; + const gasUsed = quantity(block.gasUsed); + const gasLimit = quantity(block.gasLimit); + const baseFee = quantity(block.baseFeePerGas); + const rows: Pair[] = [ + ["Number", block.number === undefined ? "" : `#${formatInt(quantity(block.number))}`], + ["Hash", String(block.hash ?? "")], + ["Parent hash", String(block.parentHash ?? "")], + ["Time", timestampMs ? formatUtc(timestampMs) : "unknown"], + ["Transactions", String(txs)], + ]; + if (gasUsed !== undefined) { + rows.push([ + "Gas used", + gasLimit === undefined + ? formatInt(gasUsed) + : `${formatInt(gasUsed)} / ${formatInt(gasLimit)}`, + ]); + } + // Absent on a pre-1559 chain, where it is not a field with an unknown value but a concept + // that does not apply. An empty row would claim otherwise. + if (baseFee !== undefined) rows.push(["Base fee", `${formatGwei(baseFee)} gwei`]); + return rows; + }, + // §4.3 calls the nonce the entry point for diagnosing a stuck transaction, and a stuck one is + // precisely the case where the receipt never arrives — so it is stated from `submitted` on. + receiptIdentityRows: (r) => + r.nonce === undefined ? [] : [["Nonce", formatInt(r.nonce)] as Pair], + receiptSettlementRows: (r, symbol) => + r.feeWei === undefined || r.feeWei === null + ? [] + : [["Fee", evmFeeRow(r.feeWei, r.gasUsed, r.effectiveGasPriceWei, symbol)] as Pair], txInfoRows: (r, symbol) => [ ["TxID", r.txid], + ["Type", r.type ?? ""], ["From", r.from ?? ""], ["To", r.to ?? ""], ["Amount", txInfoAmount(r.amount, r.symbol ? ` ${r.symbol}` : "")], + ["Nonce", r.nonce === undefined ? "" : formatInt(r.nonce)], ["Status", r.status ?? "unknown"], ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], + // Seconds on the wire (§6.5), milliseconds for the formatter. + ["Block time", r.blockTime === undefined ? "" : formatUtc(r.blockTime * 1000)], + ["Confirmations", r.confirmations === undefined ? "" : formatInt(r.confirmations)], ["Gas", r.gasUsed === undefined ? "" : formatInt(r.gasUsed)], ["Fee", r.feeWei === undefined ? "" : `${formatWei(r.feeWei)} ${symbol}`], ], }, }; +/** hex QUANTITY (or a plain number) → number, for the EVM block view. */ +function quantity(v: unknown): number | undefined { + if (v === undefined || v === null) return undefined; + try { + return Number(BigInt(String(v))); + } catch { + return undefined; + } +} + export function familyAddressLabel(family: string): string { return FAMILY_RENDER[family as ChainFamily]?.addressLabel ?? `${family} address`; } diff --git a/ts/src/adapters/inbound/cli/render/misc.ts b/ts/src/adapters/inbound/cli/render/misc.ts index ef76cf6ed..4e1a3bc8b 100644 --- a/ts/src/adapters/inbound/cli/render/misc.ts +++ b/ts/src/adapters/inbound/cli/render/misc.ts @@ -1,12 +1,15 @@ import type { TextFormatter } from "../contracts/index.js"; import { formatScalar, formatInt, formatUtc, num, methodName } from "./scalars.js"; import { type Obj, type Pair, asObj, kv, query, receipt, table, titled, ok } from "./layout.js"; +import { FAMILY_RENDER, renderFamily } from "./family.js"; export const MiscFormatters = { config: ((data) => renderConfig(asObj(data))) satisfies TextFormatter, networks: ((data) => table( - ["Network", "Alias", "Family", "Chain", "Fee model", "Endpoint"], + // "Chain id", not "Chain": the value IS the second half of the canonical id (§2.3), and the + // shorter header read as though it might hold the chain's name. + ["Network", "Alias", "Family", "Chain id", "Fee model", "Endpoint"], (Array.isArray(data) ? data : []) .map(asObj) .map((n) => [ @@ -47,20 +50,22 @@ 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); - const n = block.number ?? header.number; const raw = block.timestamp ?? header.timestamp; // Seconds read as milliseconds would date every EVM block to 1970. - const ts = - ctx.net?.family === "evm" && raw !== undefined ? num(raw, 0) * 1000 : raw; - const txs = Array.isArray(block.transactions) ? block.transactions.length : 0; - return query([ - ["Number", n === undefined ? "" : `#${formatInt(n)}`], - ["Time", ts ? formatUtc(ts) : "unknown"], - ["Transactions", String(txs)], - ]); + const family = renderFamily(ctx); + const timestampMs = + raw === undefined || raw === null + ? undefined + : family === "evm" + ? Number(BigInt(String(raw))) * 1000 + : Number(raw); + return query(FAMILY_RENDER[family].blockRows(block, timestampMs)); }) satisfies TextFormatter, }; diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index a514058fd..d57628178 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -12,6 +12,7 @@ import { renderApproval } from "./approval.js"; import { formatScalar, formatDecimal, + formatGwei, formatInt, formatSun, formatUtc, @@ -37,6 +38,9 @@ export const TxFormatters = { ["TxID", r.txid], ["Status", status], ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], + // §6.4: `--wait` stops at the receipt, so how deep is enough is the caller's call to make. + // Empty rows are dropped, so this is absent while pending and on an unreadable head. + ["Confirmations", r.confirmations === undefined ? "" : formatInt(r.confirmations)], ]); }) satisfies TextFormatter, txInfo: ((r, ctx) => { @@ -86,7 +90,7 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { const txid = String(r.txId ?? r.hash ?? ""); const stage = r.stage ?? "submitted"; const summary = receiptSummary(r, family, symbol); - const pairs: Pair[] = [...receiptRows(r)]; + const pairs: Pair[] = [...receiptRows(r), ...FAMILY_RENDER[family].receiptIdentityRows(r)]; if (txid) pairs.push(["TxID", txid]); // submitted (default, non-blocking): txid only, no fee/energy yet — those need confirmation. @@ -100,10 +104,10 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { // confirmed / failed (after --wait): real on-chain block / fee / energy / result. if (r.blockNumber !== undefined && r.blockNumber !== null) pairs.push(["Block", `#${formatInt(r.blockNumber)}`]); - if (r.energyUsed !== undefined && r.energyUsed !== null) - pairs.push(["Energy", formatInt(r.energyUsed)]); - if (r.feeSun !== undefined && r.feeSun !== null) - pairs.push(["Fee", `${formatSun(r.feeSun)} TRX`]); + // What the transaction actually consumed, in the terms its own family bills in. Reading only + // TRON's fields here left an EVM receipt with no Fee line at all: the amounts were in the JSON, + // and the person who had just spent them could not see them. + pairs.push(...FAMILY_RENDER[family].receiptSettlementRows(r, symbol)); if (r.kind === "stake-unfreeze") pairs.push(["Withdrawable", "after the unlock period — then run `stake withdraw`"]); if (stage === "failed") { @@ -386,9 +390,23 @@ function receiptRows(r: TxReceiptView): Pair[] { rows.push(["To", r.toContact ? `${r.toContact} (${address})` : address]); } if (r.kind === "contract-send") rows.push(["Contract", String(r.contract ?? "")]); + // approve(address,uint256): the two facts the caller cannot verify from what they typed — the + // uint256 on the command line is scaled by the token's decimals, and its maximum is 78 digits. + // Present in the dry run too, which is where an approval most wants checking (§7.2). + if (r.spender !== undefined) rows.push(["Spender", String(r.spender)]); + if (r.allowance !== undefined) rows.push(["Allowance", allowanceLabel(r)]); return rows; } +/** `1 USDC` / `unlimited` / the bare base-unit integer when the token's decimals were unreadable. */ +function allowanceLabel(r: TxReceiptView): string { + const value = String(r.allowance); + if (value === "unlimited") return value; + const symbol = r.token ?? ""; + const amount = r.allowanceDecimals === undefined ? value : formatDecimal(value); + return symbol ? `${amount} ${symbol}` : amount; +} + /** broadcast-receipt amount: token-aware (symbol/decimals when known, else the contract/asset-id * identifier for raw-amount sends), native smallest-unit → coin only when no token is involved. */ function receiptAmount(r: TxReceiptView, family: ChainFamily, symbol: string): string { @@ -508,9 +526,14 @@ function formatFee(fee: unknown, family: ChainFamily, symbol: string): string { return `~${energy.toLocaleString()} energy${covered}`; } // EVM fee plan: gasLimit × the per-gas ceiling. It is the most this transaction CAN cost, - // not what it will, so it is labelled as a ceiling rather than quoted as a charge. + // not what it will, so it is labelled as a ceiling (§6.1 writes "~ … max"; `≤` says the same + // thing without implying an estimate could land above it) and, when the components are known, + // states what that ceiling is made of — the same shape as a confirmed receipt's Fee row. if (f.maxCostWei !== undefined) { - return `\u2264 ${FAMILY_RENDER[family].feeFallback(f.maxCostWei, symbol)}`; + const total = `\u2264 ${FAMILY_RENDER[family].feeFallback(f.maxCostWei, symbol)}`; + return f.gasLimit === undefined || f.maxPerGasWei === undefined + ? total + : `${total} (${formatInt(f.gasLimit)} gas × ${formatGwei(f.maxPerGasWei)} gwei max)`; } if (f.note) return String(f.note); // An unrecognised fee object must not reach feeFallback: that formats a scalar sun amount and diff --git a/ts/src/adapters/inbound/cli/render/wallet.ts b/ts/src/adapters/inbound/cli/render/wallet.ts index da53f1c4b..5cb6ebcf5 100644 --- a/ts/src/adapters/inbound/cli/render/wallet.ts +++ b/ts/src/adapters/inbound/cli/render/wallet.ts @@ -12,7 +12,9 @@ export const WalletFormatters = { walletWatch: ((data) => { const d = asObj(data); return receipt(ok(), `Added watch-only account ${quote(displayName(d))}`, [ - ["Address", firstAddress(d)], + // The family label, not a bare `Address` (§3.5): once two families coexist, "Address" does + // not tell the reader which chain this account lives on. + ...addressPairs(d), ["Note", "read-only; signing operations will be rejected"], ]); }) satisfies TextFormatter, @@ -45,8 +47,14 @@ export const WalletFormatters = { }) satisfies TextFormatter, walletDerive: ((data) => { const d = asObj(data); + // One derive produces an address per family (§3.9), so the receipt lists every one of them — + // showing only the first made this release's headline invisible on the command that performs + // it. `Index` and `Account ID` come along for the same reason `create` carries them: they are + // what the next command is addressed by. return receipt(ok(), `Derived sub-account ${quote(displayName(d))}`, [ - ["Address", firstAddress(d)], + ["Account ID", String(d.accountId ?? "")], + ["Index", d.index === null || d.index === undefined ? "" : formatInt(d.index)], + ...addressPairs(d), ["Active", d.active === true ? "yes" : ""], ["Note", "shares master mnemonic; no separate backup needed"], ]); diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts index 32db4b542..f336baca8 100644 --- a/ts/src/adapters/inbound/cli/shell/index.ts +++ b/ts/src/adapters/inbound/cli/shell/index.ts @@ -489,7 +489,12 @@ function assertKnownFlags( }; for (const k of Object.keys(GLOBAL_OPTS)) add(k); for (const f of GLOBAL_FLAG_SPECS) if (f.alias) allowed.add(f.alias); // -o / -v short aliases - for (const p of cmd.path) add(p); + // The command's OWN path segments are deliberately not allowed. Adding them made `--token` a + // silently accepted no-op on `token balance`, `--contract` one on `contract deploy`, `--watch` + // one on `import watch` — a flag the command does not have, ignored instead of refused, which is + // the failure mode this whole function exists to prevent. yargs delivers the path in `_` (and, + // for grouped commands, in the `group`/`verb`/`source` keys already allowed above), so nothing + // needs them here. // Positional fields are deliberately absent: they arrive in `args` and are bound to their field // AFTER this check, so a field name present here can only be a `--field` the user typed — which // this command does not accept (see CommandDefinition.positionals). A positional named after a diff --git a/ts/src/adapters/outbound/chain/evm/evm.test.ts b/ts/src/adapters/outbound/chain/evm/evm.test.ts index c4ab92449..f88e5ff5f 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.test.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.test.ts @@ -458,6 +458,96 @@ describe("EvmRpcClient.estimateGas", () => { * blacklist test (`result === false`) never fired against error responses that simply omit the * field, and every rejected transaction was reported as submitted. */ +/** + * "There is no token here" has two shapes on EVM: an address with no code answers empty, and a + * contract without balanceOf reverts. Both are the same answer to a caller, and the reverting one + * used to surface as rpc_error — which reads as a broken network rather than a wrong address. + */ +describe("EvmRpcClient.getErc20Balance", () => { + const client = () => new EvmRpcClient("https://node.example", 5_000); + const OWNER = ADDR; + + function stubCall(body: unknown) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, text: async () => JSON.stringify({ id: 1, ...(body as object) }) })), + ); + } + + it("returns the decoded balance", async () => { + stubCall({ result: `0x${(1234n).toString(16).padStart(64, "0")}` }); + + await expect(client().getErc20Balance(TOKEN, OWNER)).resolves.toBe("1234"); + }); + + it("reports an address with no code as not a token", async () => { + stubCall({ result: "0x" }); + + await expect(client().getErc20Balance(TOKEN, OWNER)).rejects.toMatchObject({ + code: "token_metadata_unavailable", + }); + }); + + it("reports a reverting contract the same way, not as an rpc fault", async () => { + stubCall({ error: { code: -32000, message: "execution reverted" } }); + + await expect(client().getErc20Balance(TOKEN, OWNER)).rejects.toMatchObject({ + code: "token_metadata_unavailable", + }); + }); + + // The line the classification must not cross: a node that cannot be reached is still a node + // that cannot be reached. + it("leaves a transport failure as rpc_error", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("connect ECONNREFUSED"); })); + + await expect(client().getErc20Balance(TOKEN, OWNER)).rejects.toMatchObject({ + code: "rpc_error", + }); + }); +}); + +/** + * Metadata reads have the same line to hold as the balance read: a contract that does not + * implement a view answers `undefined`, but a node that cannot be reached is a node that cannot + * be reached. Catching both would report an outage as "this token has no metadata" — and the + * caller, seeing an empty result, would go on to say the address is not a token. + */ +describe("EvmRpcClient.getErc20Metadata", () => { + const client = () => new EvmRpcClient("https://node.example", 5_000); + + it("returns the fields the contract answers", async () => { + // symbol/name are dynamic strings; decimals is a word. Encoded as ethers would return them. + const str = (v: string) => { + const hex = Buffer.from(v, "utf8").toString("hex"); + return ("0x" + (32n).toString(16).padStart(64, "0") + BigInt(v.length).toString(16).padStart(64, "0") + hex.padEnd(64, "0")); + }; + const answers = [str("USDC"), "0x" + (6n).toString(16).padStart(64, "0"), str("USD Coin")]; + let i = 0; + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + text: async () => JSON.stringify({ id: 1, result: answers[i++] }), + }))); + + await expect(client().getErc20Metadata(TOKEN)).resolves.toMatchObject({ symbol: "USDC", decimals: 6 }); + }); + + it("returns nothing for a contract that implements none of them", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + text: async () => JSON.stringify({ id: 1, error: { code: -32000, message: "execution reverted" } }), + }))); + + await expect(client().getErc20Metadata(TOKEN)).resolves.toEqual({}); + }); + + it("propagates a transport failure instead of reporting absent metadata", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("connect ECONNREFUSED"); })); + + await expect(client().getErc20Metadata(TOKEN)).rejects.toMatchObject({ code: "rpc_error" }); + }); +}); + describe("EvmRpcClient.sendRawTransaction", () => { const RAW = "0x02f8b1"; const HASH = `0x${"ab".repeat(32)}`; diff --git a/ts/src/adapters/outbound/chain/evm/evm.ts b/ts/src/adapters/outbound/chain/evm/evm.ts index f16bf5a5c..28705098c 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.ts @@ -113,6 +113,12 @@ export class EvmRpcClient implements EvmGateway { }; } + /** the chain id the NODE reports, as a decimal string. Asked rather than assumed: this is what + * `chain node` is for — confirming the endpoint is the chain you think it is. */ + async chainId(): Promise { + return toDecimalString(await this.#call("eth_chainId", [])); + } + /** the node's gas estimate for a transaction, as a decimal string. */ async estimateGas(tx: Record): Promise { return toDecimalString(await this.#call("eth_estimateGas", [toRpcQuantities(tx)])); @@ -172,6 +178,9 @@ export class EvmRpcClient implements EvmGateway { ...(gasUsed !== undefined && price !== undefined ? { feeWei: (gasUsed * price).toString(10) } : {}), + // The two numbers feeWei is the product of. A receipt that states only the total leaves the + // reader unable to tell an expensive call from a cheap one at a high gas price. + ...(price === undefined ? {} : { effectiveGasPriceWei: price.toString(10) }), ...(r.blockNumber === undefined ? {} : { blockNumber: Number(BigInt(String(r.blockNumber))) }), ...(r.contractAddress === undefined || r.contractAddress === null ? {} @@ -349,13 +358,35 @@ export class EvmRpcClient implements EvmGateway { return this.call(contract, data); } - /** a read-only contract call; `data` and the result are both DATA, so both stay hex. */ + /** + * A read-only contract call; `data` and the result are both DATA, so both stay hex. + * + * A revert is the CONTRACT's answer, not a transport failure, so it gets its own code + * (§11 `execution_reverted`) carrying whatever reason the node decoded. `rpc_error` here would + * read as "the network is broken" for what is in fact a definite reply. + */ async call(to: string, data: string): Promise { - return toData(await this.#call("eth_call", [{ to, data }, "latest"])); + try { + return toData(await this.#call("eth_call", [{ to, data }, "latest"])); + } catch (e) { + const message = (e as Error).message ?? ""; + if (isNotAContractAnswer(message)) { + throw new ChainError("execution_reverted", message, { contract: to }); + } + throw e; + } } async getErc20Balance(contract: string, owner: string): Promise { - const raw = await this.call(contract, ERC20.encodeFunctionData("balanceOf", [owner])); + // Two shapes of "there is no token here": an address with no code answers empty, and a + // contract without balanceOf reverts. Both are the same answer to the caller, and both must + // read as such — a revert surfacing as rpc_error says "the network is broken" instead. + const raw = await this.call(contract, ERC20.encodeFunctionData("balanceOf", [owner])).catch( + (e: unknown) => { + if (isNotAContractAnswer((e as Error).message ?? "")) return "0x"; + throw e; + }, + ); // An address with no code returns empty rather than reverting, so "0x" here means "this is // not a token contract", not "the balance is zero". if (raw === "0x" || raw === "") { @@ -367,11 +398,33 @@ export class EvmRpcClient implements EvmGateway { return (ERC20.decodeFunctionResult("balanceOf", raw)[0] as bigint).toString(10); } + /** + * A view call whose absence is an answer: `undefined` means "this contract does not implement + * it" — an empty return (no code at the address) or a revert. Anything else is rethrown. + * + * The distinction is the whole point. Catching every failure would turn an unreachable node + * into "this token has no metadata", and a caller cannot tell that from a real answer, so it + * escalates a network outage into a claim about the contract. + */ + async #viewCall(contract: string, data: string): Promise { + let raw: string; + try { + raw = await this.call(contract, data); + } catch (e) { + if (isNotAContractAnswer((e as Error).message ?? "")) return undefined; + throw e; + } + return raw === "0x" || raw === "" ? undefined : raw; + } + /** * Best-effort ERC-20 metadata. Each field is read independently and a field the contract does * not answer comes back undefined — never defaulted. `decimals` in particular scales every * human-entered amount, so inventing 18 for a contract that stayed silent would quietly * misprice transfers; the caller decides what to do about the gap. + * + * "Best-effort" covers what the CONTRACT did not answer, never what the NODE did not deliver: + * a transport failure propagates. */ async getErc20Metadata( contract: string, @@ -390,13 +443,8 @@ export class EvmRpcClient implements EvmGateway { /** `symbol()`/`name()` as string, falling back to the bytes32 form early tokens (MKR) use. */ async #text(contract: string, fn: "symbol" | "name"): Promise { - let raw: string; - try { - raw = await this.call(contract, ERC20.encodeFunctionData(fn, [])); - } catch { - return undefined; - } - if (raw === "0x" || raw === "") return undefined; + const raw = await this.#viewCall(contract, ERC20.encodeFunctionData(fn, [])); + if (raw === undefined) return undefined; try { return ERC20.decodeFunctionResult(fn, raw)[0] as string; } catch { @@ -409,11 +457,12 @@ export class EvmRpcClient implements EvmGateway { } async #decimals(contract: string): Promise { + const raw = await this.#viewCall(contract, ERC20.encodeFunctionData("decimals", [])); + if (raw === undefined) return undefined; try { - const raw = await this.call(contract, ERC20.encodeFunctionData("decimals", [])); - if (raw === "0x" || raw === "") return undefined; return Number(ERC20.decodeFunctionResult("decimals", raw)[0]); } catch { + // A value that is not a uint8 is the contract answering something else, not a node fault. return undefined; } } @@ -442,6 +491,15 @@ export class EvmRpcClient implements EvmGateway { } } +/** + * Does this failure mean "the address holds no such contract method", as opposed to "the node + * could not answer"? A revert and an empty return are the contract speaking; a refused connection + * or an HTTP error is not, and must never be reported as a fact about the contract. + */ +function isNotAContractAnswer(message: string): boolean { + return /execution reverted|invalid opcode|out of gas/i.test(message); +} + /** Accept `constructor(uint256,string)`, `(uint256,string)` or a bare `uint256,string` — the * three ways someone writes the same thing — and hand ethers the one form it parses. */ function normalizeConstructorSignature(signature: string): string { diff --git a/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts b/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts index c23fb96c8..e36ddd824 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.token-info.test.ts @@ -118,9 +118,30 @@ describe("TronRpcClient.getTokenInfo", () => { }); }); - it("propagates 'no contract at this address' rather than blanking the fields", async () => { + /** + * "No contract at this address" is not a network fault, and it used to be reported as one + * (`rpc_error`) — which reads as "the node is broken" when the truth is "that address holds no + * token". It is now classified, and the code matches what the EVM side answers for the same + * situation so a caller can branch on one value across both families. + * + * The test above is the other half of the pair and must keep passing: a transport failure is + * still `rpc_error`. Classification here means naming two known node answers, not catching + * everything — the difference the getTokenInfo comment exists to protect. + */ + it("classifies 'no contract at this address' as missing token metadata", async () => { await expect(notAContract().getTokenInfo(CONTRACT)).rejects.toMatchObject({ - code: "rpc_error", + code: "token_metadata_unavailable", + message: expect.stringContaining("may not be a token contract"), + }); + }); + + it("classifies a reverted view call the same way", async () => { + const client = new TronRpcClient("http://localhost:1", 200); + client.tronweb.transactionBuilder.triggerConstantContract = (() => + Promise.reject(new Error("REVERT opcode executed"))) as never; + + await expect(client.getTokenInfo(CONTRACT)).rejects.toMatchObject({ + code: "token_metadata_unavailable", }); }); }); diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 22935d30f..7a331952e 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -540,15 +540,41 @@ export class TronRpcClient implements TronGateway, Broadcaster { async getTrc20Balance(contract: string, address: string): Promise { return this.#wrap("trc20 balanceOf", async () => { - const [hex] = await this.#constant(contract, "balanceOf(address)", [ - { type: "address", value: address }, - ]); + const [hex] = await this.#notAToken(contract, "balanceOf", () => + this.#constant(contract, "balanceOf(address)", [{ type: "address", value: address }]), + ); return hex ? BigInt("0x" + hex).toString() : "0"; }); } + /** + * Re-label the two node answers that mean "there is no token here", leaving every other failure + * alone. + * + * This is classification, not swallowing — the distinction the getTokenInfo comment below is + * about. A node outage, a timeout, a malformed response all still surface as themselves; only + * "no contract at this address" and a reverted view call become token_metadata_unavailable, + * which is the same code the EVM side reports for the same two situations. Without this, asking + * about a non-token answered `rpc_error`, which reads as "the network is broken". + */ + async #notAToken(contract: string, method: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + const message = (e as Error).message ?? ""; + if (/smart contract is not exist|revert opcode executed/i.test(message)) { + throw new ChainError( + "token_metadata_unavailable", + `${contract} did not answer ${method} — it may not be a token contract`, + ); + } + throw e; + } + } + async getTokenInfo(contract: string): Promise { - return this.#wrap("trc20 tokenInfo", async () => { + return this.#wrap("trc20 tokenInfo", async () => + this.#notAToken(contract, "the TRC-20 view methods", async () => { // Read the view methods by selector rather than via contract().at(): tokens deployed without a // published ABI (e.g. USDD on Nile) resolve to a contract object with no methods at all. // @@ -573,7 +599,8 @@ export class TronRpcClient implements TronGateway, Broadcaster { decimals: scale !== undefined && scale <= 255n ? Number(scale) : undefined, totalSupply: decodeAbiUint(totalSupply)?.toString(), }; - }); + }), + ); } async getTrc10Balance(assetId: string, address: string): Promise { diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index 05ed43e84..c8cfca37a 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -67,6 +67,7 @@ export const BUILTIN_NETWORKS: Record = { }, "tron:nile": { id: "tron:nile", + testnet: true, nativeSymbol: "TRX", family: "tron", chainId: "nile", @@ -83,6 +84,7 @@ export const BUILTIN_NETWORKS: Record = { }, "tron:shasta": { id: "tron:shasta", + testnet: true, nativeSymbol: "TRX", family: "tron", chainId: "shasta", @@ -104,6 +106,7 @@ export const BUILTIN_NETWORKS: Record = { }, "evm:11155111": { id: "evm:11155111", + testnet: true, nativeSymbol: "ETH", family: "evm", chainId: "11155111", @@ -122,6 +125,7 @@ export const BUILTIN_NETWORKS: Record = { }, "evm:97": { id: "evm:97", + testnet: true, nativeSymbol: "BNB", family: "evm", chainId: "97", diff --git a/ts/src/adapters/outbound/keystore/index.ts b/ts/src/adapters/outbound/keystore/index.ts index 2cefae3ba..b8ccc6904 100644 --- a/ts/src/adapters/outbound/keystore/index.ts +++ b/ts/src/adapters/outbound/keystore/index.ts @@ -21,7 +21,7 @@ import type { } from "../../../domain/types/index.js"; import { CryptoEnvelope } from "../persistence/crypto/index.js"; import { Derivation } from "../../../domain/derivation/index.js"; -import { familyOf, CHAIN_FAMILIES } from "../../../domain/family/index.js"; +import { familyOf, canonicalAddress, CHAIN_FAMILIES } from "../../../domain/family/index.js"; import { SOURCE_KINDS, sourceFamily } from "../../../domain/sources/index.js"; import { AtomicFileStore } from "../persistence/fs/index.js"; import { ExecutionError, UsageError, WalletError } from "../../../domain/errors/index.js"; @@ -190,11 +190,10 @@ export class Keystore { file, (s) => s.type === "watch" && s.family === p.family && s.address === p.address, ); - if (dup) { - file.activeAccount = dup; - this.#write(file); - return { accountId: dup, created: false }; - } + // Registering a watch account does NOT make it active (§3.3): it holds no key, so making + // it the active account turns the next write command into `watch_only_no_signer` for a + // reason the user never chose. `use ` is how the active account changes. + if (dup) return { accountId: dup, created: false }; const walletId = this.#freshId("wlt", file); // no encrypted blob: a watch account holds no secret, only family+address. const wallet: Wallet = { @@ -204,7 +203,6 @@ export class Keystore { const ref = accountRefOf(wallet, null); file.wallets.push(wallet); this.#assignLabel(file, ref, p.label); - file.activeAccount = ref; this.#write(file); return { accountId: ref, created: true }; }); @@ -598,10 +596,14 @@ export class Keystore { if (v.startsWith("wlt_")) return v; // address form (T… / 0x…): match the unique account holding it in its cache. if (familyOf(v) !== undefined) { + // Canonicalised on BOTH sides: enumerateAddresses already yields EIP-55, and §1.3 accepts an + // all-lower or all-upper EVM address as input. Comparing raw strings would refuse to find an + // account by the very spelling the user was told is valid. + const wanted = canonicalAddress(v); const hits: AccountRef[] = []; for (const w of file.wallets) { for (const { index, addr } of enumerateAddresses(w)) { - if (CHAIN_FAMILIES.some((f) => addr[f] === v)) hits.push(accountRefOf(w, index)); + if (CHAIN_FAMILIES.some((f) => addr[f] === wanted)) hits.push(accountRefOf(w, index)); } } if (hits.length === 0) diff --git a/ts/src/adapters/outbound/keystore/keystore.test.ts b/ts/src/adapters/outbound/keystore/keystore.test.ts index f69a6ad2f..beb853e9d 100644 --- a/ts/src/adapters/outbound/keystore/keystore.test.ts +++ b/ts/src/adapters/outbound/keystore/keystore.test.ts @@ -83,7 +83,7 @@ describe("Keystore", () => { expect(ks.list()).toHaveLength(1); }); - it("makes every imported or derived target active, including dedup hits", () => { + it("makes every imported or derived SIGNING target active, including dedup hits", () => { const seed = ks.import({ secret: MNEMONIC, type: "seed", label: "seed" }); const privateKey = ks.import({ secret: "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", @@ -99,8 +99,10 @@ describe("Keystore", () => { const ledger = ks.registerLedger({ family: "tron", path: "m/44'/195'/0'/0/0", address: TRON0 }); expect(ks.activeAccount()).toBe(ledger.accountId); + // A watch account is the exception (§3.3): it holds no key, so making it active would turn + // the next write command into watch_only_no_signer for a reason the user never chose. const watch = ks.registerWatch({ family: "tron", address: "Twatch-active" }); - expect(ks.activeAccount()).toBe(watch.accountId); + expect(ks.activeAccount()).toBe(ledger.accountId); const repeatedLedger = ks.registerLedger({ family: "tron", diff --git a/ts/src/adapters/outbound/price/coingecko.test.ts b/ts/src/adapters/outbound/price/coingecko.test.ts index af8fdd7f1..2d04af541 100644 --- a/ts/src/adapters/outbound/price/coingecko.test.ts +++ b/ts/src/adapters/outbound/price/coingecko.test.ts @@ -129,35 +129,28 @@ describe("CoinGeckoPriceProvider — EVM", () => { }); /** - * Testnets inherit their mainnet's price, in BOTH families. + * Testnets are no longer priced here at all. * - * TRON already did this (`tron:nile` matches the `tron:` prefix), and the same command was - * reporting USD values on Nile while showing nulls on Sepolia. The valuation is fictional - * either way — testnet coins are not worth money — so the choice is which fiction to tell - * consistently, and the ruling is to tell the same one on both chains. - * - * The mapping is EXPLICIT, never a prefix: `evm:11155111` starts with `evm:1`, so a startsWith - * rule would price Sepolia as Ethereum by accident, and would also price Gnosis (`evm:100`) - * as Ethereum, which is simply wrong. + * The 2026-08-24 ruling: a test coin is not traded, so its holdings are worth ZERO — a fact, + * not a lookup. `TestnetZeroPriceProvider` answers before this class is reached, and the ids + * were removed from the maps so a future edit cannot quietly re-enable mainnet pricing for a + * chain whose coins are free. */ - it.each([ - ["evm:11155111", "ethereum"], - ["evm:97", "binancecoin"], - ])("prices the testnet %s from its mainnet coin (%s)", async (networkId, coinId) => { - const spy = stub({ [coinId]: { usd: 2500 } }); + it.each(["evm:11155111", "evm:97"])("no longer prices the testnet %s at all", async (networkId) => { + const spy = stub({ ethereum: { usd: 2500 }, binancecoin: { usd: 600 } }); - expect(await new CoinGeckoPriceProvider().nativeUsd(networkId)).toBe(2500); - expect(String(spy.mock.calls[0]![0])).toContain(`ids=${coinId}`); + expect(await new CoinGeckoPriceProvider().nativeUsd(networkId)).toBeNull(); + expect(spy).not.toHaveBeenCalled(); }); - it.each([ - ["evm:11155111", "ethereum"], - ["evm:97", "binance-smart-chain"], - ])("prices %s's tokens against its mainnet platform (%s)", async (networkId, platform) => { + // The exposure this closes: deterministic deployment can put one address on both chains, so a + // testnet token looked up against a mainnet platform could take a real token's price. + it.each(["evm:11155111", "evm:97"])("does not look up %s's tokens either", async (networkId) => { const spy = stub({ "0xabc": { usd: 1 } }); - await new CoinGeckoPriceProvider().tokenUsd(networkId, ["0xabc"]); + const prices = await new CoinGeckoPriceProvider().tokenUsd(networkId, ["0xabc"]); - expect(String(spy.mock.calls[0]![0])).toContain(`/token_price/${platform}?`); + expect(prices.get("0xabc")).toBeNull(); + expect(spy).not.toHaveBeenCalled(); }); // The counterpart to inheritance: an id that merely SHARES A PREFIX with a known one must not diff --git a/ts/src/adapters/outbound/price/coingecko.ts b/ts/src/adapters/outbound/price/coingecko.ts index 58efe3758..188a64ae0 100644 --- a/ts/src/adapters/outbound/price/coingecko.ts +++ b/ts/src/adapters/outbound/price/coingecko.ts @@ -10,36 +10,32 @@ export class CoinGeckoPriceProvider implements PriceProvider { /** * CoinGecko native coin ids. * - * A testnet inherits its mainnet's price, in every family: TRON does so through the `tron:` - * prefix, and each EVM testnet is listed EXPLICITLY beside its mainnet. The explicit listing is - * the point — a bare `evm:` prefix would price every EVM chain as Ethereum, so an unlisted - * chain like Gnosis (`evm:100`) would be valued in ETH, which is a claim about money that - * nobody made. An unknown chain is worth `null`, not a guess. + * MAINNETS ONLY. Test networks never reach this map — they are answered as zero before the + * lookup (see TestnetZeroPriceProvider), because their coins are not traded. * - * The cost of this rule is that testnet coins are valued as if they were real. That is a - * deliberate ruling for consistency with the TRON side, which has always behaved this way. + * Each chain is listed EXPLICITLY: a bare `evm:` prefix would price every EVM chain as + * Ethereum, so an unlisted chain like Gnosis (`evm:100`) would be valued in ETH — a claim + * about money that nobody made. An unknown chain is worth `null`, not a guess. + * + * `tron:` keeps its prefix form because TRON's mainnet id is `tron:mainnet`; its testnets are + * likewise intercepted before they arrive here. */ static readonly #NATIVE_IDS: Record = { "tron:": "tron", "evm:1": "ethereum", - "evm:11155111": "ethereum", // Sepolia "evm:56": "binancecoin", - "evm:97": "binancecoin", // BSC testnet }; /** * CoinGecko asset-platform slugs for token_price lookups; same keying rule as above. * - * A testnet contract is looked up against its MAINNET platform, which is usually a miss and so - * usually null. It is not guaranteed to be: deterministic deployment can place the same address - * on both chains, in which case a testnet token would take a mainnet token's price. TRON has - * always had this exposure through its prefix; the EVM entries now share it. + * Mainnets only, for the same reason as above — which also closes a real exposure: a testnet + * contract used to be looked up against its MAINNET platform, and deterministic deployment can + * put the same address on both chains, so a testnet token could take a real token's price. */ static readonly #PLATFORMS: Record = { "tron:": "tron", "evm:1": "ethereum", - "evm:11155111": "ethereum", "evm:56": "binance-smart-chain", - "evm:97": "binance-smart-chain", }; constructor( diff --git a/ts/src/adapters/outbound/price/index.ts b/ts/src/adapters/outbound/price/index.ts index 89a579c64..138896ebc 100644 --- a/ts/src/adapters/outbound/price/index.ts +++ b/ts/src/adapters/outbound/price/index.ts @@ -22,8 +22,45 @@ export class NullPriceProvider implements PriceProvider { } } +/** + * A test network's coin is not traded, so its holdings are worth nothing — and that is a fact to + * state, not one to look up. + * + * Zero, not null: `null` means "we could not find out", and on a testnet we did not fail to find + * out — there is nothing to find. The previous behaviour priced testnet coins off their mainnet + * ticker, which valued Sepolia ETH at thousands of dollars; a valuation is a claim about money, + * and that one was false. + * + * It also spares every testnet `portfolio` a round trip to the price API. + */ +class TestnetZeroPriceProvider implements PriceProvider { + constructor( + private readonly inner: PriceProvider, + private readonly testnets: ReadonlySet, + ) {} + get source(): string { + return this.inner.source; + } + async nativeUsd(networkId: string): Promise { + return this.testnets.has(networkId) ? 0 : this.inner.nativeUsd(networkId); + } + async tokenUsd(networkId: string, contracts: string[]): Promise> { + return this.testnets.has(networkId) + ? new Map(contracts.map((contract) => [contract, 0])) + : this.inner.tokenUsd(networkId, contracts); + } +} + /** build the provider from config (`price:`). Missing → CoinGecko default. */ -export function createPriceProvider(price?: PriceConfig, timeoutMs?: number): PriceProvider { +export function createPriceProvider( + price?: PriceConfig, + timeoutMs?: number, + testnets: ReadonlySet = new Set(), +): PriceProvider { + // `provider: none` is the user switching valuation off entirely; nothing to layer on top. if (price?.provider === "none") return new NullPriceProvider(); - return new CoinGeckoPriceProvider(price?.baseUrl, timeoutMs); + return new TestnetZeroPriceProvider( + new CoinGeckoPriceProvider(price?.baseUrl, timeoutMs), + testnets, + ); } diff --git a/ts/src/adapters/outbound/price/price.test.ts b/ts/src/adapters/outbound/price/price.test.ts index fc6e28621..9facaca4a 100644 --- a/ts/src/adapters/outbound/price/price.test.ts +++ b/ts/src/adapters/outbound/price/price.test.ts @@ -18,9 +18,34 @@ describe("NullPriceProvider", () => { }); describe("createPriceProvider", () => { - it("provider:none → NullPriceProvider; default → CoinGecko", () => { + // `provider: none` is the user switching valuation off; anything else is CoinGecko behind the + // testnet layer, so the constructed provider is no longer the CoinGecko instance itself. + it("provider:none → NullPriceProvider; default → a priced provider", () => { expect(createPriceProvider({ provider: "none" })).toBeInstanceOf(NullPriceProvider); - expect(createPriceProvider(undefined)).toBeInstanceOf(CoinGeckoPriceProvider); - expect(createPriceProvider({ provider: "coingecko" })).toBeInstanceOf(CoinGeckoPriceProvider); + expect(createPriceProvider(undefined).source).toBe("coingecko"); + expect(createPriceProvider({ provider: "coingecko" }).source).toBe("coingecko"); + }); + + /** + * §4.2 / C5: a test network's coin is not traded, so its holdings are worth zero — and ZERO, + * not null. `null` means "we could not find out"; on a testnet there is nothing to find out, + * and the honest answer is that the money is not real. + */ + describe("testnet valuation", () => { + const provider = createPriceProvider(undefined, undefined, new Set(["evm:11155111"])); + + it("values a testnet coin at zero without asking anyone", async () => { + expect(await provider.nativeUsd("evm:11155111")).toBe(0); + }); + + it("values testnet tokens at zero too", async () => { + const prices = await provider.tokenUsd("evm:11155111", ["0xabc", "0xdef"]); + expect([...prices.values()]).toEqual([0, 0]); + }); + + // Unknown ≠ worthless: a chain nobody declared a testnet stays unpriced rather than zeroed. + it("leaves an undeclared network to the real provider", async () => { + expect(await provider.nativeUsd("evm:424242")).toBeNull(); + }); }); }); diff --git a/ts/src/application/ports/backup-records.ts b/ts/src/application/ports/backup-records.ts index ea96447c9..0ba1db845 100644 --- a/ts/src/application/ports/backup-records.ts +++ b/ts/src/application/ports/backup-records.ts @@ -14,8 +14,21 @@ export interface BackupRecord { operation: "backup" | "backup --keystore"; /** the account whose secret was exported (its local id at the time). */ accountId: string; - /** that account's on-chain address — the identity that outlives the local id. */ + /** + * The exported key's on-chain address — the identity that outlives the local id. + * + * For `backup --keystore` this is the address of the family whose key was written, NOT the + * account's TRON address: a seed account holds a different key per family (§1.2), and logging + * one family's export under another family's address makes the trail name the wrong key. + */ account: string; + /** + * The family whose key was exported, when exactly one was. + * + * Absent for a native `backup`: a mnemonic (or a raw private key) covers every family at once, + * so naming one of them would be a narrower claim than what actually left the machine. + */ + family?: string; label: string | null; /** the file the secret was written to (absolute path, as reported by the writer). */ out: string; diff --git a/ts/src/application/ports/chain/gateway-provider.ts b/ts/src/application/ports/chain/gateway-provider.ts index ef3b275fe..d65ba22e9 100644 --- a/ts/src/application/ports/chain/gateway-provider.ts +++ b/ts/src/application/ports/chain/gateway-provider.ts @@ -24,6 +24,8 @@ export interface EvmGateway extends NativeBalanceReader, Broadcaster { /** the node's block object verbatim (hex quantities, seconds); null when absent. * Takes a decimal height or a block tag ("latest", "finalized", "safe"). */ getBlock(numberOrTag?: string): Promise; + /** the chain id as the node reports it, decimal. */ + chainId(): Promise; /** false when synced, else the node's progress object. */ syncing(): Promise; /** connected peers; hosted endpoints commonly refuse this call. */ diff --git a/ts/src/application/services/approve-receipt.test.ts b/ts/src/application/services/approve-receipt.test.ts new file mode 100644 index 000000000..0d7740e6f --- /dev/null +++ b/ts/src/application/services/approve-receipt.test.ts @@ -0,0 +1,90 @@ +/** + * The approve receipt (§7.2) — shared by both families, because TRC20 and ERC-20 share the method, + * the hazard, and the unreadable argument. + */ +import { describe, it, expect, vi } from "vitest"; +import { approveRows } from "./approve-receipt.js"; +import { fromBaseUnits } from "../../domain/amounts/index.js"; + +const SPENDER = "0x4f2a000000000000000000000000000000009b03"; +const params = (allowance: string) => [ + { type: "address", value: SPENDER }, + { type: "uint256", value: allowance }, +]; +const base = { displayAddress: (v: string) => v, fromBaseUnits }; + +describe("approveRows", () => { + it("scales the allowance by the token's own decimals", async () => { + await expect( + approveRows({ + ...base, + method: "approve(address,uint256)", + params: params("1000000"), + metadata: async () => ({ decimals: 6, symbol: "USDC" }), + }), + ).resolves.toEqual({ spender: SPENDER, allowance: "1", allowanceDecimals: 6, token: "USDC" }); + }); + + // 2^256-1 is the approval that never runs out; 78 digits say only that the number is long, and + // no decimals can make that readable — so it does not even ask the contract. + it("calls the maximum unlimited without reading metadata", async () => { + const metadata = vi.fn(async () => ({ decimals: 6 })); + const rows = await approveRows({ + ...base, + method: "approve(address,uint256)", + params: params(String((1n << 256n) - 1n)), + metadata, + }); + + expect(rows).toEqual({ spender: SPENDER, allowance: "unlimited" }); + expect(metadata).not.toHaveBeenCalled(); + }); + + // Being unable to LABEL the amount has no bearing on the approval itself. + it("falls back to base units when decimals cannot be read", async () => { + await expect( + approveRows({ + ...base, + method: "approve(address,uint256)", + params: params("1000000"), + metadata: async () => { + throw new Error("no decimals()"); + }, + }), + ).resolves.toMatchObject({ allowance: "1000000" }); + }); + + it("writes the spender in the family's own display form", async () => { + const rows = await approveRows({ + ...base, + displayAddress: () => "TBhCfAytweLuLLL2gr8xxxxxxxxxxxxxxx", + method: "approve(address,uint256)", + params: params("1"), + metadata: async () => ({ decimals: 0 }), + }); + + expect(rows.spender).toBe("TBhCfAytweLuLLL2gr8xxxxxxxxxxxxxxx"); + }); + + // Spacing is a typing habit, not a different method. + it("matches the signature regardless of spacing", async () => { + await expect( + approveRows({ + ...base, + method: "approve(address, uint256)", + params: params("1"), + metadata: async () => ({ decimals: 0 }), + }), + ).resolves.toMatchObject({ allowance: "1" }); + }); + + it.each([ + ["another method", { method: "transfer(address,uint256)", params: params("1") }], + ["a missing argument", { method: "approve(address,uint256)", params: [{ value: SPENDER }] }], + ["a non-numeric allowance", { method: "approve(address,uint256)", params: params("many") }], + ])("adds nothing for %s", async (_name, input) => { + await expect( + approveRows({ ...base, ...input, metadata: async () => ({ decimals: 6 }) }), + ).resolves.toEqual({}); + }); +}); diff --git a/ts/src/application/services/approve-receipt.ts b/ts/src/application/services/approve-receipt.ts new file mode 100644 index 000000000..47027486b --- /dev/null +++ b/ts/src/application/services/approve-receipt.ts @@ -0,0 +1,70 @@ +/** + * `approve(address,uint256)` in the terms a person can check. + * + * This is the one call where the number on the command line is unreadable: an allowance is a + * `uint256` scaled by the token's own decimals, and the maximum is 78 digits. Approving is also + * the operation that most often costs people their funds, so the receipt states WHO was approved + * and FOR HOW MUCH rather than leaving the caller to check their own arithmetic (§7.2). + * + * Family-neutral because the danger is: TRC20 and ERC-20 share this method, this hazard, and this + * unreadable argument. Only two things differ per family — how a spender address is written, and + * where the token's decimals come from — so both arrive as parameters. + * + * Note this decodes NOTHING: the caller typed the method signature and its arguments, so the + * meaning is already stated. That is what separates it from `tx info`, which deliberately refuses + * to guess at calldata it was not told the shape of. + */ + +/** `2^256-1`: the "no expiry, no ceiling" allowance every dapp asks for. */ +const MAX_UINT256 = (1n << 256n) - 1n; + +/** a signature with its spacing normalised, so `approve(address, uint256)` matches too. */ +function normalizeSignature(signature?: string): string { + return (signature ?? "").replace(/\s+/g, ""); +} + +export interface ApproveContext { + method?: string; + params?: Array<{ value?: unknown }>; + /** the token's decimals and symbol; may fail — labelling is not worth failing the call over. */ + metadata: () => Promise<{ decimals?: number; symbol?: string }>; + /** the spender address in the family's own display form (TRON hex → base58, EVM as-is). */ + displayAddress?: (value: string) => string; + /** base units → whole units, the family's own scaling. */ + fromBaseUnits: (raw: string, decimals: number) => string; +} + +/** + * The `spender` / `allowance` fields for an approve call, or nothing at all for any other method. + * + * `unlimited` short-circuits before the metadata read: the 78-digit form tells the reader only + * that the number is long, and no decimals can make it readable. + */ +export async function approveRows(ctx: ApproveContext): Promise> { + if (normalizeSignature(ctx.method) !== "approve(address,uint256)") return {}; + const spenderRaw = ctx.params?.[0]?.value; + const raw = ctx.params?.[1]?.value; + if (typeof spenderRaw !== "string" || raw === undefined) return {}; + let amount: bigint; + try { + amount = BigInt(String(raw)); + } catch { + return {}; + } + const spender = (ctx.displayAddress ?? ((v: string) => v))(spenderRaw); + if (amount === MAX_UINT256) return { spender, allowance: "unlimited" }; + + const meta = await ctx.metadata().catch(() => ({}) as { decimals?: number; symbol?: string }); + return { + spender, + // Unreadable decimals degrade to the base-unit integer rather than failing the call: our + // ability to LABEL the amount has no bearing on the approval itself. + allowance: + meta.decimals === undefined + ? amount.toString(10) + : ctx.fromBaseUnits(amount.toString(10), meta.decimals), + ...(meta.decimals === undefined ? {} : { allowanceDecimals: meta.decimals }), + // Labels the amount — "1 USDC" rather than a bare 1. + ...(typeof meta.symbol === "string" && meta.symbol !== "" ? { token: meta.symbol } : {}), + }; +} diff --git a/ts/src/application/services/confirmations.ts b/ts/src/application/services/confirmations.ts new file mode 100644 index 000000000..6eea3aa3e --- /dev/null +++ b/ts/src/application/services/confirmations.ts @@ -0,0 +1,24 @@ +/** + * How deep a transaction is buried — the one number `--wait` does not answer. + * + * `--wait` stops at the receipt, which is inclusion, not finality. §6.4 leaves "how many + * confirmations are enough" to the caller and gives them this to judge by, so it is reported + * identically on every family rather than each computing its own variant. + */ + +/** + * `head - block`, when both are known. + * + * The including block is NOT counted, so a transaction just mined reports 0 — §6.4 fixes the + * arithmetic that way, and it is the reading that makes "0 confirmations" mean what it says. + * + * Absent rather than 0 when the head could not be read: "we could not ask" and "nothing has been + * built on top yet" are different claims, and only the second is about the chain. A negative + * result (a head read that lags the block, as a load-balanced endpoint can produce) is likewise + * omitted rather than reported. + */ +export function confirmationsOf(head: unknown, block: unknown): { confirmations?: number } { + if (head === undefined || head === null || block === undefined || block === null) return {}; + const depth = Number(head) - Number(block); + return Number.isFinite(depth) && depth >= 0 ? { confirmations: depth } : {}; +} diff --git a/ts/src/application/services/evm-confirmation.ts b/ts/src/application/services/evm-confirmation.ts index 5fddd5485..4d873291a 100644 --- a/ts/src/application/services/evm-confirmation.ts +++ b/ts/src/application/services/evm-confirmation.ts @@ -31,6 +31,9 @@ export function evmConfirmation( ...(receipt.blockNumber === undefined ? {} : { blockNumber: receipt.blockNumber }), ...(receipt.gasUsed === undefined ? {} : { gasUsed: receipt.gasUsed }), ...(receipt.feeWei === undefined ? {} : { feeWei: receipt.feeWei }), + ...(receipt.effectiveGasPriceWei === undefined + ? {} + : { effectiveGasPriceWei: receipt.effectiveGasPriceWei }), ...(receipt.contractAddress === undefined ? {} : { contractAddress: receipt.contractAddress }), diff --git a/ts/src/application/services/recipient-resolver.test.ts b/ts/src/application/services/recipient-resolver.test.ts index df87185e0..220b1c4a1 100644 --- a/ts/src/application/services/recipient-resolver.test.ts +++ b/ts/src/application/services/recipient-resolver.test.ts @@ -66,8 +66,11 @@ describe("RecipientResolver — EVM", () => { expect(resolver.resolve("evm", EVM)).toEqual({ address: EVM }); }); - it("accepts an unchecksummed EVM address", () => { - expect(resolver.resolve("evm", EVM.toLowerCase())).toEqual({ address: EVM.toLowerCase() }); + // §1.3 takes an all-lowercase address as "no checksum was offered" and accepts it — but what + // comes back is the canonical spelling, so the receipt shows the same address the wallet does + // rather than a second style the reader has to compare character by character. + it("accepts an unchecksummed EVM address and returns it in EIP-55", () => { + expect(resolver.resolve("evm", EVM.toLowerCase())).toEqual({ address: EVM }); }); // The TRON guard, now for EVM: a near-miss must not fall through to a name lookup. @@ -183,3 +186,46 @@ describe("RecipientResolver explains a contact from another chain", () => { expect(code).toBe("contact_not_found"); }); }); + +/** + * `--to` takes an address OR a contact name, so when a value is neither, the answer has to say + * which of the two it is reporting on. `contact not found: 0xnotanaddress` told someone who had + * mistyped an address to go looking for a contact they never made — and said the same words twice + * (the code already says "contact not found"). + */ +describe("RecipientResolver — a value that is neither", () => { + const resolver = new RecipientResolver({ + find: () => undefined, + findAnywhere: () => undefined, + } as never); + + it("reports a value that opens like an address as a failed address", () => { + const error = (() => { + try { + resolver.resolve("evm", "0xnotanaddress"); + } catch (e) { + return e as { code: string; message: string }; + } + throw new Error("expected a rejection"); + })(); + + expect(error.code).toBe("invalid_address"); + // and still points at the other thing --to accepts + expect(error.message).toMatch(/no contact is named that either/); + }); + + it("reports anything else as a failed name, mentioning addresses", () => { + const error = (() => { + try { + resolver.resolve("evm", "nosuchname"); + } catch (e) { + return e as { code: string; message: string }; + } + throw new Error("expected a rejection"); + })(); + + expect(error.code).toBe("contact_not_found"); + expect(error.message).toMatch(/not an address either/); + }); +}); + diff --git a/ts/src/application/services/recipient-resolver.ts b/ts/src/application/services/recipient-resolver.ts index 2e3c6dafd..8dacf217b 100644 --- a/ts/src/application/services/recipient-resolver.ts +++ b/ts/src/application/services/recipient-resolver.ts @@ -19,7 +19,9 @@ export class RecipientResolver { const value = input.trim(); if (addressCodec(family).validate(value)) { - return { address: value }; + // Canonical (§1.3): what goes into the transaction is what the receipt will show, so a + // lowercase paste does not come back looking like a different recipient. + return { address: addressCodec(family).canonical(value) }; } // The family the value LOOKS like — by shape, so a mistyped address still names its own @@ -38,7 +40,7 @@ export class RecipientResolver { ); } throw new UsageError( - "invalid_value", + "invalid_address", `recipient resembles a ${family} address but has an invalid length or checksum`, ); } @@ -58,6 +60,32 @@ export class RecipientResolver { `contact ${elsewhere.name} holds the address ${elsewhere.address}, which the selected network cannot pay`, ); } - throw new UsageError("contact_not_found", `contact not found: ${value}`); + // Neither an address nor a name in the book. WHICH of the two the user meant is knowable only + // from how the value starts: `--to` takes either, so an answer that names just one of them + // sends half the callers looking in the wrong place. A value that opens like an address is + // reported as a failed address (and still mentions the book); anything else is reported as a + // failed name (and still mentions addresses). + if (looksLikeAddressAttempt(value)) { + throw new UsageError( + "invalid_address", + `${value} is not a valid ${family} address, and no contact is named that either`, + ); + } + throw new UsageError( + "contact_not_found", + `no contact named ${value}, and it is not an address either`, + ); } } + +/** + * Did the caller mean this to be an address? + * + * Not "is it a valid address" (that is settled above) and not "is it address-SHAPED" (a near-miss, + * also settled above) — this is the weaker question of whether it OPENS like one. `0xnotanaddress` + * is neither valid nor shaped, but nobody types `0x` while reaching for a contact name. + */ +function looksLikeAddressAttempt(value: string): boolean { + return /^0x/i.test(value) || /^T[1-9A-HJ-NP-Za-km-z]{10,}$/.test(value); +} + diff --git a/ts/src/application/use-cases/config-service.ts b/ts/src/application/use-cases/config-service.ts index f79a7d4db..22ea03ba0 100644 --- a/ts/src/application/use-cases/config-service.ts +++ b/ts/src/application/use-cases/config-service.ts @@ -1,4 +1,4 @@ -import type { Config } from "../../domain/types/index.js"; +import { endpointHost, type Config } from "../../domain/types/index.js"; import type { NetworkRegistry } from "../ports/network-registry.js"; import { UsageError } from "../../domain/errors/index.js"; import type { ConfigDocumentRepository } from "../ports/config-document-repository.js"; @@ -198,15 +198,6 @@ function assertWritableNetworkField(field: string): void { } } -function endpointHost(url: unknown): string { - if (typeof url !== "string") return ""; - try { - return new URL(url).host; - } catch { - return ""; - } -} - function httpsEndpoint(value: string, key: string): string { let parsed: URL; try { 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 015572a9f..d2bada072 100644 --- a/ts/src/application/use-cases/evm/account-service.test.ts +++ b/ts/src/application/use-cases/evm/account-service.test.ts @@ -43,12 +43,19 @@ describe("EvmAccountService.info", () => { }); // `eth_getCode` answers this and nothing else does: "0x" is an externally-owned account. - it("marks an address with no code as not a contract", async () => { - expect((await service({ code: "0x" }).info(scope, net)).isContract).toBe(false); + it("marks an address with no code as an EOA, and gives it no code size", async () => { + const out = await service({ code: "0x" }).info(scope, net); + + expect(out.type).toBe("eoa"); + // Not zero: an EOA is not a contract that happens to have no bytes. + expect(out).not.toHaveProperty("codeSize"); }); - it("marks an address carrying bytecode as a contract", async () => { - expect((await service({ code: "0x60806040" }).info(scope, net)).isContract).toBe(true); + it("marks an address carrying bytecode as a contract and sizes its code", async () => { + const out = await service({ code: "0x60806040" }).info(scope, net); + + expect(out.type).toBe("contract"); + expect(out.codeSize).toBe(4); }); it("keeps the nonce a decimal string, so a large one cannot lose precision", async () => { diff --git a/ts/src/application/use-cases/evm/account-service.ts b/ts/src/application/use-cases/evm/account-service.ts index 20474dee8..ae59d8506 100644 --- a/ts/src/application/use-cases/evm/account-service.ts +++ b/ts/src/application/use-cases/evm/account-service.ts @@ -108,6 +108,10 @@ export class EvmAccountService { gateway.getTransactionCount(address), gateway.getCode(address), ]); + // "0x" is the empty-code answer, i.e. an externally-owned account. `type` rather than a + // boolean (§4.3): it is the field agents match on, and it has room for a third kind of + // account without every reader having to relearn the meaning of a flag. + const isContract = code !== "0x" && code !== ""; return { address, balance, @@ -115,8 +119,10 @@ export class EvmAccountService { nonce, decimals: FAMILIES.evm.nativeDecimals, symbol: network.nativeSymbol, - // "0x" is the empty-code answer, i.e. an externally-owned account. - isContract: code !== "0x" && code !== "", + type: isContract ? "contract" : "eoa", + // Bytes of deployed code, and only for an account that has some: an EOA reporting 0 would + // be answering a question that does not apply to it. Hex is "0x" + two chars per byte. + ...(isContract ? { codeSize: (code.length - 2) / 2 } : {}), }; } } 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 9e6c5c00f..14efc5d12 100644 --- a/ts/src/application/use-cases/evm/chain-service.test.ts +++ b/ts/src/application/use-cases/evm/chain-service.test.ts @@ -34,6 +34,10 @@ const FINALIZED = { number: "0x12d600" }; function service(over: Partial> = {}) { const gateway = { clientVersion: async () => over.clientVersion ?? "Geth/v1.14.0", + chainId: async () => { + if (over.chainId instanceof Error) throw over.chainId; + return over.chainId ?? "1"; + }, syncing: async () => (over.syncing === undefined ? false : over.syncing), peerCount: async () => { if (over.peerCount instanceof Error) throw over.peerCount; @@ -55,13 +59,44 @@ describe("EvmChainService.node", () => { const out = await service().node(net); expect(out).toMatchObject({ - endpoint: "https://node.example", + // host only, never the configured URL — see the API-key test below + endpoint: "node.example", version: "Geth/v1.14.0", headBlock: { number: 1234567 }, peers: { connected: 25 }, }); }); + /** + * A commercial RPC endpoint carries its API key IN THE URL, and §2.2 tells users to configure + * exactly that. `chain node` is the diagnostic people paste into issues and CI logs, so it + * reports the HOST and nothing else. `config networks..httpEndpoint` is where a full URL is + * handed over — a named read rather than a listing. + */ + it("never echoes an endpoint's path or query, which is where API keys live", async () => { + const withKey = { ...net, httpEndpoint: "https://eth.example/v2/SECRET-KEY?apikey=ALSO-SECRET" }; + const out = await service().node(withKey as NetworkDescriptor); + + expect(out.endpoint).toBe("eth.example"); + expect(JSON.stringify(out)).not.toContain("SECRET"); + }); + + /** + * Asked of the NODE rather than read off our own descriptor: the question `chain node` answers + * is whether this endpoint is the chain the caller thinks it is, and our configuration is the + * very thing under suspicion. Every signature commits to this value (EIP-155). + */ + it("reports the chain id the node itself claims", async () => { + expect((await service({ chainId: "11155111" }).node(net)).chainId).toBe("11155111"); + }); + + it("degrades the chain id to null rather than failing the command", async () => { + const out = await service({ chainId: new ChainError("rpc_error", "method not found") }).node(net); + + expect(out.chainId).toBeNull(); + expect(out.headBlock).toMatchObject({ number: 1234567 }); + }); + it("maps the finalized block to the solid block and derives the lag", async () => { const out = await service().node(net); @@ -110,17 +145,33 @@ describe("EvmChainService.prices", () => { return svc.prices({ ...net, ...(declared ? { feeModel: declared } : {}) } as NetworkDescriptor); } - it("reports the 1559 fee fields on a chain with a base fee", async () => { + /** + * §9.3: `gasPriceWei` is base + tip — the price a transaction actually pays — NOT the node's + * `eth_gasPrice` suggestion. Printing that beside the two components gave three numbers that + * did not add up (here: 155,353,216 vs the true 155,415,168). + */ + it("reports the 1559 fee fields, with the gas price as base + tip", async () => { await expect( priced({ baseFeeWei: "155315168", gasPriceWei: "155353216", suggestedPriorityWei: "100000" }), ).resolves.toEqual({ feeModel: "eip1559", baseFeeWei: "155315168", priorityFeeWei: "100000", - gasPriceWei: "155353216", + gasPriceWei: "155415168", + transferGas: 21000, + // and what that price means in money: 21,000 gas is the protocol's fixed transfer cost. + transferCostWei: String(155415168n * 21000n), }); }); + // A unit price does not tell most readers whether gas is expensive; a transfer's cost does. + it("translates the gas price into what a plain transfer costs, on legacy too", async () => { + const out = await priced({ gasPriceWei: "3000000000" }); + + expect(out.transferGas).toBe(21000); + expect(out.transferCostWei).toBe(String(3000000000n * 21000n)); + }); + // BSC: base fee zero is still EIP-1559, and the reported model must say so. it("calls a zero base fee EIP-1559, not legacy", async () => { await expect( diff --git a/ts/src/application/use-cases/evm/chain-service.ts b/ts/src/application/use-cases/evm/chain-service.ts index 75d9e7e28..a3a533143 100644 --- a/ts/src/application/use-cases/evm/chain-service.ts +++ b/ts/src/application/use-cases/evm/chain-service.ts @@ -1,7 +1,11 @@ -import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import { endpointHost, type NetworkDescriptor } from "../../../domain/types/index.js"; import { evmFeeMode } from "../../../domain/fees/evm-gas.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 + * its per-gas numbers into a real spend with. */ +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; @@ -32,14 +36,26 @@ export class EvmChainService { async prices(network: NetworkDescriptor) { const fee = await this.gateways.get(network, "evm").feeData(); const mode = evmFeeMode(fee.baseFeeWei, network.feeModel); + const eip1559 = mode === "eip1559" && fee.baseFeeWei !== undefined; + const priorityFeeWei = fee.suggestedPriorityWei ?? null; + // On a 1559 chain the price a transfer actually pays is base + tip. `eth_gasPrice` is the + // node's own single-number suggestion, which is not that sum — quoting it beside the two + // components would print three numbers that do not add up (§9.3). + const gasPriceWei = + eip1559 && priorityFeeWei !== null + ? (BigInt(fee.baseFeeWei!) + BigInt(priorityFeeWei)).toString(10) + : fee.gasPriceWei; return { feeModel: mode, // A zero base fee is reported as "0" and not dropped: on BSC that IS the base fee, and the // difference between "zero" and "absent" is the difference between the two fee models. - ...(mode === "eip1559" && fee.baseFeeWei !== undefined - ? { baseFeeWei: fee.baseFeeWei, priorityFeeWei: fee.suggestedPriorityWei ?? null } - : {}), - gasPriceWei: fee.gasPriceWei, + ...(eip1559 ? { baseFeeWei: fee.baseFeeWei, priorityFeeWei } : {}), + gasPriceWei, + // A unit price answers "how expensive is gas", not "what will this cost me". 21,000 is the + // protocol's fixed cost of a plain transfer, so the translation is exact rather than an + // estimate — the same intent as TRON's `Memo fee` row. + transferGas: NATIVE_TRANSFER_GAS, + transferCostWei: (BigInt(gasPriceWei) * BigInt(NATIVE_TRANSFER_GAS)).toString(10), }; } @@ -53,12 +69,15 @@ export class EvmChainService { */ async node(network: NetworkDescriptor) { const gateway = this.gateways.get(network, "evm"); - const [version, syncing, peers, head, finalized] = await Promise.all([ + const [version, syncing, peers, head, finalized, chainId] = await Promise.all([ optional(() => gateway.clientVersion()), optional(() => gateway.syncing()), optional(() => gateway.peerCount()), gateway.getBlock(), optional(() => gateway.getBlock("finalized")), + // Asked of the NODE, not read off the descriptor: the question this command answers is + // "is this endpoint the chain I think it is", and our own configuration cannot answer that. + optional(() => gateway.chainId()), ]); const headBlock = head as Record | null; @@ -67,8 +86,14 @@ export class EvmChainService { const headTimestamp = quantity(headBlock?.timestamp); return { - endpoint: network.httpEndpoint ?? null, + // HOST only — same reason as the TRON side: an endpoint may carry an API key in its path, + // and `chain node` is a diagnostic people paste around. Full URLs come from + // `config networks..httpEndpoint`, which is a named read rather than a listing. + endpoint: endpointHost(network.httpEndpoint) || null, version, + // EIP-155's chain id, as the node reports it. It is what every signature commits to, so it + // is worth stating where an endpoint can be checked against the chain it claims to serve. + chainId, // EVM nodes expose no p2p protocol version over JSON-RPC; TRON's getnodeinfo does. p2pVersion: null, headBlock: { 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 ff8040877..f9f91c81c 100644 --- a/ts/src/application/use-cases/evm/contract-service.test.ts +++ b/ts/src/application/use-cases/evm/contract-service.test.ts @@ -183,3 +183,107 @@ describe("EvmContractService.deploy", () => { expect(gateway.encodeDeploy).toHaveBeenCalledWith("0x6080", { source: "none" }); }); }); + +/** + * `approve(address,uint256)` — §7.2's one receipt special case. + * + * The uint256 a caller types is scaled by the token's decimals, and its maximum is 78 digits, so + * the one thing they cannot check is the thing that matters most: how much they just approved. + */ +describe("EvmContractService.send — approve", () => { + const SEPOLIA = { ...net, id: "evm:11155111", chainId: "11155111" } as NetworkDescriptor; + const scope = () => + ({ + activeAccount: "wlt_test", + resolveAddress: () => OWNER, + timeoutMs: 100, + wait: false, + waitTimeoutMs: 100, + emit: vi.fn(), + warn: vi.fn(), + }) as never; + const CONTRACT = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + const SPENDER = "0x4f2a000000000000000000000000000000009b03"; + + function approveHarness(decimals: number | Error = 6) { + const gateway = { + getTransactionCount: vi.fn(async () => "7"), + feeData: vi.fn(async () => ({ baseFeeWei: "100", gasPriceWei: "110", suggestedPriorityWei: "10" })), + estimateGas: vi.fn(async () => "46200"), + encodeFunctionCall: vi.fn(() => "0x095ea7b3"), + getErc20Metadata: vi.fn(async () => { + if (decimals instanceof Error) throw decimals; + return { decimals, symbol: "USDC" }; + }), + }; + const pipeline = { + assertCanSign: vi.fn(), + run: vi.fn(async (params: TxPipelineParams) => ({ + stage: "plan" as const, + tx: await params.build(OWNER), + fee: {}, + })), + } as unknown as TxPipeline; + const service = new EvmContractService( + { get: () => gateway } as unknown as ChainGatewayProvider, + pipeline, + ); + return { service, gateway }; + } + + const send = (service: EvmContractService, rawAllowance: string) => + service.send(scope(), SEPOLIA, { + contract: CONTRACT, + method: "approve(address,uint256)", + params: [ + { type: "address", value: SPENDER }, + { type: "uint256", value: rawAllowance }, + ], + dryRun: true, + } as never) as Promise>; + + it("reports the spender and the allowance in the token's own units", async () => { + const { service } = approveHarness(6); + + await expect(send(service, "1000000")).resolves.toMatchObject({ + spender: SPENDER, + allowance: "1", + // labelled, so the receipt reads "1 USDC" rather than a bare 1 + token: "USDC", + }); + }); + + // 2^256-1 is the approval that never runs out; 78 digits say only that the number is long. + it("calls the maximum allowance unlimited, without asking the contract anything", async () => { + const { service, gateway } = approveHarness(6); + + await expect(send(service, String((1n << 256n) - 1n))).resolves.toMatchObject({ + allowance: "unlimited", + }); + expect(gateway.getErc20Metadata).not.toHaveBeenCalled(); + }); + + // Being unable to LABEL the amount must not stop the approval or invent a scale for it. + it("falls back to base units when the token's decimals cannot be read", async () => { + const { service } = approveHarness(new Error("no decimals()")); + + await expect(send(service, "1000000")).resolves.toMatchObject({ allowance: "1000000" }); + }); + + it("adds nothing for any other method", async () => { + const { service } = approveHarness(6); + const out = (await service.send(scope(), SEPOLIA, { + contract: CONTRACT, + method: "transfer(address,uint256)", + params: [ + { type: "address", value: SPENDER }, + { type: "uint256", value: "1000000" }, + ], + dryRun: true, + } as never)) as Record; + + expect(out).not.toHaveProperty("spender"); + expect(out).not.toHaveProperty("allowance"); + }); +}); + diff --git a/ts/src/application/use-cases/evm/contract-service.ts b/ts/src/application/use-cases/evm/contract-service.ts index 78ce75680..da29f5087 100644 --- a/ts/src/application/use-cases/evm/contract-service.ts +++ b/ts/src/application/use-cases/evm/contract-service.ts @@ -2,9 +2,10 @@ import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index. import { resolveGasLimit } from "../../services/evm-gas-estimate.js"; import { UsageError } from "../../../domain/errors/index.js"; import { FAMILIES } from "../../../domain/family/index.js"; -import { toBaseUnits } from "../../../domain/amounts/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 type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider, @@ -76,6 +77,7 @@ export class EvmContractService { async send(scope: TransactionScope, network: NetworkDescriptor, input: EvmContractWriteInput) { const gateway = this.gateways.get(network, "evm"); + let nonce: string | undefined; const data = gateway.encodeFunctionCall( input.method!, (input.params ?? []) as Array<{ type: string; value: unknown }>, @@ -85,19 +87,43 @@ export class EvmContractService { ? "0" : toBaseUnits(input.callValue, FAMILIES.evm.nativeDecimals, "call value"); - const outcome = await this.#run(scope, network, gateway, input, { - to: input.contract!, - data, - value, - }); + // Resolved BEFORE the run so `--dry-run` carries it too: the allowance is the one thing a + // dry run of an approve exists to confirm (§6.1 names it one of only two dry-run extras). + const approval = await this.#approval(gateway, input); + + const outcome = await this.#run( + scope, + network, + gateway, + input, + { to: input.contract!, data, value }, + (_from, built) => { + nonce = built; + }, + ); return { kind: "contract-send" as const, ...outcomeData(outcome), + ...(nonce === undefined ? {} : { nonce: Number(nonce) }), + ...approval, contract: input.contract, method: input.method, }; } + /** §7.2's approve receipt; the shared helper does the work, this supplies the EVM specifics. */ + async #approval( + gateway: EvmGateway, + input: EvmContractWriteInput, + ): Promise> { + return approveRows({ + method: input.method, + params: (input.params ?? []) as Array<{ value?: unknown }>, + metadata: () => gateway.getErc20Metadata(input.contract!), + fromBaseUnits, + }); + } + /** * Deploy a contract. The transaction has no recipient — that is what makes it a deployment — * and the address is derived from the sender and nonce rather than waited for, because CREATE @@ -107,6 +133,7 @@ export class EvmContractService { const gateway = this.gateways.get(network, "evm"); const data = gateway.encodeDeploy(input.bytecode!, input.constructorArgs ?? { source: "none" }); let contractAddress: string | undefined; + let nonce: string | undefined; const outcome = await this.#run( scope, @@ -114,13 +141,15 @@ export class EvmContractService { gateway, input, { data, value: "0" }, - (from, nonce) => { - contractAddress = gateway.contractAddressFor(from, nonce); + (from, built) => { + nonce = built; + contractAddress = gateway.contractAddressFor(from, built); }, ); return { kind: "contract-deploy" as const, ...outcomeData(outcome), + ...(nonce === undefined ? {} : { nonce: Number(nonce) }), ...(contractAddress === undefined ? {} : { contractAddress }), }; } @@ -159,10 +188,12 @@ export class EvmContractService { declaredFeeModel: network.feeModel, overrides: overridesOf(input), }); + 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, diff --git a/ts/src/application/use-cases/evm/token-service.test.ts b/ts/src/application/use-cases/evm/token-service.test.ts index a489b9dcd..52959c783 100644 --- a/ts/src/application/use-cases/evm/token-service.test.ts +++ b/ts/src/application/use-cases/evm/token-service.test.ts @@ -127,4 +127,25 @@ describe("EvmTokenService.info", () => { name: "Tether USD", }); }); + + /** + * Metadata is read best-effort, so a thin token still reports. But a contract that answers + * NOTHING is not a token with thin metadata — it is not a token, and `{contract}` alone under + * `success` reads as "this token has no metadata". `add` and `balance` already refuse the same + * address; this is the third command agreeing with them. + */ + it("still reports a token that answers only some of its metadata", async () => { + const { svc } = service({ decimals: 6 }); + + await expect(svc.info(net, { contract: USDT })).resolves.toMatchObject({ decimals: 6 }); + }); + + it("refuses an address that answers none of it, rather than echoing it back", async () => { + const { svc } = service({}); + + await expect(svc.info(net, { contract: USDT })).rejects.toMatchObject({ + code: "token_metadata_unavailable", + message: expect.stringContaining("may not be a token contract"), + }); + }); }); diff --git a/ts/src/application/use-cases/evm/token-service.ts b/ts/src/application/use-cases/evm/token-service.ts index de80151ab..60930d0c1 100644 --- a/ts/src/application/use-cases/evm/token-service.ts +++ b/ts/src/application/use-cases/evm/token-service.ts @@ -30,8 +30,22 @@ export class EvmTokenService { return { address, token: input.contract, balance, ...meta }; } + /** + * Token metadata is read best-effort — a token that answers `decimals` but not `name` is still + * a token, and the missing field is simply absent. But a contract that answers NOTHING is not a + * token whose metadata is thin: it is not a token. Echoing the address back under `success` + * reads as "this token has no metadata", which is a claim about a token that does not exist. + * + * `add` and `balance` already refuse the same address; this makes the third command agree. + */ async info(network: NetworkDescriptor, input: Erc20Selector) { const meta = await this.gateways.get(network, "evm").getErc20Metadata(input.contract); + if (meta.symbol === undefined && meta.decimals === undefined && meta.name === undefined) { + throw new ExecutionError( + "token_metadata_unavailable", + `${input.contract} did not answer symbol, decimals or name — it may not be a token contract`, + ); + } return { contract: input.contract, ...meta }; } 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 620502e8f..3a4907c1c 100644 --- a/ts/src/application/use-cases/evm/transaction-service.test.ts +++ b/ts/src/application/use-cases/evm/transaction-service.test.ts @@ -403,6 +403,37 @@ describe("EvmTransactionService.sign", () => { expect(pipeline.signOnly).not.toHaveBeenCalled(); }); + /** + * The one mistake `tx sign` must not make quietly. + * + * Both transactions are EVM, so `family_mismatch` never fires; and signing consults no node, so + * nothing downstream can catch it either. Without this guard the command hands back a perfectly + * valid MAINNET transaction and says nothing about it. + */ + it("refuses a transaction built for another chain before signing it", async () => { + const { service, pipeline } = signHarness(); + // ethers' own unsignedSerialized for the same shape as UNSIGNED, but chainId 1. + const MAINNET = + "0x02ed0180830f42408478fbb08282520894000000000000000000000000000000000000dead87038d7ea4c6800080c0"; + + await expect(service.sign(scope(), SEPOLIA, MAINNET)).rejects.toMatchObject({ + code: "chain_id_mismatch", + }); + expect(pipeline.signOnly).not.toHaveBeenCalled(); + }); + + // The check runs BEFORE the already-signed check: a foreign-chain transaction is refused for + // being foreign, which is the fact the reader needs, not for carrying a signature. + it("names the chain mismatch even when the foreign transaction is already signed", async () => { + const { service } = signHarness(); + const SIGNED_MAINNET = + "0x02f8700180830f42408478fbb08282520894000000000000000000000000000000000000dead87038d7ea4c6800080c080a0bf5dda9670fd52be2346cdb74cdd238a51a4ae67ac7513fd7540fd78eca31d25a026903a9fcb8d75c83ff1e0177a8dd7061e0ddc7051500a6f1a041be1a2e32ca3"; + + await expect(service.sign(scope(), SEPOLIA, SIGNED_MAINNET)).rejects.toMatchObject({ + code: "chain_id_mismatch", + }); + }); + it("refuses an already-signed transaction instead of double-signing it", async () => { const { service } = signHarness(); const alreadySigned = @@ -453,6 +484,19 @@ describe("EvmTransactionService.broadcast", () => { expect(out.alreadyKnown).toBe(true); }); + // The node would reject it too, but only after it has been sent — and its error would not say + // which chain the transaction was actually built for. + it("refuses a transaction built for another chain without submitting it", async () => { + const { service, seen } = bcHarness(); + const SIGNED_MAINNET = + "0x02f8700180830f42408478fbb08282520894000000000000000000000000000000000000dead87038d7ea4c6800080c080a0bf5dda9670fd52be2346cdb74cdd238a51a4ae67ac7513fd7540fd78eca31d25a026903a9fcb8d75c83ff1e0177a8dd7061e0ddc7051500a6f1a041be1a2e32ca3"; + + await expect(service.broadcast(scope(), SEPOLIA, SIGNED_MAINNET)).rejects.toMatchObject({ + code: "chain_id_mismatch", + }); + expect(seen).toEqual([]); + }); + it("refuses hex that is not a signed transaction", async () => { const { service } = bcHarness(); @@ -525,7 +569,8 @@ describe("EvmTransactionService.broadcast --dry-run", () => { const mainnet = { ...SEPOLIA, id: "evm:1", chainId: "1" }; await expect(service.broadcast(scope(), mainnet as never, SIGNED, true)).rejects.toMatchObject({ - code: "chain_mismatch", + // The spec's code (§6.2/§6.3/§11); the dry run shares the guard the sign and submit paths use. + code: "chain_id_mismatch", }); }); @@ -609,11 +654,15 @@ describe("EvmTransactionService.broadcast --dry-run", () => { * reads getTransactionById beside getTransactionInfoById. */ describe("EvmTransactionService.status", () => { - function statusHarness(tx: unknown, receipt: unknown) { + function statusHarness(tx: unknown, receipt: unknown, head: string | Error = "42") { const warn = vi.fn(); const gateway = { getTransactionByHash: vi.fn(async () => tx), getTransactionReceipt: vi.fn(async () => receipt), + getBlockNumber: vi.fn(async () => { + if (head instanceof Error) throw head; + return head; + }), }; const service = new EvmTransactionService( { get: () => gateway } as unknown as ChainGatewayProvider, @@ -664,6 +713,25 @@ describe("EvmTransactionService.status", () => { // A public endpoint may simply not keep old transactions. Reporting not_found without saying so // invites the reader to conclude the transaction never happened, which may be false. + // Same field, same arithmetic as TRON's: §6.4 makes it a two-family field, not an EVM one. + it("reports head minus the transaction's block as confirmations", async () => { + const { service, scope: s } = statusHarness({ hash: HASH }, { success: true, blockNumber: 5 }, "42"); + + await expect(service.status(s, SEPOLIA, HASH)).resolves.toMatchObject({ confirmations: 37 }); + }); + + it("omits confirmations when the head could not be read, and still answers", async () => { + const { service, scope: s } = statusHarness( + { hash: HASH }, + { success: true, blockNumber: 5 }, + new Error("head unreachable"), + ); + const out = await service.status(s, SEPOLIA, HASH); + + expect(out.state).toBe("confirmed"); + expect(out.confirmations).toBeUndefined(); + }); + it("warns that not_found may mean the node lacks history, not that the tx never existed", async () => { const { service, scope: s, warn } = statusHarness(null, null); await service.status(s, SEPOLIA, HASH); @@ -684,6 +752,9 @@ describe("EvmTransactionService.info", () => { const gateway = { getTransactionByHash: vi.fn(async () => tx), getTransactionReceipt: vi.fn(async () => receipt), + getBlockNumber: vi.fn(async () => "42"), + // seconds on the wire, as an EVM node reports them + getBlock: vi.fn(async () => ({ timestamp: "0x66b1c0d0" })), getErc20Metadata: vi.fn(async () => meta), }; return new EvmTransactionService( @@ -698,8 +769,14 @@ describe("EvmTransactionService.info", () => { it("reports a native transfer's parties and amount", async () => { const svc = infoHarness( - { hash: HASH, from: OWNER, to: TO, value: "0xde0b6b3a7640000", input: "0x" }, - { success: true, blockNumber: 5, gasUsed: "21000", feeWei: "1000" }, + { hash: HASH, from: OWNER, to: TO, value: "0xde0b6b3a7640000", input: "0x", nonce: "0x7" }, + { + success: true, + blockNumber: 5, + gasUsed: "21000", + feeWei: "1000", + effectiveGasPriceWei: "50", + }, ); await expect(svc.info(scope(), SEPOLIA, HASH)).resolves.toMatchObject({ @@ -711,10 +788,54 @@ describe("EvmTransactionService.info", () => { blockNumber: 5, gasUsed: 21000, feeWei: "1000", - status: "SUCCESS", + // §6.5 收斂: one case throughout, so an agent matches "success" and never "SUCCESS". + status: "success", + // §6.5's flat keys, out of the node objects rather than buried in the passthrough + type: "transfer", + nonce: 7, + rawAmount: "1000000000000000000", + blockTime: 1722925264, + effectiveGasPriceWei: "50", }); }); + /** + * `type` is deliberately coarse: three words a reader can act on, and none of them requires + * decoding calldata we have chosen not to decode (see the ERC-20 tests below). + */ + it.each([ + [{ to: TO, input: "0x" }, "transfer"], + [{ to: TO, input: "0xdeadbeef" }, "contract-call"], + [{ to: null, input: "0x6080" }, "contract-creation"], + ])("classifies %o as %s", async (fields, expected) => { + const svc = infoHarness({ hash: HASH, from: OWNER, value: "0x0", ...fields }); + + await expect(svc.info(scope(), SEPOLIA, HASH)).resolves.toMatchObject({ type: expected }); + }); + + // The detail view must not fail because a second, optional read did. + it("still answers when the block's timestamp cannot be read", async () => { + const gateway = { + getTransactionByHash: async () => ({ hash: HASH, from: OWNER, to: TO, value: "0x0", input: "0x" }), + getTransactionReceipt: async () => ({ success: true, blockNumber: 5 }), + getBlockNumber: async () => "42", + getBlock: async () => { + throw new Error("pruned"); + }, + getErc20Metadata: async () => ({}), + }; + const svc = new EvmTransactionService( + { get: () => gateway } as unknown as ChainGatewayProvider, + { effective: () => [] } as never, + {} as never, + { resolve: vi.fn() } as never, + ); + + const out = await svc.info(scope(), SEPOLIA, HASH); + expect(out.blockNumber).toBe(5); + expect(out.blockTime).toBeUndefined(); + }); + // The ruling: decode `transfer(address,uint256)` and nothing else. Reporting the raw fields for // an ERC-20 transfer would name the CONTRACT as the recipient and the amount as zero. it("decodes an ERC-20 transfer to its real recipient and amount", async () => { @@ -776,6 +897,8 @@ describe("EvmTransactionService.info address style", () => { input: "0x", }), getTransactionReceipt: async () => null, + getBlockNumber: async () => "42", + getBlock: async () => ({ timestamp: "0x66b1c0d0" }), getErc20Metadata: async () => ({}), }; const svc = new EvmTransactionService( diff --git a/ts/src/application/use-cases/evm/transaction-service.ts b/ts/src/application/use-cases/evm/transaction-service.ts index 8fb030bd3..2d88ece24 100644 --- a/ts/src/application/use-cases/evm/transaction-service.ts +++ b/ts/src/application/use-cases/evm/transaction-service.ts @@ -14,6 +14,7 @@ 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 type { TransactionScope } from "../../contracts/execution-scope.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; @@ -62,6 +63,10 @@ export class EvmTransactionService { // rather than attached to the transaction: --dry-run and --build-only echo that object // verbatim, and a fee plan riding along inside it reads as part of the transaction. let plan: Record = {}; + // The nonce is decided while building and never appears in a receipt we might not get. It is + // the field §4.3 calls the entry point for diagnosing a stuck transaction, so the receipt + // states it even when the transaction is only submitted. + let nonce: number | undefined; const outcome = await this.pipeline.run({ ctx: scope, net: network, @@ -72,6 +77,7 @@ export class EvmTransactionService { artifact: (tx) => gateway.encodeTransactionHex(tx), build: async (from) => { const { tx, fee } = await this.#build( + scope, gateway, network, from, @@ -80,6 +86,7 @@ export class EvmTransactionService { input, ); plan = fee; + nonce = (tx as { nonce?: number }).nonce; return tx; }, // The plan already carries the ceiling, so there is nothing further to ask the node. @@ -89,6 +96,7 @@ export class EvmTransactionService { return { kind: "send" as const, ...outcomeData(outcome), + ...(nonce === undefined ? {} : { nonce }), rawAmount: transfer.rawAmount, token: transfer.symbol, decimals: transfer.decimals, @@ -170,6 +178,8 @@ export class EvmTransactionService { to: transfer.to, contract, ...(meta.symbol === undefined ? {} : { symbol: meta.symbol }), + // Both forms, as everywhere else: the exact integer for arithmetic, the scaled one to read. + rawAmount: transfer.rawAmount, amount: meta.decimals === undefined ? transfer.rawAmount @@ -178,6 +188,7 @@ export class EvmTransactionService { } async #build( + scope: TransactionScope, gateway: EvmGateway, network: NetworkDescriptor, from: string, @@ -214,6 +225,7 @@ export class EvmTransactionService { ...(input.priorityFee === undefined ? {} : { priorityFeeWei: input.priorityFee }), }, }); + for (const warning of plan.warnings ?? []) scope.warn(warning); return { tx: { @@ -225,7 +237,15 @@ export class EvmTransactionService { ? { type: 2, maxFeePerGas: plan.maxFeeWei, maxPriorityFeePerGas: plan.priorityFeeWei } : { type: 0, gasPrice: plan.gasPriceWei }), }, - fee: { feeModel: plan.mode, maxCostWei: plan.maxCostWei, gasLimit: plan.gasLimit }, + // 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, + }, }; } @@ -237,6 +257,11 @@ export class EvmTransactionService { */ async sign(scope: TransactionScope, network: NetworkDescriptor, hex: string) { const parsed = parseEvmTransaction(hex); + // BEFORE the signature: a transaction states the chain it is for, and signing keeps that + // value. Nothing downstream can catch this — no node is consulted when signing — so a + // mainnet transaction handed to `--network sepolia` would come back validly signed FOR + // MAINNET, which is the one mistake this command must not make quietly. + assertChainId(parsed, network); if (parsed.signature !== null) { throw new ChainError( "invalid_transaction", @@ -271,6 +296,9 @@ export class EvmTransactionService { } const gateway = this.gateways.get(network, "evm"); if (dryRun) return this.#dryRunBroadcast(scope, network, gateway, parsed); + // The selected network's node would reject a foreign chain id anyway, but refusing here says + // WHICH chain the transaction was built for, and keeps it out of a mempool it never belonged in. + assertChainId(parsed, network); const result = await gateway.sendRawTransaction(parsed.serialized); const txId = authoritativeTxId(parsed.hash ?? undefined, result.hash, (m) => scope.warn(m)); const submitted = { @@ -316,12 +344,7 @@ export class EvmTransactionService { // Local, and the cheapest way to catch a transaction signed for another chain: a replay of it // here is impossible, so there is nothing to gain by asking a node first. - if (String(parsed.chainId) !== String(network.chainId)) { - throw new ChainError( - "chain_mismatch", - `this transaction is signed for chain ${parsed.chainId}, but ${network.id} is chain ${network.chainId}`, - ); - } + assertChainId(parsed, network); checks.push({ name: "chainId", status: "ok", detail: `matches ${network.id}` }); const from = parsed.from; @@ -331,6 +354,7 @@ export class EvmTransactionService { feeModel: parsed.maxFeePerGas === null ? "legacy" : "eip1559", maxCostWei: maxCostWei.toString(), gasLimit: parsed.gasLimit.toString(), + maxPerGasWei: perGasCeiling.toString(), }; const state = @@ -416,9 +440,11 @@ export class EvmTransactionService { hash: string, ): Promise { const gateway = this.gateways.get(network, "evm"); - const [transaction, receipt] = await Promise.all([ + const [transaction, receipt, head] = await Promise.all([ gateway.getTransactionByHash(hash).catch(() => null), gateway.getTransactionReceipt(hash).catch(() => null), + // Best-effort third call: it only adds a field, and must not be able to fail the answer. + gateway.getBlockNumber().catch(() => undefined), ]); const confirmed = receipt !== null; const failed = confirmed && receipt.success !== true; @@ -443,6 +469,7 @@ export class EvmTransactionService { ...(receipt?.blockNumber === undefined ? {} : { blockNumber: receipt.blockNumber as number }), + ...confirmationsOf(head, receipt?.blockNumber), }; } @@ -461,34 +488,59 @@ export class EvmTransactionService { hash: string, ): Promise { const gateway = this.gateways.get(network, "evm"); - const [transaction, receipt] = await Promise.all([ + const [transaction, receipt, head] = await Promise.all([ gateway.getTransactionByHash(hash), gateway.getTransactionReceipt(hash).catch(() => null), + gateway.getBlockNumber().catch(() => undefined), ]); if (!transaction) { throw new UsageError("not_found", `no transaction with hash ${hash} on ${network.id}`); } const transfer = decodeErc20Transfer(String(transaction.input ?? "0x")); const value = BigInt(String(transaction.value ?? "0x0")); + // The block only for its timestamp, and only once we know there is one. Best-effort like the + // head read: a detail view is still worth having without the wall-clock time. + const blockTime = + receipt?.blockNumber === undefined + ? undefined + : await gateway + .getBlock(String(receipt.blockNumber)) + .then((block) => quantityToNumber((block as { timestamp?: unknown } | null)?.timestamp)) + .catch(() => undefined); return { txid: hash, + type: transactionType(transaction, transfer !== undefined), from: checksummed(transaction.from), + // The transaction's own nonce, flattened out of the node object: §4.3 makes it the entry + // point for diagnosing a stuck transaction, and digging it out of a passthrough field is + // not what "the detail view" should ask of a reader. + ...(transaction.nonce === undefined + ? {} + : { nonce: quantityToNumber(transaction.nonce) }), ...(transfer ? await this.#erc20Parties(gateway, checksummed(transaction.to), transfer) : { to: checksummed(transaction.to), + rawAmount: value.toString(10), amount: fromBaseUnits(value.toString(10), FAMILIES.evm.nativeDecimals), symbol: network.nativeSymbol, }), + ...(blockTime === undefined ? {} : { blockTime }), ...(receipt === null ? {} : { - status: receipt.success === true ? "SUCCESS" : "REVERT", + // Lower case, per §6.5: `tx status` and every write receipt already answer in lower + // case, and one field spelled two ways makes an agent match twice for one meaning. + status: receipt.success === true ? "success" : "revert", ...(receipt.blockNumber === undefined ? {} : { blockNumber: receipt.blockNumber as number }), ...(receipt.gasUsed === undefined ? {} : { gasUsed: Number(receipt.gasUsed) }), ...(receipt.feeWei === undefined ? {} : { feeWei: String(receipt.feeWei) }), + ...(receipt.effectiveGasPriceWei === undefined + ? {} + : { effectiveGasPriceWei: String(receipt.effectiveGasPriceWei) }), + ...confirmationsOf(head, receipt.blockNumber), }), transaction, receipt, @@ -496,6 +548,23 @@ export class EvmTransactionService { } } +/** + * Refuse a transaction built for a different chain. + * + * EIP-155 puts the chain id inside the transaction, so this is answerable locally and BEFORE a + * signature exists. `family_mismatch` does not fire here — a mainnet transaction and a Sepolia one + * are both EVM — which is exactly why this check has to be its own: without it, signing a mainnet + * transaction while pointing at a testnet produces a perfectly valid mainnet transaction and says + * nothing. + */ +function assertChainId(tx: Transaction, network: NetworkDescriptor): void { + if (String(tx.chainId) === String(network.chainId)) return; + throw new ChainError( + "chain_id_mismatch", + `this transaction is built for chain ${tx.chainId}, but ${network.id} is chain ${network.chainId}`, + ); +} + /** parse raw hex into an ethers Transaction, reporting bad input as bad input. */ function parseEvmTransaction(hex: string): Transaction { try { @@ -508,6 +577,34 @@ function parseEvmTransaction(hex: string): Transaction { } } +/** + * What KIND of transaction this is, in three words a reader can act on. + * + * Deliberately coarse: `transfer` covers a native send and a decoded ERC-20 transfer (both move + * value to someone), `contract-creation` is a deployment (`to` is null — that IS what makes it + * one), and everything else is `contract-call`. Naming the METHOD would mean decoding calldata we + * have chosen not to decode. + */ +function transactionType( + transaction: Record, + isErc20Transfer: boolean, +): string { + if (transaction.to === null || transaction.to === undefined) return "contract-creation"; + if (isErc20Transfer) return "transfer"; + const input = String(transaction.input ?? "0x"); + return input === "0x" || input === "" ? "transfer" : "contract-call"; +} + +/** hex QUANTITY (or a decimal) → number; undefined when it is neither. */ +function quantityToNumber(value: unknown): number | undefined { + if (value === undefined || value === null) return undefined; + try { + return Number(BigInt(String(value))); + } catch { + return undefined; + } +} + /** ERC-20 `transfer(address,uint256)` calldata → its recipient and base-unit amount. */ function decodeErc20Transfer(input: string): { to: string; rawAmount: string } | undefined { // 0xa9059cbb is the transfer(address,uint256) selector; 4 bytes + two 32-byte words. diff --git a/ts/src/application/use-cases/tron/chain-service.test.ts b/ts/src/application/use-cases/tron/chain-service.test.ts index 99f45a8e0..d973e6337 100644 --- a/ts/src/application/use-cases/tron/chain-service.test.ts +++ b/ts/src/application/use-cases/tron/chain-service.test.ts @@ -119,7 +119,8 @@ describe("TronChainService.node", () => { }; const view = await svc(gateway).node(net); expect(view).toMatchObject({ - endpoint: "https://nile.trongrid.io", + // host only: the same rule the EVM side follows, for the same reason (API keys in URLs) + endpoint: "nile.trongrid.io", version: "java-tron 4.7.7", p2pVersion: "11111", headBlock: { number: 84120345, timestamp: now - 2000 }, @@ -129,6 +130,20 @@ describe("TronChainService.node", () => { peers: { connected: 30, active: 27 }, }); }); + /** §2.2 tells users to point this at a commercial gateway, whose API key lives in the URL. + * `chain node` is pasted into issues and CI logs, so it reports the host and nothing else. */ + it("never echoes an endpoint's path or query, which is where API keys live", async () => { + const gateway = { + getNodeInfo: async () => ({}), + getBlock: async () => ({ block_header: { raw_data: { number: 1, timestamp: Date.now() } } }), + }; + const withKey = { ...net, httpEndpoint: "https://tron.example/wallet?apikey=SECRET-KEY" }; + const view = await svc(gateway).node(withKey as typeof net); + + expect(view.endpoint).toBe("tron.example"); + expect(JSON.stringify(view)).not.toContain("SECRET"); + }); + it("nulls unexposed fields (public gateway)", async () => { const gateway = { getNodeInfo: async () => ({}), diff --git a/ts/src/application/use-cases/tron/chain-service.ts b/ts/src/application/use-cases/tron/chain-service.ts index d2ea2d274..b27a52bb1 100644 --- a/ts/src/application/use-cases/tron/chain-service.ts +++ b/ts/src/application/use-cases/tron/chain-service.ts @@ -1,4 +1,4 @@ -import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import { endpointHost, type NetworkDescriptor } from "../../../domain/types/index.js"; import { UsageError } from "../../../domain/errors/index.js"; import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; @@ -65,7 +65,10 @@ export class TronChainService { const solidNumber = blockNum(info.solidityBlock); const codeVersion = info.configNodeInfo?.codeVersion; return { - endpoint: network.httpEndpoint ?? null, + // HOST only: a configured endpoint can carry an API key in its path, and this command's + // output is the one people paste into issues and CI logs. `config networks..httpEndpoint` + // is where a full URL is handed over, because there it was asked for by name. + endpoint: endpointHost(network.httpEndpoint) || null, version: codeVersion ? `java-tron ${codeVersion}` : null, p2pVersion: info.configNodeInfo?.p2pVersion ?? null, headBlock: { number: headNumber, timestamp: headTimestamp }, diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index 777d73a51..b138ec3a5 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -18,6 +18,8 @@ import { } from "../../services/transaction-mode.js"; import { tronConfirmation } from "../../services/tron-confirmation.js"; import { tronHexToBase58 } from "../../../domain/address/index.js"; +import { fromBaseUnits } from "../../../domain/amounts/index.js"; +import { approveRows } from "../../services/approve-receipt.js"; import { tronTransactionHooks } from "./multisig-authorization.js"; export class TronContractService { @@ -54,6 +56,20 @@ export class TronContractService { ) { if (transactionRequiresSigner(input)) this.pipeline.assertCanSign(scope.activeAccount, "tron"); const gateway = this.gateways.get(network, "tron"); + // Resolved BEFORE the run so `--dry-run` carries it too: the allowance is the one thing a dry + // run of an approve exists to confirm. TRC20 shares the method and the hazard with ERC-20, so + // it shares the receipt (§7.2). + const approval = await approveRows({ + method: input.method, + params: input.parameters, + metadata: () => gateway.getTokenInfo(input.contract).then((info) => ({ + decimals: info.decimals ?? info.precision, + symbol: typeof info.symbol === "string" ? info.symbol : undefined, + })), + // A TRON address may arrive as 41-hex from a caller pasting what a node returned. + displayAddress: tronHexToBase58, + fromBaseUnits, + }); const outcome = await this.pipeline.run({ ctx: scope, net: network, @@ -82,6 +98,7 @@ export class TronContractService { return { kind: "contract-send" as const, ...outcomeData(outcome), + ...approval, method: input.method, contract: input.contract, }; 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 548f42f3a..1ec9f6251 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 @@ -7,7 +7,7 @@ import type { NetworkDescriptor } from "../../../domain/types/index.js"; const NET = { id: "tron:nile", family: "tron", nativeSymbol: "TRX", chainId: "nile" } as unknown as NetworkDescriptor; // Minimal fake gateway: status() only touches the two lookup endpoints. -function service(opts: { tx?: TronTx | Error; info?: TronTxInfo }) { +function service(opts: { tx?: TronTx | Error; info?: TronTxInfo; head?: number | Error }) { const gateway = { async getTransactionById(): Promise { if (opts.tx instanceof Error) throw opts.tx; @@ -17,6 +17,11 @@ function service(opts: { tx?: TronTx | Error; info?: TronTxInfo }) { async getTransactionInfoById(): Promise { return opts.info ?? {}; }, + async getBlock(): Promise { + if (opts.head instanceof Error) throw opts.head; + if (opts.head === undefined) throw new Error("no head configured"); + return { block_header: { raw_data: { number: opts.head } } }; + }, } as unknown as TronGateway; const gateways = { get: () => gateway } as unknown as ChainGatewayProvider; return new TronTransactionService(gateways, {} as never, {} as never, {} as never); @@ -64,3 +69,52 @@ describe("TronTransactionService.status — four-state", () => { expect(s.state).toBe("not_found"); }); }); + +/** + * `Confirmations` — new in this release and NOT EVM-specific (§6.4). `--wait` stops at the + * receipt, so how deep is deep enough is the caller's judgement to make, and this is the number + * they make it with. + */ +describe("TronTransactionService.status — confirmations", () => { + it("reports head minus the transaction's block", async () => { + const s = await service({ + tx: { txID: "abc" } as TronTx, + info: { blockNumber: 42, receipt: { result: "SUCCESS" } }, + head: 78, + }).status(NET, "abc"); + + expect(s.confirmations).toBe(36); + }); + + // The including block is not counted, so a transaction in the head block reports zero. + it("reports zero for a transaction in the head block", async () => { + const s = await service({ + tx: { txID: "abc" } as TronTx, + info: { blockNumber: 42, receipt: { result: "SUCCESS" } }, + head: 42, + }).status(NET, "abc"); + + expect(s.confirmations).toBe(0); + }); + + // Absent, not zero: "we could not ask" is a different claim from "nothing on top yet", and the + // extra call must never cost the answer the command was actually asked for. + it("omits the field when the head could not be read, and still answers", async () => { + const s = await service({ + tx: { txID: "abc" } as TronTx, + info: { blockNumber: 42, receipt: { result: "SUCCESS" } }, + head: new Error("node unreachable"), + }).status(NET, "abc"); + + expect(s.state).toBe("confirmed"); + expect(s.confirmations).toBeUndefined(); + }); + + it("omits the field while the transaction has no block", async () => { + const s = await service({ tx: { txID: "abc" } as TronTx, info: {}, head: 78 }).status(NET, "abc"); + + expect(s.state).toBe("pending"); + expect(s.confirmations).toBeUndefined(); + }); +}); + diff --git a/ts/src/application/use-cases/tron/transaction-service.ts b/ts/src/application/use-cases/tron/transaction-service.ts index 8d4cdd422..e6e264cf0 100644 --- a/ts/src/application/use-cases/tron/transaction-service.ts +++ b/ts/src/application/use-cases/tron/transaction-service.ts @@ -25,6 +25,7 @@ import { } from "../../services/transaction-mode.js"; import { stageTronBroadcast, tronConfirmation } from "../../services/tron-confirmation.js"; import { localTxId } from "../../services/broadcast-identity.js"; +import { confirmationsOf } from "../../services/confirmations.js"; import { tronTransactionHooks } from "./multisig-authorization.js"; import type { RecipientResolver } from "../../services/recipient-resolver.js"; @@ -137,18 +138,27 @@ export class TronTransactionService { // broadcast tx immediately (mempool), and throws "Transaction not found" for an unknown hash. // getTransactionInfo (full-node unconfirmed view) fills in ~one block after inclusion (~3s), // not after solidification — that's what promotes pending → confirmed/failed. - const [exists, info] = await Promise.all([ + const [exists, info, head] = await Promise.all([ gateway.getTransactionById(txid).then( (tx) => tx?.txID !== undefined, () => false, ), gateway.getTransactionInfoById(txid).catch((): TronTxInfo => ({})), + // Best-effort, exactly as on the EVM side: it adds a field and must never cost the answer. + headBlockNumber(gateway), ]); const confirmed = info.blockNumber !== undefined; const result = info.receipt?.result; const failed = confirmed && result !== undefined && result !== "SUCCESS"; const state = confirmed ? (failed ? "failed" : "confirmed") : exists ? "pending" : "not_found"; - return { txid, state, confirmed, failed, blockNumber: info.blockNumber }; + return { + txid, + state, + confirmed, + failed, + blockNumber: info.blockNumber, + ...confirmationsOf(head, info.blockNumber), + }; } async info(network: NetworkDescriptor, txid: string): Promise { @@ -156,15 +166,19 @@ export class TronTransactionService { // The transaction is the source of truth for existence; the info (block/fee/energy) is // enrichment. Mirror getContractMetadata's best-effort shape: a missing/failed info must not // sink the command for a tx that exists (e.g. still pending, or a flaky solidity node). - const [transaction, info] = await Promise.all([ + const [transaction, info, head] = await Promise.all([ gateway.getTransactionById(txid), gateway.getTransactionInfoById(txid).catch((): TronTxInfo => ({})), + headBlockNumber(gateway), ]); return { txid, ...(await this.enrichParties(gateway, gateway.decodeTransaction(transaction))), - status: info.receipt?.result ?? transaction.ret?.[0]?.contractRet, + // The node reports SUCCESS / REVERT / OUT_OF_ENERGY…; this CLI reports one case throughout + // (§6.5). The comparisons that decide `failed` read the node's own value, not this field. + status: lowerCaseStatus(info.receipt?.result ?? transaction.ret?.[0]?.contractRet), blockNumber: info.blockNumber, + ...confirmationsOf(head, info.blockNumber), energyUsed: info.receipt?.energy_usage_total, feeSun: info.fee, transaction, @@ -295,3 +309,27 @@ export class TronTransactionService { return { from: decoded.from, contract: decoded.tokenContract }; } } + +/** + * The chain's head height, or undefined when it could not be read. + * + * Only ever used to compute `confirmations`, so it swallows its own failure: a status query that + * died because a second, optional call failed would be a worse answer than one without the extra + * field. + */ +async function headBlockNumber(gateway: TronGateway): Promise { + try { + const block = (await gateway.getBlock()) as + | { block_header?: { raw_data?: { number?: number } } } + | undefined; + return block?.block_header?.raw_data?.number; + } catch { + return undefined; + } +} + +/** the node's status word in the single case this CLI answers in (§6.5). */ +function lowerCaseStatus(value: string | undefined): string | undefined { + return value === undefined ? undefined : value.toLowerCase(); +} + diff --git a/ts/src/application/use-cases/wallet-service.keystore.test.ts b/ts/src/application/use-cases/wallet-service.keystore.test.ts index 7d27cbc52..131bc5dc4 100644 --- a/ts/src/application/use-cases/wallet-service.keystore.test.ts +++ b/ts/src/application/use-cases/wallet-service.keystore.test.ts @@ -451,3 +451,46 @@ describe("keystore export follows the selected network's family", () => { expect(exported("evm").address).toMatch(/^0x[0-9a-fA-F]{40}$/); }); }); + +/** + * The audit log answers "which key left this machine". A seed account holds a different key per + * family (§1.2), so filing an EVM export under the account's TRON address names the wrong key — + * and the log's only job is to name the right one. + */ +describe("WalletService.backupKeystore — what the audit log records", () => { + it("files a keystore export under the exported family's address", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + const account = h.keystore.describe(accountId); + + h.service.backupKeystore(accountId, undefined, PW, "evm"); + h.service.backupKeystore(accountId, undefined, PW, "tron"); + + const [tron, evm] = h.store.list(); // newest first + expect(evm).toMatchObject({ family: "evm", account: account.addresses.evm }); + expect(tron).toMatchObject({ family: "tron", account: account.addresses.tron }); + expect(evm!.account).not.toBe(tron!.account); + }); + + // A mnemonic is every family's key at once, so naming one would claim less than what left. + it("records no family for a native backup", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + + h.service.backup(accountId, undefined); + + expect(h.store.list()[0]).not.toHaveProperty("family"); + }); + + // Filtering used to compare the TRON address alone, which hid an account's own EVM exports. + it("finds an EVM export when filtering by that account", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + h.service.backupKeystore(accountId, undefined, PW, "evm"); + + const { records } = h.service.backupRecords({ account: accountId }); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ family: "evm" }); + }); +}); + diff --git a/ts/src/application/use-cases/wallet-service.ts b/ts/src/application/use-cases/wallet-service.ts index 9d1cb6fce..7298499da 100644 --- a/ts/src/application/use-cases/wallet-service.ts +++ b/ts/src/application/use-cases/wallet-service.ts @@ -1,6 +1,12 @@ import { bytesToHex } from "@noble/hashes/utils.js"; import { Derivation } from "../../domain/derivation/index.js"; -import { CHAIN_FAMILIES, familyOf, type ChainFamily } from "../../domain/family/index.js"; +import { + CHAIN_FAMILIES, + canonicalAddress, + familyOf, + type ChainFamily, +} from "../../domain/family/index.js"; +import { resembledFamily } from "../../domain/contact/index.js"; import { KeystoreV3 } from "../../domain/keystore/index.js"; import { derivePrivAddresses } from "../../domain/wallet/index.js"; import { TronAddress, evmAddressFromPublicKey, tronHexAddress } from "../../domain/address/index.js"; @@ -19,7 +25,9 @@ const notExportable = (type: string) => /** A keystore file is single-key and TRON-shaped: its `address` is the TRON form, and an HD account * exports the key at its own TRON derivation path. (EVM lands as its own export when it lands.) */ -const KEYSTORE_FAMILY: ChainFamily = "tron"; +/** the address a record is filed under when the export covered EVERY family (native backup): + * one stable identity is needed, and TRON's is the one this log has always used. */ +const RECORD_IDENTITY_FAMILY: ChainFamily = "tron"; export interface BackupRecordQuery { /** inclusive bounds as UTC ISO-8601 instants; parsed and validated by the caller. */ @@ -60,9 +68,14 @@ export class WalletService { } importWatch(addressInput: string, label?: string) { - const address = addressInput.trim(); - const family = familyOf(address); - if (!family) throw new UsageError("invalid_value", `unrecognised address format: ${address}`); + const input = addressInput.trim(); + const family = familyOf(input); + if (!family) { + throw new UsageError("invalid_address", addressRejection(input)); + } + // Stored in the spelling it will be printed in (§1.3), so this account never displays + // differently from the same address reached through any other command. + const address = canonicalAddress(input); const result = this.wallets.registerWatch({ family, address, label }); return { status: mutationStatus(result.created), ...this.wallets.describe(result.accountId) }; } @@ -173,6 +186,7 @@ export class WalletService { } const file = this.backups.write(descriptor.accountId, requestedPath, payload, "native"); + // No family: a mnemonic — and equally a raw private key — is every family's key at once. this.#recordExport("backup", descriptor, file.out); return { ...descriptor, secretType, format: "native" as const, ...file }; } @@ -203,7 +217,7 @@ export class WalletService { KeystoreV3.encrypt(privateKey, masterPassword, keystoreAddress(family, privateKey)), "keystore", ); - this.#recordExport("backup --keystore", descriptor, file.out); + this.#recordExport("backup --keystore", descriptor, file.out, family); return { ...descriptor, family, @@ -247,10 +261,13 @@ export class WalletService { if (query.to !== undefined && r.timestamp > query.to) return false; // Records are snapshots, so an account is matched by either identity it was logged under — // a since-renamed account still matches on accountId, a re-imported one on its address. + // Any of the target's addresses, not just its TRON one: a keystore export is filed under + // the family it exported, so filtering on one family would hide the other family's exports + // of the very account being asked about. if ( target && r.accountId !== target.accountId && - r.account !== target.addresses[KEYSTORE_FAMILY] + !CHAIN_FAMILIES.some((f) => target.addresses[f] !== undefined && r.account === target.addresses[f]) ) return false; return true; @@ -300,9 +317,10 @@ export class WalletService { addresses: Partial>; }, out: string, + family?: ChainFamily, ) { try { - this.#appendExport(operation, descriptor, out); + this.#appendExport(operation, descriptor, out, family); } catch (error) { throw new ExecutionError( "audit_append_failed", @@ -320,11 +338,20 @@ export class WalletService { addresses: Partial>; }, out: string, + family?: ChainFamily, ) { + // The address of the key that actually left. Falling back to the identity family covers a + // native backup (every family at once) and a single-family account that has no TRON address. + const address = + (family === undefined ? undefined : descriptor.addresses[family]) ?? + descriptor.addresses[RECORD_IDENTITY_FAMILY] ?? + CHAIN_FAMILIES.map((f) => descriptor.addresses[f]).find((a) => a !== undefined) ?? + ""; this.backupRecordStore.append({ operation, accountId: descriptor.accountId, - account: descriptor.addresses[KEYSTORE_FAMILY] ?? "", + account: address, + ...(family === undefined ? {} : { family }), label: descriptor.label ?? null, out, timestamp: new Date(this.now()).toISOString().replace(/\.\d{3}Z$/, "Z"), @@ -351,3 +378,18 @@ function keystoreAddress(family: ChainFamily, privateKey: Bytes): string { ? tronHexAddress(new TronAddress().fromPublicKey(publicKey)) : evmAddressFromPublicKey(publicKey); } + +/** + * Why a value was not accepted as an address, in the terms the user can act on. + * + * "Unrecognised format" leaves someone who mistyped one character of an otherwise perfect address + * hunting for the wrong thing. A value that is SHAPED like an address failed its checksum or its + * length; one that is not shaped like any address is a different mistake entirely. + */ +function addressRejection(value: string): string { + const resembles = resembledFamily(value); + return resembles + ? `${value} looks like a ${resembles} address but its length or checksum is wrong` + : `unrecognised address format: ${value}`; +} + diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index 60013ba88..c76011121 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -76,7 +76,17 @@ export function composeCliRuntime(options: BootstrapOptions) { const tokenBook = new TokenBook(root, store); const contactBook = new ContactBook(root, store); const recipientResolver = new RecipientResolver(contactBook); - const priceProvider = createPriceProvider(config.price, timeoutMs); + const priceProvider = createPriceProvider( + config.price, + timeoutMs, + // Declared per network (§2.2), never inferred from the id: a user-configured chain we know + // nothing about stays unpriced (null = unknown), which is not the same as worth nothing. + new Set( + Object.values(config.networks) + .filter((n) => n.testnet === true) + .map((n) => n.id), + ), + ); const gatewayProvider = new ChainGatewayRegistry( familyMap((plugin) => plugin.createGateway), timeoutMs, diff --git a/ts/src/domain/address/address.test.ts b/ts/src/domain/address/address.test.ts index 6eed0d23d..5eb21b62f 100644 --- a/ts/src/domain/address/address.test.ts +++ b/ts/src/domain/address/address.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { + EvmAddress, TronAddress, evmAddressFromPublicKey, evmChecksumAddress, @@ -94,3 +95,38 @@ describe("evmAddressFromPublicKey", () => { expect(reEncoded).toBe(evmAddressFromPublicKey(pub)); }); }); + +/** + * §1.3 accepts more spellings than it prints: an all-lower or all-upper EVM address offers no + * checksum to verify, so it is valid input — but everything this CLI stores and shows is EIP-55. + * Without the normalising step the same address appears in two spellings depending on which + * command wrote it, and a user comparing them concludes they are different addresses. + */ +describe("AddressCodec.canonical", () => { + const codec = new EvmAddress(); + const CHECKSUMMED = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; + + it("checksums an all-lowercase EVM address", () => { + expect(codec.canonical(CHECKSUMMED.toLowerCase())).toBe(CHECKSUMMED); + }); + + it("checksums an all-uppercase EVM address", () => { + expect(codec.canonical(`0x${CHECKSUMMED.slice(2).toUpperCase()}`)).toBe(CHECKSUMMED); + }); + + it("leaves an already-checksummed address untouched", () => { + expect(codec.canonical(CHECKSUMMED)).toBe(CHECKSUMMED); + }); + + // Not this function's job to decide a non-address is wrong: callers hand it labels and refs too. + it("passes a value that is not an address through unchanged", () => { + expect(codec.canonical("not-an-address")).toBe("not-an-address"); + }); + + // Base58Check fixes every character of a TRON address, so there is only ever one spelling. + it("is the identity for TRON", () => { + const tron = "TBhCfAytweLuLLL2gr8xxxxxxxxxxxxxxx"; + expect(new TronAddress().canonical(tron)).toBe(tron); + }); +}); + diff --git a/ts/src/domain/address/index.ts b/ts/src/domain/address/index.ts index 416e9ceb8..370c4d34a 100644 --- a/ts/src/domain/address/index.ts +++ b/ts/src/domain/address/index.ts @@ -14,6 +14,15 @@ export interface AddressCodec { family: ChainFamily; fromPublicKey(pub: Bytes): string; validate(addr: string): boolean; + /** + * The one spelling of a VALID address that this CLI stores and prints. + * + * Input is accepted in more forms than output is written in (§1.3 takes an all-lower or + * all-upper EVM address as "no checksum was offered"), so without a normalising step the same + * address reaches the screen in two different spellings depending on which command put it + * there — and a user comparing them concludes they are different addresses. + */ + canonical(addr: string): string; } const b58c = createBase58check(sha256); @@ -89,6 +98,10 @@ export class TronAddress implements AddressCodec { return false; } } + /** Base58Check is already one-form-per-address: its own checksum fixes every character. */ + canonical(addr: string): string { + return addr; + } } export class EvmAddress implements AddressCodec { @@ -99,6 +112,10 @@ export class EvmAddress implements AddressCodec { validate(addr: string): boolean { return isEvmAddress(addr); } + /** EIP-55, always — the form §1.3 says every EVM address leaves this CLI in. */ + canonical(addr: string): string { + return isEvmAddress(addr) ? evmChecksumAddress(hexToBytes(addr.slice(2).toLowerCase())) : addr; + } } /** Convert a 41-prefixed TRON hex address to base58; preserve non-hex values unchanged. */ diff --git a/ts/src/domain/contact/contact.test.ts b/ts/src/domain/contact/contact.test.ts index d6e1fb3c1..52808f319 100644 --- a/ts/src/domain/contact/contact.test.ts +++ b/ts/src/domain/contact/contact.test.ts @@ -96,3 +96,23 @@ describe("resemblesAddress spots a near-miss of any family", () => { expect(resemblesAddress("team-vault")).toBe(false); }); }); + +/** + * The address book is a display surface as much as a lookup: `contact list` is where someone + * checks a payee against what their exchange showed them. The loader runs this same constructor, + * so an entry written before this rule normalises the moment it is read back. + */ +describe("createContact — canonical address", () => { + const CHECKSUMMED = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; + + it("stores an all-lowercase EVM address in EIP-55", () => { + expect(createContact("evm", "alice", CHECKSUMMED.toLowerCase()).address).toBe(CHECKSUMMED); + }); + + it("names the argument that was wrong when the address is not one", () => { + expect(() => createContact("evm", "alice", "0xnope")).toThrowError( + expect.objectContaining({ code: "invalid_address" }), + ); + }); +}); + diff --git a/ts/src/domain/contact/index.ts b/ts/src/domain/contact/index.ts index 13040fc31..2eccf6d90 100644 --- a/ts/src/domain/contact/index.ts +++ b/ts/src/domain/contact/index.ts @@ -49,14 +49,16 @@ export function createContact( // Validated against the entry's OWN family: a TRON address filed under `evm` would make // `--to friend` resolve, on an EVM network, to an address that does not exist there. if (!CHAIN_FAMILIES.includes(family) || !addressCodec(family).validate(address)) { - throw new UsageError("invalid_value", `contact address must be a valid ${family} address`); + throw new UsageError("invalid_address", `contact address must be a valid ${family} address`); } const name = contactName(nameInput); return { family, name, nameKey: contactNameKey(name), - address, + // Canonical (§1.3): the book is a display surface as much as a lookup, and the loader runs + // this same constructor, so an entry written before this rule normalises when it is read. + address: addressCodec(family).canonical(address), note: contactNote(noteInput), }; } diff --git a/ts/src/domain/family/index.ts b/ts/src/domain/family/index.ts index bb7cff83e..ebb6ebdf1 100644 --- a/ts/src/domain/family/index.ts +++ b/ts/src/domain/family/index.ts @@ -63,3 +63,15 @@ export function addressCodec(family: ChainFamily): AddressCodec { export function familyOf(address: string): ChainFamily | undefined { return CHAIN_FAMILIES.find((f) => FAMILIES[f].codec.validate(address)); } + +/** + * An address in the single spelling this CLI stores and prints, whichever family it belongs to. + * + * Anything that is not a valid address of any family is returned untouched: callers use this on + * values that may be a label, a contact name or a ref, and it is not this function's place to + * decide those are wrong. + */ +export function canonicalAddress(address: string): string { + const family = familyOf(address); + return family ? FAMILIES[family].codec.canonical(address) : address; +} diff --git a/ts/src/domain/fees/evm-gas.test.ts b/ts/src/domain/fees/evm-gas.test.ts index 7430508fd..3daf74dd2 100644 --- a/ts/src/domain/fees/evm-gas.test.ts +++ b/ts/src/domain/fees/evm-gas.test.ts @@ -156,3 +156,43 @@ describe("gweiToWei", () => { expect(() => gweiToWei("fast")).toThrow(); }); }); + +/** + * The two ways a fee plan can be quietly wrong. + * + * Both produce a signable transaction, and neither raises an error anywhere downstream: the node + * accepts what it is given, and the transaction simply never gets mined — or gets mined paying a + * tip the caller did not choose. An error would be wrong (the caller may mean it); silence was + * worse. + */ +describe("planEvmFee — warnings", () => { + const chain = { baseFeeWei: "1000000000", gasPriceWei: "1100000000", suggestedPriorityWei: "1000000", gasLimit: "21000" }; + + it("says so when the suggested tip had to be cut down to the fee cap", () => { + const plan = planEvmFee({ ...chain, overrides: { maxFeeWei: "500000" } }); + + expect(plan.priorityFeeWei).toBe("500000"); + expect(plan.warnings?.join(" ")).toMatch(/tip was reduced/); + }); + + it("says so when the fee cap is below the base fee, so it cannot be included", () => { + const plan = planEvmFee({ ...chain, overrides: { maxFeeWei: "500000" } }); + + expect(plan.warnings?.join(" ")).toMatch(/cannot be included until the base fee falls/); + }); + + // An explicit tip is the caller's decision, not something to report back at them. + it("does not warn about a tip the caller chose", () => { + const plan = planEvmFee({ + ...chain, + overrides: { maxFeeWei: "3000000000", priorityFeeWei: "2000000" }, + }); + + expect(plan.warnings).toBeUndefined(); + }); + + it("stays silent on an ordinary plan", () => { + expect(planEvmFee(chain).warnings).toBeUndefined(); + }); +}); + diff --git a/ts/src/domain/fees/evm-gas.ts b/ts/src/domain/fees/evm-gas.ts index b8151a086..478b5afe3 100644 --- a/ts/src/domain/fees/evm-gas.ts +++ b/ts/src/domain/fees/evm-gas.ts @@ -37,6 +37,14 @@ export interface EvmFeePlan { gasPriceWei?: string; /** the most this transaction can cost: gasLimit × the per-gas ceiling. */ maxCostWei: string; + /** + * Things the caller should know about what was decided for them. + * + * The plan stays pure — it returns the sentences, it does not emit them. Both conditions here + * are cases where the transaction is still signable and still WRONG in a way no error would + * report: silently adjusting someone's fee, or signing one the chain will not currently accept. + */ + warnings?: string[]; } /** @@ -98,7 +106,22 @@ export function planEvmFee(input: EvmFeeInput): EvmFeePlan { : base * 2n + (priorityGiven ?? suggested); // maxPriorityFeePerGas above maxFeePerGas is rejected outright by nodes, so the user's ceiling // wins over a suggestion that outgrew it. - const priority = priorityGiven ?? (suggested > maxFee ? maxFee : suggested); + const clamped = priorityGiven === undefined && suggested > maxFee; + const priority = priorityGiven ?? (clamped ? maxFee : suggested); + + const warnings: string[] = []; + if (clamped) { + warnings.push( + `--max-fee ${maxFee} wei is below the node's suggested tip of ${suggested} wei, so the tip was reduced to match it; a node rejects a tip above the fee cap`, + ); + } + // Signable, and unmineable until the base fee falls to meet it. Nothing downstream reports + // this: the node accepts the transaction and it simply sits there. + if (maxFee < base) { + warnings.push( + `--max-fee ${maxFee} wei is below the current base fee of ${base} wei; this transaction cannot be included until the base fee falls`, + ); + } return { mode, @@ -106,6 +129,7 @@ export function planEvmFee(input: EvmFeeInput): EvmFeePlan { maxFeeWei: maxFee.toString(10), priorityFeeWei: priority.toString(10), maxCostWei: (BigInt(gasLimit) * maxFee).toString(10), + ...(warnings.length ? { warnings } : {}), }; } diff --git a/ts/src/domain/types/network.ts b/ts/src/domain/types/network.ts index ce123f05e..2eeddb3c1 100644 --- a/ts/src/domain/types/network.ts +++ b/ts/src/domain/types/network.ts @@ -21,6 +21,13 @@ interface NetworkBase { * owns what is genuinely family-wide — the base-unit name (wei) and its decimals. */ nativeSymbol: string; + /** + * A test network — its coin is not traded, so it has no USD value to report. + * + * Declared per network rather than guessed from the id: `evm:11155111` says nothing about + * being a testnet, and a guess would be wrong for exactly the chains nobody checked. + */ + testnet?: boolean; feeModel?: FeeModel; capabilities: string[]; } @@ -52,6 +59,27 @@ export function isTronNetwork(network: NetworkDescriptor): network is TronNetwor return network.family === "tron"; } +/** + * The host of an endpoint URL — what listings show instead of the URL itself. + * + * A commercial RPC endpoint carries its API key IN THE URL (`…/v2/`, `…?apikey=`), and + * §2.2 tells users to configure exactly that. Any output that prints an endpoint it was not + * explicitly asked for therefore prints a credential — and `chain node` is the command whose + * output people paste into issues and CI logs. Trimming to the host is the one cut that needs no + * guess about which path segment is the secret. + * + * `config networks..httpEndpoint` remains the way to read the full URL: named reads give the + * whole value, listings do not. Returns "" for a missing or unparseable URL. + */ +export function endpointHost(url: unknown): string { + if (typeof url !== "string" || url === "") return ""; + try { + return new URL(url).host; + } catch { + return ""; + } +} + export interface CapabilityDescriptor { key: string; summary: string; diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index cb37d23de..4fe81b792 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -84,6 +84,14 @@ export interface TxStatusView { /** kept for back-compat: `state === "failed"`. */ failed: boolean; blockNumber?: number | string; + /** + * Head height minus the transaction's block — how much chain has been built on top of it. + * + * Present only once there IS a block, and best-effort: the head read is a second call, and a + * failed one costs this field rather than the answer the command was asked for. `--wait` stops + * at the receipt, so this is the number a caller reads to decide whether that is enough. + */ + confirmations?: number; } /** decoded transfer parties of a tx (best-effort from the raw tx). */ @@ -91,6 +99,8 @@ export interface TxParties { from?: string; to?: string; amount?: string; + /** the same amount in base units — the exact integer, beside the scaled display value. */ + rawAmount?: string; symbol?: string; contract?: string; } @@ -175,6 +185,11 @@ export interface TxReceiptView { // contract method?: string; contractAddress?: string; + /** approve(address,uint256) only: who was approved, and for how much in token units + * ("unlimited" for 2^256-1). The command line carries a scaled uint256 nobody can read. */ + spender?: string; + allowance?: string; + allowanceDecimals?: number; // TRC10 assets — quantities in the asset's minimal units, rendered with `precision` name?: string; abbr?: string; @@ -234,6 +249,18 @@ export interface TxReceiptView { energyUsed?: number; feeSun?: string | number; feeWei?: string; + /** gas actually burnt (EVM); pairs with effectiveGasPriceWei to explain feeWei. */ + gasUsed?: string | number; + /** the per-gas price the chain settled at (EVM), decimal wei. */ + effectiveGasPriceWei?: string; + /** + * The transaction's own nonce (EVM). + * + * Captured while building rather than read back from a receipt: it is decided before the + * transaction is signed, and §4.3 names it the entry point for diagnosing a stuck transaction — + * which is exactly the case where no receipt will ever arrive. + */ + nonce?: number | string; withdrawnSun?: string | number; result?: string; failed?: boolean; @@ -243,8 +270,18 @@ export interface TxReceiptView { * tx/receipt blobs (kept for JSON detail). Each family populates only its own subset. */ export interface TxInfoView extends TxParties { txid: string; + /** coarse kind: transfer / contract-call / contract-creation (EVM). */ + type?: string; + /** the transaction's own nonce (EVM). */ + nonce?: number; + /** the including block's timestamp, Unix SECONDS — the same unit `chain node` reports. */ + blockTime?: number; + /** the per-gas price the chain settled at (EVM), decimal wei. */ + effectiveGasPriceWei?: string; status?: string; blockNumber?: number | string; + /** head height minus this transaction's block; best-effort, see TxStatusView.confirmations. */ + confirmations?: number; energyUsed?: number; // tron execution resource gasUsed?: number; // evm execution resource feeSun?: number; // tron native fee (sun) diff --git a/ts/src/domain/wallet/index.ts b/ts/src/domain/wallet/index.ts index 72c0f9046..92ddce7dd 100644 --- a/ts/src/domain/wallet/index.ts +++ b/ts/src/domain/wallet/index.ts @@ -99,16 +99,33 @@ export function derivePrivAddresses(pk: Bytes): ChainAddresses { return out; } -/** (index, cached addresses) pairs of a wallet — the one shape both dedup and views walk. */ +/** + * (index, cached addresses) pairs of a wallet — the one shape both dedup and views walk. + * + * Every address leaves here CANONICAL (§1.3: EVM in EIP-55). Normalising at this single point + * rather than at each call site means a wallets.json written before this rule — a watch account + * registered from an all-lowercase paste — displays and matches identically to one written after + * it, without rewriting the file. + */ export function enumerateAddresses( w: Wallet, ): Array<{ index: number | null; addr: Partial }> { const s = w.source; if (s.type === "seed") { - return accountIndices(s).map((i) => ({ index: i, addr: s.addresses[String(i)]! })); + return accountIndices(s).map((i) => ({ index: i, addr: canonicalAddresses(s.addresses[String(i)]!) })); } - if (s.type === "privateKey") return [{ index: null, addr: s.addresses }]; - return [{ index: null, addr: { [s.family]: s.address } }]; + if (s.type === "privateKey") return [{ index: null, addr: canonicalAddresses(s.addresses) }]; + return [{ index: null, addr: { [s.family]: addressCodec(s.family).canonical(s.address) } }]; +} + +/** every value of an address map in its canonical spelling. */ +function canonicalAddresses(addr: Partial): Partial { + const out: Partial = {}; + for (const f of CHAIN_FAMILIES) { + const value = addr[f]; + if (value !== undefined) out[f] = addressCodec(f).canonical(value); + } + return out; } /** addresses projected for a single account view (seed index / privateKey / ledger). */ diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index d7c1fe335..002b221cc 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -367,7 +367,17 @@ describe("golden CLI — watch wallet (import, no signer)", () => { expect(r.json.data.addresses.tron).toBe(TRON1); const list = run(["--output", "json", "list"]); expect(list.json.data[0].type).toBe("watch"); - expect(list.json.data[0].active).toBe(true); + }); + + /** + * §3.3: watch is the exception to "an import becomes the active account". It holds no key, so + * activating it turns the next write command into watch_only_no_signer for a reason the user + * never chose — `use ` is how the active account changes. + */ + it("does not make a watch account active", () => { + const r = run(["--output", "json", "import", "watch", "--address", TRON1, "--label", "obs"]); + expect(r.json.data.active).toBe(false); + expect(run(["--output", "json", "list"]).json.data[0].active).toBe(false); }); it("imports a watch account through the import source command", () => { @@ -377,10 +387,72 @@ describe("golden CLI — watch wallet (import, no signer)", () => { expect(r.json.data.addresses.tron).toBe(TRON1); }); - it("rejects an unrecognised watch address → invalid_value, exit 2 (§7.14.2)", () => { + // §1.3/§11 name this one `invalid_address`; it used to be the generic invalid_value, which + // said nothing about WHICH argument was wrong. + it("rejects an unrecognised watch address → invalid_address, exit 2 (§7.14.2)", () => { const r = run(["--output", "json", "import", "watch", "--address", "not-an-address"]); expect(r.status).toBe(2); - expect(r.json.error.code).toBe("invalid_value"); + expect(r.json.error.code).toBe("invalid_address"); + }); + + // A near-miss is a different mistake from a value that is not address-shaped at all, and the + // message has to say so: otherwise someone who mistyped one character hunts for the wrong thing. + it("says a mistyped EVM address failed its checksum rather than 'unrecognised format'", () => { + const r = run([ + "--output", + "json", + "import", + "watch", + "--address", + "0x742D35Cc6634C0532925a3b844Bc454e4438f44e", + ]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_address"); + expect(r.json.error.message).toMatch(/checksum/); + }); + + /** + * The companion to storing canonically: §1.3 accepts three spellings as INPUT, so an account + * has to be findable by all of them. Canonicalising only the stored side would make the CLI + * refuse to find an account by the very spelling it just told the user was valid. + */ + it("finds a watch account by any accepted spelling of its address", () => { + run([ + "--output", + "json", + "import", + "watch", + "--address", + "0x742d35cc6634c0532925a3b844bc454e4438f44e", + "--label", + "lookup", + ]); + for (const spelling of [ + "0x742d35cc6634c0532925a3b844bc454e4438f44e", + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "0x742D35CC6634C0532925A3B844BC454E4438F44E", + ]) { + const r = run(["--output", "json", "current", "--account", spelling]); + expect(r.status).toBe(0); + expect(r.json.data.label).toBe("lookup"); + } + }); + + // §1.3: an all-lowercase address offers no checksum, so it is accepted — and stored in the one + // spelling this CLI prints, so it never shows up looking like a different address. + it("stores an all-lowercase EVM watch address in EIP-55", () => { + const r = run([ + "--output", + "json", + "import", + "watch", + "--address", + "0x742d35cc6634c0532925a3b844bc454e4438f44e", + "--label", + "lower", + ]); + expect(r.status).toBe(0); + expect(r.json.data.addresses.evm).toBe("0x742d35Cc6634C0532925a3b844Bc454e4438f44e"); }); it("deletes an account through the root positional delete command", () => { @@ -393,6 +465,9 @@ describe("golden CLI — watch wallet (import, no signer)", () => { it("refuses to sign with a watch-only active account → watch_only_no_signer, exit 1", () => { run(["--output", "json", "import", "watch", "--address", TRON1, "--label", "obs"]); + // Explicitly selected: registering one no longer activates it (§3.3), and this test is about + // what happens when a watch account IS the one being signed with. + run(["--output", "json", "use", "obs"]); const r = run([ "--output", "json", @@ -925,3 +1000,63 @@ describe("golden CLI — startup migration", () => { expect(run(["--help"], { password: null }).status).toBe(0); }); }); + +/** + * A flag the command does not declare must be REFUSED, never ignored — including one that happens + * to be spelled like the command's own path. + * + * `assertKnownFlags` used to allow the path segments, so `token balance --token USDT` and + * `contract deploy --contract 0x…` were silently accepted no-ops: the user typed something the + * command does not have, and the CLI ran anyway as if they had not. That is the exact failure the + * check exists to prevent, and it hid behind the one input most likely to be typed by mistake — + * `tx send` really does take `--token`, so reaching for it on `token balance` is natural. + */ +describe("golden CLI — flags spelled like the command path", () => { + it.each([ + [["token", "balance", "--token", "USDT", "--contract", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"], "--token"], + [["contract", "deploy", "--contract", "0xabc", "--code", "0x00"], "--contract"], + [["import", "watch", "--watch", "x", "--address", "TBy6..."], "--watch"], + ])("refuses %o", (tokens, flag) => { + const r = run(["--output", "json", ...tokens]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_option"); + expect(r.json.error.message).toContain(flag); + }); + + // The commands themselves must still run — the path segments arrive as positionals, not flags. + it("leaves the commands' own invocations working", () => { + expect(run(["--output", "json", "token", "list", "--network", "tron:nile"]).json.command).toBe( + "token.list", + ); + expect(run(["--output", "json", "contact", "list"]).json.command).toBe("contact.list"); + expect(run(["--output", "json", "encoding", "convert", "deadbeef"]).json.command).toBe( + "encoding.convert", + ); + }); +}); + +/** + * §2.3 fixes what `networks` shows. The header is worth pinning because the column NAMES carry + * meaning here: `Chain id` says the value is the second half of the canonical id (`evm` + `1` = + * `evm:1`), which the shorter "Chain" left open to reading as the chain's name. + */ +describe("golden CLI — networks table", () => { + it("names its columns as §2.3 specifies", () => { + const header = run(["networks"]).stdout.split("\n")[0]; + + expect(header).toContain("| Chain id |"); + // canonical id and alias are separate columns: one is stable, the other is readable + expect(header).toContain("| Network "); + expect(header).toContain("| Alias "); + expect(header).toContain("| Endpoint "); + }); + + // The endpoint may carry an API key in its path; a listing has no business echoing one. + it("shows endpoints as hosts, never full URLs", () => { + const out = run(["networks"]).stdout; + + expect(out).toContain("api.trongrid.io"); + expect(out).not.toContain("https://"); + }); +}); + From 4bb44712ff87facc9d8dd6b3f76879ab44192fa7 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 25 Aug 2026 03:03:49 +0800 Subject: [PATCH 04/23] feat: drop useless --- ts/src/adapters/inbound/cli/render/chain.ts | 6 ------ ts/src/adapters/inbound/cli/render/misc.ts | 2 +- ts/src/adapters/outbound/price/price.test.ts | 2 +- ts/src/application/use-cases/evm/contract-service.ts | 1 - 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/ts/src/adapters/inbound/cli/render/chain.ts b/ts/src/adapters/inbound/cli/render/chain.ts index 2000083c8..1f887ff4d 100644 --- a/ts/src/adapters/inbound/cli/render/chain.ts +++ b/ts/src/adapters/inbound/cli/render/chain.ts @@ -18,12 +18,6 @@ function parameterValue(key: string, value: unknown): string { return unit ? `${formatInt(value)} ${unit}` : String(value ?? ""); } -function timestamp(v: unknown): string { - const n = Number(v); - if (!Number.isFinite(n) || n <= 0) return "—"; - return new Date(n).toISOString().replace("T", " ").slice(0, 19); -} - export const ChainFormatters = { chainParams: ((data) => { const d = asObj(data); diff --git a/ts/src/adapters/inbound/cli/render/misc.ts b/ts/src/adapters/inbound/cli/render/misc.ts index 4e1a3bc8b..f6dbf8a56 100644 --- a/ts/src/adapters/inbound/cli/render/misc.ts +++ b/ts/src/adapters/inbound/cli/render/misc.ts @@ -1,5 +1,5 @@ import type { TextFormatter } from "../contracts/index.js"; -import { formatScalar, formatInt, formatUtc, num, methodName } from "./scalars.js"; +import { formatScalar, num, methodName } from "./scalars.js"; import { type Obj, type Pair, asObj, kv, query, receipt, table, titled, ok } from "./layout.js"; import { FAMILY_RENDER, renderFamily } from "./family.js"; diff --git a/ts/src/adapters/outbound/price/price.test.ts b/ts/src/adapters/outbound/price/price.test.ts index 9facaca4a..5df4e0055 100644 --- a/ts/src/adapters/outbound/price/price.test.ts +++ b/ts/src/adapters/outbound/price/price.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, vi } from "vitest"; -import { CoinGeckoPriceProvider, NullPriceProvider, createPriceProvider } from "./index.js"; +import { NullPriceProvider, createPriceProvider } from "./index.js"; afterEach(() => { vi.unstubAllGlobals(); diff --git a/ts/src/application/use-cases/evm/contract-service.ts b/ts/src/application/use-cases/evm/contract-service.ts index da29f5087..ab2919321 100644 --- a/ts/src/application/use-cases/evm/contract-service.ts +++ b/ts/src/application/use-cases/evm/contract-service.ts @@ -1,6 +1,5 @@ import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index.js"; import { resolveGasLimit } from "../../services/evm-gas-estimate.js"; -import { UsageError } from "../../../domain/errors/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"; From ee28612982a9e02079547d27d0b067efea8b3178 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 25 Aug 2026 03:09:22 +0800 Subject: [PATCH 05/23] feat(lint): clear --- ts/eslint.config.js | 3 ++- ts/src/adapters/inbound/cli/commands/contract.ts | 2 +- ts/src/adapters/inbound/cli/render/account.ts | 2 -- ts/src/application/services/target/index.ts | 4 ++-- ts/src/application/use-cases/tron/account-service.ts | 2 -- ts/src/bootstrap/migration-wiring.test.ts | 1 - ts/src/domain/migration/wallets-v2.test.ts | 4 ---- 7 files changed, 5 insertions(+), 13 deletions(-) diff --git a/ts/eslint.config.js b/ts/eslint.config.js index bb3e628f0..c81696933 100644 --- a/ts/eslint.config.js +++ b/ts/eslint.config.js @@ -4,7 +4,8 @@ import prettier from "eslint-config-prettier"; export default tseslint.config( { - ignores: ["dist/**", "node_modules/**", ".wallet-cli/**", ".private/**"], + // standalone build/verify scripts run under Node or Bun, not the CLI's stream ports + ignores: ["dist/**", "node_modules/**", ".wallet-cli/**", ".private/**", "scripts/**"], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index 73b43a5b2..75743842a 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -6,7 +6,7 @@ import type { TronContractService } from "../../../../application/use-cases/tron import type { DeployConstructorArgs } from "../../../../application/ports/chain/gateway-provider.js"; import type { EvmContractService } from "../../../../application/use-cases/evm/contract-service.js"; import type { TronContractParameter } from "../../../../application/ports/chain/tron-gateway.js"; -import { Schemas, addressFieldsFor, allRefines } from "../schemas/index.js"; +import { Schemas, addressFieldsFor } from "../schemas/index.js"; import { gweiToWei } from "../../../../domain/fees/evm-gas.js"; import { toBaseUnits } from "../../../../domain/amounts/index.js"; import { FAMILIES } from "../../../../domain/family/index.js"; diff --git a/ts/src/adapters/inbound/cli/render/account.ts b/ts/src/adapters/inbound/cli/render/account.ts index 3ae0638dc..ba7f3ec0f 100644 --- a/ts/src/adapters/inbound/cli/render/account.ts +++ b/ts/src/adapters/inbound/cli/render/account.ts @@ -2,10 +2,8 @@ import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; import { fromBaseUnits } from "../../../../domain/amounts/index.js"; import { formatScalar, - formatInt, formatUsd, formatUsdPrice, - formatSun, formatTime, num, quote, diff --git a/ts/src/application/services/target/index.ts b/ts/src/application/services/target/index.ts index 1e63b38ac..5ececc200 100644 --- a/ts/src/application/services/target/index.ts +++ b/ts/src/application/services/target/index.ts @@ -1,4 +1,4 @@ -import type { ChainFamily, NetworkDescriptor } from "../../../domain/types/index.js"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; import type { ExecutionPolicy, ExecutionSelection } from "../../contracts/index.js"; import type { NetworkRegistry } from "../../ports/network-registry.js"; import { UsageError } from "../../../domain/errors/index.js"; @@ -39,7 +39,7 @@ export class TargetResolver { return {}; } - const { network, reason } = this.resolveNetwork(selection); + const { network } = this.resolveNetwork(selection); if (policy.family && network.family !== policy.family) { throw new UsageError( diff --git a/ts/src/application/use-cases/tron/account-service.ts b/ts/src/application/use-cases/tron/account-service.ts index 89d97d556..8e26e54b9 100644 --- a/ts/src/application/use-cases/tron/account-service.ts +++ b/ts/src/application/use-cases/tron/account-service.ts @@ -1,11 +1,9 @@ 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 { 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"; diff --git a/ts/src/bootstrap/migration-wiring.test.ts b/ts/src/bootstrap/migration-wiring.test.ts index 818ed9d11..f9495348f 100644 --- a/ts/src/bootstrap/migration-wiring.test.ts +++ b/ts/src/bootstrap/migration-wiring.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from "node:os"; import { main } from "./runner.js"; const TRON_ADDR = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; -const EVM_ADDR = "0xe2E1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; async function runIn(walletsDoc: unknown, tokens: string[]) { const root = mkdtempSync(join(tmpdir(), "wcli-mig-")); diff --git a/ts/src/domain/migration/wallets-v2.test.ts b/ts/src/domain/migration/wallets-v2.test.ts index 037d12de4..574718dc3 100644 --- a/ts/src/domain/migration/wallets-v2.test.ts +++ b/ts/src/domain/migration/wallets-v2.test.ts @@ -38,10 +38,6 @@ describe("walletsNeedPassword", () => { const TRON_ADDR = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; const EVM_ADDR = "0xe2E1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; -const noSeeds = (): never => { - throw new Error("seed access must not be needed"); -}; - const seed = Derivation.mnemonicToSeed( "test test test test test test test test test test test junk", ); From 016e651efe8c65979e121ebdc43203e6d5a213b8 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 25 Aug 2026 11:18:20 +0800 Subject: [PATCH 06/23] fix(ci): test timeout --- ts/test/golden.test.ts | 8 +++++--- ts/vitest.config.ts | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 002b221cc..c4e4d5a59 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -34,7 +34,7 @@ function run(args: string[], opts: { input?: string; password?: string | null } finalArgs.push("--password-stdin"); stdin = (opts.password ?? DEFAULT_PW) + "\n"; } - // 18s < the suite's 20s testTimeout: a genuinely hung subprocess errors here with a clear + // 25s < the suite's 30s testTimeout: a genuinely hung subprocess errors here with a clear // signal instead of silently eating the whole test budget. // `node --import tsx` executes the same TypeScript entry without the tsx CLI's IPC control // socket, so black-box tests also run in restricted CI/sandbox environments. @@ -42,7 +42,7 @@ function run(args: string[], opts: { input?: string; password?: string | null } input: stdin, encoding: "utf8", env, - timeout: 18_000, + timeout: 25_000, ...DETACHED, } as SpawnSyncOptionsWithStringEncoding); let json: any; @@ -297,7 +297,9 @@ describe("golden CLI — wallet lifecycle (shared identity)", () => { const again = run(["--output", "json", "backup", "main", "--out", out]); expect(again.status).toBe(2); expect(again.json.error.code).toBe("output_exists"); - }, 15000); // seed encrypt + two backup decrypts run scrypt 3× → exceeds vitest's 5s default + // No per-test timeout: this is the suite's most expensive case (seed encrypt + two backup + // decrypts run scrypt 3×), so it needs the project's full budget, not a smaller slice of it. + }); it("supports root-level use and backup account commands", () => { seedWallet(); diff --git a/ts/vitest.config.ts b/ts/vitest.config.ts index 9c8dafd18..a105c9349 100644 --- a/ts/vitest.config.ts +++ b/ts/vitest.config.ts @@ -17,13 +17,15 @@ export default defineConfig({ // Golden tests spawn a fresh `node --import tsx src/index.ts` per case, which cold-transpiles the // whole CLI import graph each time. Under parallel CPU load a single spawn can take far // longer than vitest's default 5s testTimeout, causing intermittent timeout failures. - // Keep this suite above the child-process guard so hangs fail with subprocess details. + // 30s covers the heaviest case (~4s on a 10-core dev box) on CI's 2-core runner, where the + // same case has been measured past 15s. Keep this suite above the child-process guard so + // hangs fail with subprocess details. test: { name: "golden", environment: "node", include: ["test/**/*.test.ts"], - testTimeout: 20_000, - hookTimeout: 20_000, + testTimeout: 30_000, + hookTimeout: 30_000, }, }, ], From f5ee5a83d84d907e5cea41907c230c5107748950 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 25 Aug 2026 14:45:19 +0800 Subject: [PATCH 07/23] feat: requirement alignment review --- .../adapters/inbound/cli/commands/contact.ts | 6 +- .../adapters/inbound/cli/commands/contract.ts | 4 +- ts/src/adapters/inbound/cli/commands/tx.ts | 111 ++++++++---- .../adapters/inbound/cli/contracts/command.ts | 4 + ts/src/adapters/inbound/cli/help/catalog.ts | 11 +- ts/src/adapters/inbound/cli/help/help.test.ts | 26 ++- ts/src/adapters/inbound/cli/help/index.ts | 19 +- .../inbound/cli/render/family-render.test.ts | 4 +- ts/src/adapters/inbound/cli/render/family.ts | 5 +- ts/src/adapters/inbound/cli/shell/index.ts | 37 +++- .../outbound/chain/evm/node-errors.ts | 4 + .../outbound/chain/tron/node-errors.ts | 3 + ts/src/adapters/outbound/ledger/index.test.ts | 64 ++++++- ts/src/adapters/outbound/ledger/index.ts | 26 ++- .../use-cases/evm/account-service.test.ts | 11 +- .../use-cases/evm/account-service.ts | 6 +- ts/src/bootstrap/error-codes.test.ts | 66 +++++++ ts/src/domain/errors/codes.ts | 165 ++++++++++++++++++ ts/src/domain/fees/evm-gas.test.ts | 16 ++ ts/src/domain/fees/evm-gas.ts | 22 ++- 20 files changed, 549 insertions(+), 61 deletions(-) create mode 100644 ts/src/bootstrap/error-codes.test.ts create mode 100644 ts/src/domain/errors/codes.ts diff --git a/ts/src/adapters/inbound/cli/commands/contact.ts b/ts/src/adapters/inbound/cli/commands/contact.ts index f892f6b61..5fff78f7f 100644 --- a/ts/src/adapters/inbound/cli/commands/contact.ts +++ b/ts/src/adapters/inbound/cli/commands/contact.ts @@ -10,7 +10,11 @@ export function registerContactCommands(registry: CommandRegistry, service: Cont .string() .min(1) .max(256) - .describe("local name for this recipient; usable anywhere an address is accepted"), + // §3.11 states the rule the domain enforces (contactName): a name that looked like an + // address would make `--to ` ambiguous with the address it resembles. + .describe( + "local name for this recipient; 1-64 safe characters and must not look like a chain address. Usable anywhere an address is accepted", + ), address: z.string().min(1).max(128).describe("recipient address to store under this name"), note: z.string().max(512).optional().describe("free-form note, up to 128 safe characters"), }); diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index 75743842a..a4b83fa9e 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -198,8 +198,8 @@ const evmGasFields = z.object({ gasLimit: Schemas.positiveIntString() .optional() .describe("gas units to authorise; defaults to the node's estimate, unpadded"), - maxFee: z.string().optional().describe("maximum total fee per gas, in gwei (EIP-1559 only)"), - priorityFee: z.string().optional().describe("tip per gas, in gwei (EIP-1559 only)"), + maxFee: z.string().optional().describe("maximum total fee per gas, in gwei — 25 or 25gwei (EIP-1559 only)"), + priorityFee: z.string().optional().describe("tip per gas, in gwei — 25 or 25gwei (EIP-1559 only)"), nonce: z.coerce .number() .int() diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index fe9196fbc..ed0d68ad7 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -91,11 +91,11 @@ const evmSendFields = z.object({ maxFee: z .string() .optional() - .describe("maximum total fee per gas, in gwei (EIP-1559 chains only)"), + .describe("maximum total fee per gas, in gwei — 25 or 25gwei (EIP-1559 chains only)"), priorityFee: z .string() .optional() - .describe("tip per gas paid to the proposer, in gwei (EIP-1559 chains only)"), + .describe("tip per gas paid to the proposer, in gwei — 25 or 25gwei (EIP-1559 chains only)"), nonce: z.coerce .number() .int() @@ -106,11 +106,17 @@ const evmSendFields = z.object({ /** EVM has no multi-signature relay, so the artifact both ends exchange is raw hex: an unsigned * serialisation in, a signed one out. TRON's `--transaction` JSON has no EVM meaning. */ -function evmHexOnly(input: { transaction?: string; hex?: string; file?: string }): string { - if (input.transaction !== undefined) { +function evmHexOnly( + input: { transaction?: string; hex?: string; file?: string }, + hasStdin = false, +): string { + // `--transaction` no longer reaches here — it is declared by the TRON binding, so the flag check + // refuses it first. `--tx-stdin` is not a flag but a channel, so it still needs saying: a piped + // payload that is silently ignored is worse than one that is refused. + if (hasStdin) { throw new UsageError( "invalid_option", - "--transaction is the TRON JSON form; on an EVM network pass raw hex with --hex or --file", + "--tx-stdin carries the TRON JSON form; on an EVM network pass raw hex with --hex or --file", ); } return hexInput(input); @@ -125,7 +131,12 @@ export const txBroadcastEvmBinding = (svc: EvmTransactionService): FamilyBinding if (input.dryRun && ctx.wait) { throw new UsageError("invalid_option", "--wait cannot be used with --dry-run"); } - return svc.broadcast(ctx, net, evmHexOnly(input), input.dryRun === true); + return svc.broadcast( + ctx, + net, + evmHexOnly(input, ctx.secrets.has("tx")), + input.dryRun === true, + ); }, }); @@ -147,8 +158,14 @@ export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => run: async (ctx, net, input) => svc.send(ctx, net, input), }); -const broadcastFields = z.object({ +/** TRON's JSON form of a signed transaction. Declared by the TRON binding alone, which is what + * makes help tag it `(tron)` and every other family refuse it — the same treatment `--asset-id` + * gets. EVM has no JSON transaction: it exchanges RLP hex. */ +const tronBroadcastFields = z.object({ transaction: z.string().optional().describe("signed transaction JSON"), +}); + +const broadcastFields = z.object({ hex: z.string().min(2).optional().describe("signed transaction hex: protobuf hex for TRON, RLP for EVM"), file: z .string() @@ -166,6 +183,10 @@ const broadcastFields = z.object({ export const txBroadcastSpec: ChainSpec = { path: ["tx", "broadcast"], stdin: "tx", + // The channel carries TRON's transaction JSON, and only the TRON binding reads it. Declaring + // that is what tags it `(tron)` in help and lets any other family refuse it outright, instead + // of ignoring a payload the caller piped in. + stdinFamily: "tron", network: "optional", wallet: "none", auth: "none", @@ -183,13 +204,11 @@ export const txBroadcastSpec: ChainSpec = { }, ], baseRefine: (input, context) => { - if ( - [input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length > 1 - ) { + if ([input.hex, input.file].filter((entry) => entry !== undefined).length > 1) { context.addIssue({ code: "custom", - path: ["transaction"], - message: "--transaction, --hex, and --file are mutually exclusive", + path: ["hex"], + message: "--hex and --file are mutually exclusive", }); } }, @@ -202,6 +221,18 @@ export const txBroadcastSpec: ChainSpec = { }; export const txBroadcastTronBinding = (service: TronMultisigService): FamilyBinding => ({ + fields: tronBroadcastFields, + refine: (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", + }); + } + }, run: async (ctx, net, input) => { if (input.dryRun && ctx.wait) { throw new UsageError("invalid_option", "--wait cannot be used with --dry-run"); @@ -255,12 +286,18 @@ export const txApprovalsTronBinding = (service: TronMultisigService): FamilyBind run: async (_ctx, network, input) => service.approvals(network, hexInput(input)), }); -const signFields = z.object({ +/** The TRON compatibility path, declared by the TRON binding so help tags it `(tron)`; see + * tronBroadcastFields. Its two companion rules (`--out` / `--offline` are hex-only) travel with + * it, because they are only meaningful where `--transaction` exists. */ +const tronSignFields = z.object({ transaction: z .string() .min(1) .optional() .describe("unsigned transaction JSON; TRON compatibility path, never checked online"), +}); + +const signFields = z.object({ ...artifactFields, offline: z .boolean() @@ -296,27 +333,15 @@ export const txSignSpec: ChainSpec = { // --hex/--file first: --transaction is the compatibility path, not the co-signing one. exclusive: [{ label: "the transaction to co-sign", flags: ["hex", "file", "transaction"] }], baseRefine: (input, context) => { + // On a family without `--transaction` this is the whole rule: one of --hex / --file. if ( - [input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length !== 1 + input.transaction === undefined && + [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", - }); - } - if (input.offline && input.transaction) { - context.addIssue({ - code: "custom", - path: ["offline"], - message: "--offline is only valid with --hex or --file", + path: ["hex"], + message: "provide exactly one of --hex or --file", }); } }, @@ -337,6 +362,32 @@ export const txSignTronBinding = ( multisigService: TronMultisigService, writer: TransactionArtifactWriter, ): FamilyBinding => ({ + fields: tronSignFields, + refine: (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", + }); + } + if (input.offline && input.transaction) { + context.addIssue({ + code: "custom", + path: ["offline"], + message: "--offline is only valid with --hex or --file", + }); + } + }, run: async (ctx, net, input) => { exactlyOne( [input.transaction, input.hex, input.file], diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index a30c864da..de0bad027 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -142,6 +142,10 @@ export interface ChainSpec<_I = any, O = any> { broadcasts?: boolean; capability?: string; stdin?: StdinChannel; + /** the stdin channel belongs to ONE family (e.g. `--tx-stdin` carries TRON's transaction JSON). + * Help tags the flag with it and every other family refuses it, the same way a flag declared in + * a single family's binding behaves — a channel no other family reads must say so. */ + stdinFamily?: ChainFamily; interactive?: boolean; passwordMode?: "establish" | "verify"; positionals?: { field: string; placeholder?: string }[]; diff --git a/ts/src/adapters/inbound/cli/help/catalog.ts b/ts/src/adapters/inbound/cli/help/catalog.ts index cea9d2fa6..4f111590d 100644 --- a/ts/src/adapters/inbound/cli/help/catalog.ts +++ b/ts/src/adapters/inbound/cli/help/catalog.ts @@ -15,6 +15,7 @@ import type { import { CommandRegistry } from "../registry/index.js"; import { commandId } from "../command-id.js"; import { GLOBAL_FLAG_SPECS, type GlobalFlagSpec } from "../globals/index.js"; +import { ERROR_CODES } from "../../../../domain/errors/codes.js"; // Flags accepted on every command (kubectl-style globals + secret channels). The flag model — arity, // descriptions, defaults, and the global-vs-command-scoped split — is owned by domain metadata @@ -113,7 +114,15 @@ export function buildCatalog( }, ) .sort((a, b) => a.id.localeCompare(b.id)); - return JSON.stringify({ tool: "wallet-cli", version, globalFlags: GLOBAL_FLAGS, commands }); + // The error index travels with the command surface: an agent discovering what it can call also + // learns, in the same call, every `error.code` those calls can answer with. + return JSON.stringify({ + tool: "wallet-cli", + version, + globalFlags: GLOBAL_FLAGS, + errorCodes: ERROR_CODES, + commands, + }); } function mergedInput(def: ChainCommandDefinition): z.ZodType { diff --git a/ts/src/adapters/inbound/cli/help/help.test.ts b/ts/src/adapters/inbound/cli/help/help.test.ts index b04da035b..a663fe3f0 100644 --- a/ts/src/adapters/inbound/cli/help/help.test.ts +++ b/ts/src/adapters/inbound/cli/help/help.test.ts @@ -2,8 +2,13 @@ import { describe, it, expect } from "vitest"; import { z } from "zod"; import { HelpService } from "./index.js"; import { CommandRegistry } from "../registry/index.js"; -import type { ChainSpec, StreamManager } from "../contracts/index.js"; -import { txBroadcastSpec, txSendSpec, txTronLinkMultisigSpec } from "../commands/tx.js"; +import type { ChainSpec, FamilyBinding, StreamManager } from "../contracts/index.js"; +import { + txBroadcastSpec, + txBroadcastTronBinding, + txSendSpec, + txTronLinkMultisigSpec, +} from "../commands/tx.js"; import { messageSignSpec } from "../commands/shared.js"; // ── minimal fakes ───────────────────────────────────────────────────────────── @@ -78,9 +83,11 @@ describe("HelpService --json-schema", () => { // Asserting the spec object is not enough: the renderer resolves members by kebab flag name, so a // group can be well-formed and still never appear. These render the real specs end to end. describe("shipped exclusive groups actually render", () => { - function optionsOf(spec: ChainSpec): string[] { + // The binding matters here: flags a family declares itself (`--transaction`) live on it, not on + // the spec, so a stub binding would render help missing exactly those rows. + function optionsOf(spec: ChainSpec, binding?: FamilyBinding): string[] { const reg = new CommandRegistry(); - reg.addChain(spec, "tron", { run: async () => ({}) }); + reg.addChain(spec, "tron", binding ?? { run: async () => ({}) }); const stream = makeStream(); new HelpService(reg, stream, "0.0.0").handleMeta([...spec.path, "--help"]); const lines = (stream.last ?? "").split("\n"); @@ -111,7 +118,7 @@ describe("shipped exclusive groups actually render", () => { }); it("renders tx broadcast's group including the stdin channel flag", () => { - const out = optionsOf(txBroadcastSpec); + const out = optionsOf(txBroadcastSpec, txBroadcastTronBinding({} as never)); expect(out[0]).toBe(" Exactly one of these — the signed transaction to broadcast:"); expect(out.slice(1, 5).map((l) => l.trim().split(" ")[0])).toEqual([ "--transaction", @@ -119,6 +126,15 @@ describe("shipped exclusive groups actually render", () => { "--hex", "--file", ]); + // The two TRON-only members say so. A jointly-required group drops "[optional]" from its rows + // (that tag would contradict the group), but the family tag is a different fact and survives: + // without it, "no tag" would mean both "every family" and "we did not move the flag". + expect(out[1]).toContain("(tron)"); + expect(out[2]).toContain("(tron)"); + expect(out[1]).not.toContain("[optional]"); + // --hex and --file are read by both families and stay untagged. + expect(out[3]).not.toContain("(tron)"); + expect(out[4]).not.toContain("(tron)"); }); // tx multisig's three modes are rejected in combination by tronLinkMultisigRefine. Without the diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index dd7d241c4..3d2b132ad 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -316,6 +316,7 @@ export class HelpService { fields: introspectFields(mergedFields(def)), fieldFamilies: fieldFamilies(def), inputFlags: spec.stdin ? inputFlagsFor(spec) : [], + stdinFamily: spec.stdinFamily, exclusive: spec.exclusive, examples: spec.examples, requires: spec.requires, @@ -337,6 +338,8 @@ export class HelpService { /** family-specific flags, so each can be marked with the family it belongs to. */ fieldFamilies?: Map; inputFlags: readonly GlobalFlag[]; + /** family that owns the stdin channel, when only one family reads it. */ + stdinFamily?: ChainFamily; exclusive?: ChainSpec["exclusive"]; examples: CommandDefinition["examples"]; requires?: string[]; @@ -406,7 +409,8 @@ export class HelpService { key: f.kebab, head: flagHead(f), desc: f.description ?? "", - tag: family ? `${flagTag(f)}${flagTag(f) ? " " : ""}(${family})` : flagTag(f), + tag: flagTag(f), + ...(family ? { familyTag: `(${family})` } : {}), }; }), ...c.inputFlags.map((g) => ({ @@ -414,12 +418,18 @@ export class HelpService { head: globalFlagHead(g), desc: g.description, tag: globalFlagTag(g), + ...(c.stdinFamily ? { familyTag: `(${c.stdinFamily})` } : {}), })), ]; if (optionRows.length) { const width = Math.min(34, Math.max(...optionRows.map((r) => r.head.length))); - const rowLine = (r: OptionRow, tag: string): string => - ` ${r.head.padEnd(width)} ${r.desc}${r.desc && tag ? " " : ""}${tag}`.trimEnd(); + // Two independent tags: "[optional]" says whether the flag may be omitted, "(tron)" says + // which family reads it. They are joined here so the family tag survives on its own in an + // exclusive block, where the optional tag is deliberately dropped. + const rowLine = (r: OptionRow, tag: string): string => { + const tags = [tag, r.familyTag].filter(Boolean).join(" "); + return ` ${r.head.padEnd(width)} ${r.desc}${r.desc && tags ? " " : ""}${tags}`.trimEnd(); + }; // an exclusive set renders as its own labelled block, ahead of the free-standing options. // A jointly-required set drops the per-member "[optional]" tag: individually true, but read // together it says the whole set may be omitted — which is exactly what the runtime rejects. @@ -585,7 +595,10 @@ interface OptionRow { key: string; head: string; desc: string; + /** "[optional]" / "[required]" — dropped inside a jointly-required exclusive block. */ tag: string; + /** "(tron)" — which family reads this flag; independent of `tag`, so it survives that block. */ + familyTag?: string; } function flagHead(f: FieldInfo): string { diff --git a/ts/src/adapters/inbound/cli/render/family-render.test.ts b/ts/src/adapters/inbound/cli/render/family-render.test.ts index 1b242868e..33adc1fed 100644 --- a/ts/src/adapters/inbound/cli/render/family-render.test.ts +++ b/ts/src/adapters/inbound/cli/render/family-render.test.ts @@ -137,8 +137,8 @@ describe("FAMILY_RENDER accountInfoRows", () => { const rows = FAMILY_RENDER.evm.accountInfoRows(EVM_ACCOUNT, "ETH"); expect(rows).toContainEqual(["Nonce", "16"]); - // json says `eoa` — a field value; the text row is the sentence version of the same fact. - expect(rows).toContainEqual(["Type", "externally owned"]); + // Uppercase `EOA` — the standard name, and the text twin of json's `eoa` (§4.3). + expect(rows).toContainEqual(["Type", "EOA"]); expect( FAMILY_RENDER.evm.accountInfoRows({ ...EVM_ACCOUNT, type: "contract" }, "ETH"), ).toContainEqual(["Type", "contract"]); diff --git a/ts/src/adapters/inbound/cli/render/family.ts b/ts/src/adapters/inbound/cli/render/family.ts index f329fb41a..57ecac001 100644 --- a/ts/src/adapters/inbound/cli/render/family.ts +++ b/ts/src/adapters/inbound/cli/render/family.ts @@ -186,8 +186,9 @@ export const FAMILY_RENDER: Record = { ["Balance", `${formatWei(d.balance)} ${symbol}`], ["Nonce", formatInt(d.nonce)], // The distinction a reader needs before sending: an address with code may reject a plain - // transfer. json says `eoa`, which is a field value; this is the sentence version of it. - ["Type", d.type === "contract" ? "contract" : "externally owned"], + // transfer. `EOA` rather than a spelled-out phrase: it is the standard name on this chain, + // it is what json's `eoa` says, and it stays a noun beside `contract` (§4.3). + ["Type", d.type === "contract" ? "contract" : "EOA"], ]; // Only a contract has code, so only a contract gets the row (§4.3 — an EOA is not a // contract with zero bytes). diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts index f336baca8..b26cf1d5f 100644 --- a/ts/src/adapters/inbound/cli/shell/index.ts +++ b/ts/src/adapters/inbound/cli/shell/index.ts @@ -6,7 +6,11 @@ import yargs, { type Argv } from "yargs"; import { randomBytes } from "node:crypto"; import { type RefinementCtx, type ZodObject, type ZodRawShape, type ZodType } from "zod"; -import type { AccountDescriptor, NetworkDescriptor } from "../../../../domain/types/index.js"; +import type { + AccountDescriptor, + ChainFamily, + NetworkDescriptor, +} from "../../../../domain/types/index.js"; import { isChainCommand } from "../contracts/index.js"; import type { ChainCommandDefinition, @@ -266,7 +270,7 @@ async function executeChainCommand( const effectiveInput = composeRefines(effectiveFields, spec.baseRefine, binding.refine); const executionSpec = withFields(spec, effectiveFields); // order matters: the flag check must see argv before positionals are bound onto their fields - assertKnownFlags(executionSpec, argv); + assertKnownFlags(executionSpec, argv, otherFamilyFlags(def, net.family)); argv = bindGroupedPositionals(spec, argv); deps.prompter.setInteractive(isInteractiveCommand(spec)); caps.check(spec, net); @@ -477,9 +481,29 @@ function randomWalletLabel(): string { * and zod would silently strip them). Allowed = positionals + globals + THIS command's fields * (a sibling command's flag in the same namespace is unknown here). → invalid_option, exit 2. */ +/** Flags another family of this command declares, so a flag that exists — just not here — can say + * so instead of arriving as a bare "unknown option". */ +function otherFamilyFlags( + def: ChainCommandDefinition, + selected: ChainFamily, +): Map { + const out = new Map(); + for (const [family, binding] of Object.entries(def.families) as [ + ChainFamily, + ChainCommandDefinition["families"][ChainFamily], + ][]) { + if (family === selected) continue; + for (const name of Object.keys(binding?.fields?.shape ?? {})) { + out.set(camelToKebab(name), family); + } + } + return out; +} + function assertKnownFlags( cmd: Pick, argv: any, + otherFamily: Map = new Map(), ): void { const allowed = new Set(["_", "$0", "group", "verb", "args", "source"]); const add = (name: string) => { @@ -514,7 +538,14 @@ function assertKnownFlags( if (unknown.length > 0) { throw new UsageError( "invalid_option", - `unknown option(s): ${unknown.map((u) => `--${u}`).join(", ")}`, + `unknown option(s): ${unknown + .map((u) => { + const family = otherFamily.get(u); + // "--transaction is a tron option" is the answer; "unknown option --transaction" sends + // the reader hunting for a typo in a flag that exists. + return family ? `--${u} (a ${family} option on this command)` : `--${u}`; + }) + .join(", ")}`, ); } } diff --git a/ts/src/adapters/outbound/chain/evm/node-errors.ts b/ts/src/adapters/outbound/chain/evm/node-errors.ts index 73a6112e8..10bd8bc95 100644 --- a/ts/src/adapters/outbound/chain/evm/node-errors.ts +++ b/ts/src/adapters/outbound/chain/evm/node-errors.ts @@ -42,6 +42,10 @@ export function isAlreadyKnown(message: string): boolean { return /already known|known transaction|already exists|transaction already in pool/i.test(message); } +/** the codes this table can produce — the error-code registry checks itself against it, so a new + * rule here cannot ship without an entry there. */ +export const EVM_REJECTION_CODES: readonly string[] = PATTERNS.map(([, code]) => code); + export function classifyEvmRejection(message: string): EvmRejection | undefined { for (const [pattern, code, text] of PATTERNS) { if (pattern.test(message)) return { code, message: text }; diff --git a/ts/src/adapters/outbound/chain/tron/node-errors.ts b/ts/src/adapters/outbound/chain/tron/node-errors.ts index 790123150..ca284bcec 100644 --- a/ts/src/adapters/outbound/chain/tron/node-errors.ts +++ b/ts/src/adapters/outbound/chain/tron/node-errors.ts @@ -51,6 +51,9 @@ const RULES: Array<{ match: RegExp; rejection: NodeRejection }> = [ }, ]; +/** the codes this table can produce; see EVM_REJECTION_CODES. */ +export const TRON_REJECTION_CODES: readonly string[] = RULES.map((r) => r.rejection.code); + /** * java-tron wraps an actuator's message in its own envelope before it reaches the wire, e.g. * `Contract validate error : ExchangeTransactionContract is rejected`. Strip that wrapper so the diff --git a/ts/src/adapters/outbound/ledger/index.test.ts b/ts/src/adapters/outbound/ledger/index.test.ts index 49e7b9909..9b677002d 100644 --- a/ts/src/adapters/outbound/ledger/index.test.ts +++ b/ts/src/adapters/outbound/ledger/index.test.ts @@ -8,10 +8,19 @@ import { Ledger } from "./index.js"; const { closeSpy, tip712Calls, failures } = vi.hoisted(() => ({ closeSpy: vi.fn(async () => {}), tip712Calls: [] as Array<{ path: string; domainHash: string; messageHash: string }>, - failures: { tip712: undefined as Error | undefined, tip712Hang: false }, + failures: { + tip712: undefined as Error | undefined, + tip712Hang: false, + open: undefined as Error | undefined, + }, })); vi.mock("@ledgerhq/hw-transport-node-hid-noevents", () => ({ - default: { open: async () => ({ close: closeSpy }) }, + default: { + open: async () => { + if (failures.open) throw failures.open; + return { close: closeSpy }; + }, + }, })); // Every device APDU never resolves — models an on-device prompt that is never tapped. vi.mock("@ledgerhq/hw-app-trx", () => ({ @@ -295,3 +304,54 @@ describe("Ledger TIP-712", () => { expect(tip712Calls).toHaveLength(0); }); }); + +/** + * The two device states a user hits most, and they used to arrive as the same code. + * + * `auth_required` for an unplugged device claims a credential is missing when nothing is + * connected, and it read identically to a connected-but-locked device — whose fix (enter the PIN) + * has nothing to do with the other one (plug it in). §11 names them separately for that reason. + */ +describe("Ledger device states", () => { + it("reports an unreachable device as device_not_found, not auth_required", async () => { + failures.open = new Error("cannot open device"); + try { + await expect(new Ledger(2000).getAddress("tron", PATH)).rejects.toMatchObject({ + code: "device_not_found", + }); + } finally { + failures.open = undefined; + } + }); + + it("reports a locked device as device_locked, whichever way the transport says so", async () => { + for (const e of [ + Object.assign(new Error("Ledger device: Locked device (0x5515)"), { statusCode: 0x5515 }), + Object.assign(new Error("Locked device"), { name: "LockedDeviceError" }), + ]) { + failures.open = e; + try { + await expect(new Ledger(2000).getAddress("tron", PATH)).rejects.toMatchObject({ + code: "device_locked", + }); + } finally { + failures.open = undefined; + } + } + }); + + it("maps a locked device reported as an APDU status word to device_locked", async () => { + failures.tip712 = Object.assign(new Error("Locked device (0x5515)"), { statusCode: 0x5515 }); + try { + await expect( + new Ledger(2000).signTypedData("tron", PATH, { + domain: { name: "X", version: "1", chainId: 1 }, + types: { A: [{ name: "x", type: "uint256" }] }, + message: { x: "1" }, + }), + ).rejects.toMatchObject({ code: "device_locked" }); + } finally { + failures.tip712 = undefined; + } + }); +}); diff --git a/ts/src/adapters/outbound/ledger/index.ts b/ts/src/adapters/outbound/ledger/index.ts index 1fee4894a..ade32307c 100644 --- a/ts/src/adapters/outbound/ledger/index.ts +++ b/ts/src/adapters/outbound/ledger/index.ts @@ -148,6 +148,15 @@ const APP_SETTING_REQUIRED: Record = { 0x6a8d: 'enable "Custom contracts" in the Ledger TRON app settings to sign this contract call', }; +/** 0x5515 (LOCKED_DEVICE) — the device is connected but its PIN has not been entered. hw-transport + * also raises a named `LockedDeviceError` on paths that never reach an APDU status word. */ +function isLockedDevice(e: unknown): boolean { + return ( + (e as { statusCode?: number }).statusCode === 0x5515 || + (e as { name?: string }).name === "LockedDeviceError" + ); +} + /** Map a thrown device/app error to a typed CliError (user-rejection vs not-ready). */ function classifyDeviceError(e: unknown): CliError { if (e instanceof CliError) return e; @@ -155,6 +164,13 @@ function classifyDeviceError(e: unknown): CliError { const status = (e as { statusCode?: number }).statusCode; if (status === 0x6985) return new ChainError("signing_rejected", "the operation was rejected on the device"); + // Its own code, not the generic device bucket: "connected but locked" has exactly one fix, and + // `auth_required` would send the reader looking for a password this CLI never asked for. + if (isLockedDevice(e)) + return new WalletError( + "device_locked", + "the Ledger device is locked — unlock it with your PIN and run the command again", + ); // 0x6d00 (INS_NOT_SUPPORTED) is a standard status word every Ledger app shares — the app version // does not implement this instruction, or the wrong app is open. Kept chain- and operation-agnostic // on purpose: classifyDeviceError fires for any family and any call. @@ -225,8 +241,14 @@ export class Ledger { try { handle = await openTransport(); } catch (e) { - // no device / emulator reachable — the pipeline treats this as "device not ready". - throw new ExecutionError("auth_required", `cannot reach Ledger device: ${errMessage(e)}`); + // Nothing answered on USB/HID (or at the Speculos endpoint). `auth_required` said the wrong + // thing here — there is no credential to supply, the device simply is not there — and it + // read the same as a locked device, whose fix is entirely different. + if (isLockedDevice(e)) throw classifyDeviceError(e); + throw new WalletError( + "device_not_found", + `cannot reach a Ledger device — connect it, unlock it, and open the app: ${errMessage(e)}`, + ); } if (cancelled) { await handle.close().catch(() => {}); 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 d2bada072..4cee56144 100644 --- a/ts/src/application/use-cases/evm/account-service.test.ts +++ b/ts/src/application/use-cases/evm/account-service.test.ts @@ -36,7 +36,7 @@ describe("EvmAccountService.info", () => { expect(out).toMatchObject({ address: "0xADDR", balance: "1000000000000000000", - nonce: "7", + nonce: 7, decimals: 18, symbol: "ETH", }); @@ -58,9 +58,12 @@ describe("EvmAccountService.info", () => { expect(out.codeSize).toBe(4); }); - it("keeps the nonce a decimal string, so a large one cannot lose precision", async () => { - const out = await service({ nonce: "9007199254740993" }).info(scope, net); - expect(out.nonce).toBe("9007199254740993"); + // The regression this guards: `account info` used to answer with a decimal STRING while + // `tx info` answered the same field with a number, so an agent reading one and feeding the + // other saw the type change under it. + it("reports the nonce as a number, the same carrier tx info uses", async () => { + const out = await service({ nonce: "42" }).info(scope, net); + expect(out.nonce).toBe(42); }); }); diff --git a/ts/src/application/use-cases/evm/account-service.ts b/ts/src/application/use-cases/evm/account-service.ts index ae59d8506..89ec9be16 100644 --- a/ts/src/application/use-cases/evm/account-service.ts +++ b/ts/src/application/use-cases/evm/account-service.ts @@ -115,8 +115,10 @@ export class EvmAccountService { return { address, balance, - // a decimal string, not a number: nonces are small today but the carrier stays lossless. - nonce, + // A number, matching `tx info`'s own `nonce` (§4.3): one field name must not arrive as two + // types across two commands. Safe as a number — a nonce counts an account's transactions, + // so it cannot approach 2^53 the way a wei balance does. + nonce: Number(nonce), decimals: FAMILIES.evm.nativeDecimals, symbol: network.nativeSymbol, type: isContract ? "contract" : "eoa", diff --git a/ts/src/bootstrap/error-codes.test.ts b/ts/src/bootstrap/error-codes.test.ts new file mode 100644 index 000000000..80a75e81b --- /dev/null +++ b/ts/src/bootstrap/error-codes.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { ERROR_CODES } from "../domain/errors/codes.js"; +import { EVM_REJECTION_CODES } from "../adapters/outbound/chain/evm/node-errors.js"; +import { TRON_REJECTION_CODES } from "../adapters/outbound/chain/tron/node-errors.js"; + +/** + * The drift guard behind the "single index" promise. + * + * A table of error codes maintained by hand is accurate on the day it is written and wrong a week + * later — which is how §11 came to list nine codes the implementation never produces while thirty + * it does produce went undocumented. So the index is checked against the source: every code the + * code can throw must have an entry, and every entry must correspond to a code that still exists. + * + * It lives in bootstrap because it reads across every layer, which only the composition root may + * do; the index itself stays in `domain/errors`, beside the errors it describes. + */ +const SRC = fileURLToPath(new URL("..", import.meta.url)); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return entry.isFile() && entry.name.endsWith(".ts") && !entry.name.includes(".test.") + ? [path] + : []; + }); +} + +/** Codes thrown as literals. The two node-rejection tables build theirs from data, so they publish + * their own lists rather than being scraped. */ +function producedCodes(): Set { + const thrown = /new (?:Usage|Execution|Chain|Wallet|Transport)Error\(\s*"([a-z_0-9]+)"/g; + const codes = new Set([...EVM_REJECTION_CODES, ...TRON_REJECTION_CODES]); + for (const file of sourceFiles(SRC)) { + const text = readFileSync(file, "utf8"); + for (const match of text.matchAll(thrown)) codes.add(match[1]!); + } + return codes; +} + +describe("the error-code index", () => { + it("has an entry for every code the source can throw", () => { + const undocumented = [...producedCodes()].filter((code) => !(code in ERROR_CODES)).sort(); + expect(undocumented).toEqual([]); + }); + + // The other direction, so the index does not accumulate codes that were renamed or removed — + // an agent branching on a code that can never arrive is writing dead handling. + it("has no entry for a code nothing throws", () => { + const produced = producedCodes(); + const stale = Object.keys(ERROR_CODES) + .filter((code) => !produced.has(code)) + .sort(); + expect(stale).toEqual([]); + }); + + it("gives every code a one-line meaning", () => { + for (const [code, meaning] of Object.entries(ERROR_CODES)) { + expect(meaning, code).toMatch(/^[a-z(]/); + expect(meaning, code).not.toMatch(/\n/); + } + }); +}); diff --git a/ts/src/domain/errors/codes.ts b/ts/src/domain/errors/codes.ts new file mode 100644 index 000000000..54b90bc60 --- /dev/null +++ b/ts/src/domain/errors/codes.ts @@ -0,0 +1,165 @@ +/** + * The error-code index. + * + * §11 of the requirements calls its table "the only error-code index … no code outside it may + * appear". That promise is worth keeping — an agent branches on `error.code`, and a code it has + * never seen documented is a code it cannot handle — but a hand-maintained table drifts the moment + * someone adds an error. So the index lives here, next to the errors themselves, and + * `codes.test.ts` fails the build when a code is produced without an entry (or an entry outlives + * the code it described). The `--json-schema` catalog publishes it, so discovery is one call. + * + * Each entry is one line: what happened, from the caller's side. Not what to do about it — that + * belongs in the message, which can name the file, flag or address involved. + */ +export const ERROR_CODES = { + // ── invocation: the command line itself (exit 2) ────────────────────────── + usage_error: "the command line could not be parsed", + unknown_command: "no such command path, including under --help / --json-schema", + invalid_option: "an option is not accepted here, or contradicts another one", + missing_option: "a required option was not given", + invalid_value: "an option's value is not of the shape that option takes", + unknown_parameter: "no chain parameter by that name", + limit_exceeded: "a bounded input (file size, list length, page size) was over its limit", + + // ── selection: account, network, family ─────────────────────────────────── + family_mismatch: + "the account, recipient, raw transaction or command does not belong to the selected network's chain", + missing_network: "the command needs a network and none was selected or configured", + unsupported_network: "no network by that id or alias", + unsupported_network_capability: "the selected network does not offer what this command needs", + missing_wallet_address: "no account is available to act as", + account_exists: "an account with that address is already in the keystore", + invalid_account: "the account reference is not well-formed", + not_exportable: "the account holds no exportable secret (watch-only or Ledger)", + no_software_wallet: "the operation needs a locally stored key and none exists", + watch_only_no_signer: "the selected account can be watched but cannot sign", + + // ── secrets, keystore, local files ──────────────────────────────────────── + auth_required: "the master password is needed and was not available", + auth_failed: "the master password was wrong", + weak_password: "the proposed master password does not meet the strength rule", + wrong_keystore_password: "the keystore file's own password was wrong", + invalid_keystore: "the file is not a valid V3 keystore", + keystore_not_found: "no keystore file at that path", + secret_source_error: "a secret channel (stdin / TTY) could not be read", + tty_required: "the operation only accepts input from a terminal, and there is none", + entropy_failure: "the system random source failed", + insecure_permissions: "a wallet file's permissions are wider than 0600", + migration_required: "a registry file is older than this build and must be migrated first", + audit_append_failed: "the local export/audit log could not be appended to", + file_not_found: "an input file does not exist", + output_exists: "the output path is already taken and would be overwritten", + io_error: "a local read or write failed", + encoding_error: "data on disk or on the wire is not in the form its format requires", + + // ── configuration ───────────────────────────────────────────────────────── + invalid_config: "the config file is malformed, or a network in it is missing a required field", + insecure_config: "the config file's permissions or contents are unsafe to load", + + // ── address book & token book ───────────────────────────────────────────── + contact_not_found: "no contact by that name, and the value is not an address either", + invalid_address: "the value is not a valid address for the relevant chain", + already_exists: "a contact with that name or address is already stored", + token_not_in_book: "no token by that reference in the local address book", + token_already_listed: "that token is already in the local address book", + token_is_official: "the entry is a built-in and cannot be edited or removed", + token_metadata_unavailable: "the token's on-chain metadata could not be read", + unsupported_token: "the token standard is not one this command handles", + ambiguous_token_symbol: "the symbol matches more than one token; address it by contract", + ambiguous_asset_name: "the TRC10 name matches more than one asset; address it by id", + + // ── transaction construction & signing ──────────────────────────────────── + invalid_transaction: "the transaction is malformed, or already carries a signature", + invalid_payload: "the payload does not decode as what the flag says it is", + invalid_amount: "the amount is not positive, or is finer than the asset's precision", + precision_loss: "the amount cannot be represented exactly at the required precision", + tx_integrity: "the transaction re-encoded differently than it arrived — it was altered in flight", + chain_id_mismatch: "the transaction was built for a different chain than the one selected", + signing_rejected: "the signature was declined on the device", + dry_run_violation: "a --dry-run path attempted to broadcast; the attempt was barred", + invalid_permission: "no such permission group on the account, or it cannot be used here", + not_authorized: "the account is not permitted to perform this operation", + already_signed: "this account has already signed the transaction", + already_approved: "the approval was already recorded", + not_approved: "the transaction has not gathered the approvals it needs", + tx_expired: "the transaction's expiration has passed", + + // ── broadcast & confirmation ────────────────────────────────────────────── + transaction_rejected: "the node refused the transaction, in its own words", + nonce_too_low: "nonce already used; the account has moved on", + nonce_too_high: "nonce is ahead of the account; an earlier transaction is missing", + replacement_underpriced: "replacing a pending transaction needs a higher fee than the original", + gas_too_low: "the gas limit is below what this transaction needs", + gas_limit_exceeded: "the gas limit exceeds the block gas limit", + fee_too_low: "the fee is below what the network is currently accepting", + insufficient_balance: "the balance cannot cover the amount plus the maximum fee", + insufficient_token_balance: "the token balance cannot cover the amount", + execution_reverted: "the contract reverted the call", + execution_error: "the transaction ran on-chain and failed", + not_found: "the transaction, block or record does not exist at this node", + + // ── node & external services ────────────────────────────────────────────── + rpc_error: "the node answered with an error", + invalid_node_response: "the node's answer was not in the shape the API defines", + provider_error: "an external service failed", + provider_rate_limited: "an external service is rate-limiting this client", + timeout: "the node, service or device did not answer in time", + aborted: "the operation was stopped before it finished", + cancelled: "the operation was cancelled before it reached the device", + history_not_supported: "the selected network exposes no transaction history endpoint", + chain_parameter_unavailable: "the node does not report that chain parameter", + gasfree_auth_failed: "the GasFree service rejected the request's credentials", + gasfree_credentials_missing: "no GasFree credentials are configured", + gasfree_integrity: "the GasFree service's answer failed its integrity check", + gasfree_rejected: "the GasFree service refused the transfer", + tronlink_credentials_missing: "no TronLink multi-sig service credentials are configured", + + // ── hardware wallet ─────────────────────────────────────────────────────── + device_not_found: "no Ledger device answered", + device_locked: "the Ledger device is connected but locked", + ledger_setting_required: "a setting in the Ledger app must be enabled for this operation", + ledger_unsupported: "the Ledger app does not implement this operation or cannot decode it", + ledger_address_not_found: "the address was not found within the scanned derivation range", + wrong_device_seed: "the device holds a different seed than the account was registered with", + + // ── TRON: resources, staking, voting, rewards ───────────────────────────── + account_not_active: "the account is not activated on-chain", + account_already_active: "the account is already activated on-chain", + insufficient_stake: "the staked amount cannot cover this operation", + insufficient_voting_power: "the account has less voting power than the votes cast", + no_frozen_supply: "there is nothing frozen to act on", + not_yet_unfreezable: "the stake is still within its lock-up period", + nothing_to_withdraw: "there is nothing available to withdraw", + withdraw_too_frequent: "the withdrawal interval has not elapsed yet", + no_reward: "there is no reward to claim", + not_a_witness: "the address is not a witness", + already_witness: "the address is already a witness", + + // ── TRON: assets, proposals, exchanges, contracts ───────────────────────── + asset_not_found: "no TRC10 asset by that id or name", + invalid_asset_name: "the TRC10 name is not of an acceptable form", + already_issued_asset: "the account has already issued a TRC10 asset", + not_an_issuer: "the account did not issue this asset", + not_in_ico_window: "the asset's participation window is not open", + id_taken: "that id is already in use", + proposal_not_found: "no proposal by that id", + proposal_expired: "the proposal's voting window has closed", + not_proposal_owner: "the account did not create this proposal", + already_canceled: "the proposal was already withdrawn", + exchange_not_found: "no Bancor exchange pair by that id", + exchange_closed: "the exchange pair is not accepting this operation", + exchange_trading_disabled: "this network is not accepting Bancor trades", + not_exchange_creator: "the account did not create this exchange pair", + token_not_in_exchange: "that token is not one of the pair's two sides", + same_token: "both sides of the pair would be the same token", + insufficient_reserve: "the pair's reserve cannot support the requested amount", + self_participation: "the account cannot take both sides of this operation", + slippage_exceeded: "the trade would have returned less than the floor set for it", + contract_not_found: "no contract at that address", + not_contract_deployer: "the account did not deploy this contract", + + // ── last resort ─────────────────────────────────────────────────────────── + internal_error: "an unexpected internal failure; the message is redacted on purpose", +} as const satisfies Record; + +export type ErrorCode = keyof typeof ERROR_CODES; diff --git a/ts/src/domain/fees/evm-gas.test.ts b/ts/src/domain/fees/evm-gas.test.ts index 3daf74dd2..a70f979a5 100644 --- a/ts/src/domain/fees/evm-gas.test.ts +++ b/ts/src/domain/fees/evm-gas.test.ts @@ -155,6 +155,22 @@ describe("gweiToWei", () => { it("rejects text that is not a number", () => { expect(() => gweiToWei("fast")).toThrow(); }); + + // §6.1 promised `cast`-style suffixes. Only the one that names this flag's own unit is honoured: + // it cannot change the value, and refusing it only punishes a copied `cast` line. + it("accepts a gwei suffix as a synonym for the bare number", () => { + expect(gweiToWei("25gwei")).toBe("25000000000"); + expect(gweiToWei("25 GWEI")).toBe("25000000000"); + expect(gweiToWei("0.05gwei")).toBe(gweiToWei("0.05")); + }); + + // The reason the other suffixes stay out: one flag spanning nine orders of magnitude means a + // typo costs a billion times the fee. Refused by name, not silently reinterpreted. + it("refuses any other unit by name", () => { + for (const bad of ["0.01ether", "25wei", "25 eth"]) { + expect(() => gweiToWei(bad)).toThrow(/read in gwei/); + } + }); }); /** diff --git a/ts/src/domain/fees/evm-gas.ts b/ts/src/domain/fees/evm-gas.ts index 478b5afe3..79b764368 100644 --- a/ts/src/domain/fees/evm-gas.ts +++ b/ts/src/domain/fees/evm-gas.ts @@ -140,13 +140,31 @@ export function planEvmFee(input: EvmFeeInput): EvmFeePlan { * * Scaled by string manipulation rather than float arithmetic: `0.05 * 1e9` is not exactly * 50000000 in binary floating point, and a fee is not a place to discover that. + * + * A `gwei` suffix is accepted as a synonym (`25gwei` === `25`): it names the unit the flag already + * reads, so it cannot change the number, and someone pasting a `cast` line should not be stopped + * by it. Every OTHER unit is refused by name — one flag spanning nine orders of magnitude is how + * `0.01ether` and `25` end up a billion apart, and that is the whole reason this flag takes one + * unit rather than `cast`'s several. */ +// Anchored on a leading number so a non-numeric value ("fast") still gets the plain "not a gwei +// amount" message rather than being reported as an unknown unit. +const FOREIGN_UNIT = /^[\d.]+\s*([a-z]+)$/i; + export function gweiToWei(gwei: string): string { - const match = /^(\d+)(?:\.(\d+))?$/.exec(gwei.trim()); + const value = gwei.trim().replace(/\s*gwei$/i, ""); + const foreign = FOREIGN_UNIT.exec(value); + if (foreign) { + throw new UsageError( + "invalid_value", + `fee rates are read in gwei, so ${foreign[1]} is not accepted: pass 25 or 25gwei, not ${gwei.trim()}`, + ); + } + const match = /^(\d+)(?:\.(\d+))?$/.exec(value); if (!match) throw new UsageError("invalid_value", `not a gwei amount: ${gwei}`); const fraction = match[2] ?? ""; if (fraction.length > 9) { - throw new UsageError("invalid_value", `${gwei} gwei is finer than one wei`); + throw new UsageError("invalid_value", `${value} gwei is finer than one wei`); } return BigInt(`${match[1]}${fraction.padEnd(9, "0")}`).toString(10); } From 44a40eead895a8189c26b6bc3744b7920b0b2738 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 25 Aug 2026 15:55:11 +0800 Subject: [PATCH 08/23] feat: run format --- ts/scripts/build-standalone.mjs | 3 +- ts/scripts/verify-standalone.mjs | 11 +- .../cli/commands/contract.artifact.test.ts | 18 +- .../adapters/inbound/cli/commands/contract.ts | 35 ++- .../cli/commands/family-fields.test.ts | 17 +- .../inbound/cli/commands/message.sign.test.ts | 10 +- ts/src/adapters/inbound/cli/commands/stake.ts | 6 +- .../cli/commands/text-formatters.test.ts | 293 ++++++++++-------- ts/src/adapters/inbound/cli/commands/token.ts | 3 +- ts/src/adapters/inbound/cli/commands/tx.ts | 23 +- .../cli/commands/wallet.current.test.ts | 46 ++- .../adapters/inbound/cli/commands/wallet.ts | 34 +- .../inbound/cli/context/context.test.ts | 7 +- .../cli/help/examples-are-runnable.test.ts | 6 +- .../cli/help/group-family-tags.test.ts | 5 +- ts/src/adapters/inbound/cli/help/index.ts | 5 +- ts/src/adapters/inbound/cli/render/account.ts | 9 +- .../inbound/cli/render/family-render.test.ts | 61 ++-- ts/src/adapters/inbound/cli/render/family.ts | 12 +- ts/src/adapters/inbound/cli/render/misc.ts | 5 +- .../inbound/cli/render/scalars.test.ts | 9 +- ts/src/adapters/inbound/cli/render/tx.ts | 4 +- ts/src/adapters/inbound/cli/render/wallet.ts | 4 +- .../inbound/cli/shell/shell.chain.test.ts | 6 +- .../chain/broadcast-guard-coverage.test.ts | 5 +- .../adapters/outbound/chain/evm/evm.test.ts | 91 ++++-- ts/src/adapters/outbound/chain/evm/evm.ts | 21 +- .../outbound/chain/evm/node-errors.ts | 16 +- .../outbound/chain/evm/signing-strategy.ts | 3 +- ts/src/adapters/outbound/chain/tron/tron.ts | 48 +-- .../adapters/outbound/config/config.test.ts | 24 +- .../outbound/contactbook/contactbook.test.ts | 3 +- ts/src/adapters/outbound/contactbook/index.ts | 6 +- ts/src/adapters/outbound/keystore/index.ts | 37 ++- .../outbound/keystore/keystore.test.ts | 57 +++- ts/src/adapters/outbound/ledger/evm.test.ts | 4 +- ts/src/adapters/outbound/ledger/index.ts | 68 ++-- .../adapters/outbound/price/coingecko.test.ts | 15 +- .../adapters/outbound/tronlink/client.test.ts | 3 +- .../ports/chain/gateway-provider.ts | 4 +- .../services/evm-confirmation.test.ts | 12 +- .../services/evm-gas-estimate.test.ts | 37 ++- .../application/services/evm-gas-estimate.ts | 28 +- .../services/ledger-account.test.ts | 16 + ts/src/application/services/ledger-account.ts | 28 +- .../services/recipient-resolver.test.ts | 20 +- .../services/recipient-resolver.ts | 1 - ts/src/application/services/target/index.ts | 2 - .../use-cases/account-balance-service.test.ts | 4 +- .../use-cases/config-service.test.ts | 33 +- .../use-cases/evm/account-service.test.ts | 20 +- .../use-cases/evm/account-service.ts | 8 +- .../use-cases/evm/chain-service.test.ts | 19 +- .../use-cases/evm/contract-service.test.ts | 9 +- .../use-cases/evm/transaction-service.test.ts | 95 ++++-- .../use-cases/evm/transaction-service.ts | 48 ++- .../use-cases/message-service.test.ts | 6 +- .../use-cases/portfolio-holdings.test.ts | 10 +- .../use-cases/tron/account-service.ts | 5 +- .../tron/contract-service.deploy.test.ts | 7 +- .../tron/contract-service.fee-limit.test.ts | 3 +- .../use-cases/tron/contract-service.ts | 9 +- .../tron/transaction-service.status.test.ts | 13 +- .../use-cases/tron/transaction-service.ts | 4 +- .../use-cases/wallet-service.keystore.test.ts | 6 +- .../application/use-cases/wallet-service.ts | 16 +- ts/src/bootstrap/composition.ts | 3 +- ts/src/bootstrap/families/evm.ts | 6 +- ts/src/bootstrap/migration-gate.test.ts | 31 +- ts/src/bootstrap/migration-steps.test.ts | 4 +- ts/src/bootstrap/migration-wiring.test.ts | 38 +-- ts/src/bootstrap/runner.test.ts | 19 +- ts/src/bootstrap/runner.ts | 5 +- ts/src/domain/address/address.test.ts | 1 - ts/src/domain/contact/contact.test.ts | 1 - ts/src/domain/errors/codes.ts | 5 + ts/src/domain/fees/evm-gas.test.ts | 13 +- ts/src/domain/migration/wallets-v2.test.ts | 31 +- ts/src/domain/sources/sources.test.ts | 6 +- ts/src/domain/wallet/index.ts | 5 +- ts/test/golden.test.ts | 6 +- ts/test/unknown-command.test.ts | 12 +- 82 files changed, 1127 insertions(+), 555 deletions(-) diff --git a/ts/scripts/build-standalone.mjs b/ts/scripts/build-standalone.mjs index afda2673d..838a93e6f 100644 --- a/ts/scripts/build-standalone.mjs +++ b/ts/scripts/build-standalone.mjs @@ -14,8 +14,7 @@ const targetByHost = { const pinnedCompilerByTarget = { "bun-linux-x64-baseline": "node_modules/@oven/bun-linux-x64-baseline/bin/bun", - "bun-windows-x64-baseline": - "node_modules/@oven/bun-windows-x64-baseline/bin/bun.exe", + "bun-windows-x64-baseline": "node_modules/@oven/bun-windows-x64-baseline/bin/bun.exe", }; function option(name) { diff --git a/ts/scripts/verify-standalone.mjs b/ts/scripts/verify-standalone.mjs index ff2b4678a..3ae3ebdfd 100644 --- a/ts/scripts/verify-standalone.mjs +++ b/ts/scripts/verify-standalone.mjs @@ -6,7 +6,8 @@ import { spawnSync } from "node:child_process"; const executable = process.argv[2] ? resolve(process.argv[2]) : undefined; const expectedVersion = - process.argv[3] ?? JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; + process.argv[3] ?? + JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; if (!executable) { throw new Error("usage: node scripts/verify-standalone.mjs EXECUTABLE [EXPECTED_VERSION]"); @@ -39,10 +40,10 @@ if (help.status !== 0 || !help.stdout.includes("Usage: wallet-cli")) { // three outcomes prove the addon loaded, while binding/dynamic-library failures remain rejected. const isolatedHome = mkdtempSync(join(tmpdir(), "wallet-cli-standalone-")); try { - const ledger = run( - ["import", "ledger", "--app", "tron", "--index", "0", "--output", "json"], - { ...process.env, WALLET_CLI_HOME: isolatedHome }, - ); + const ledger = run(["import", "ledger", "--app", "tron", "--index", "0", "--output", "json"], { + ...process.env, + WALLET_CLI_HOME: isolatedHome, + }); const output = `${ledger.stdout}\n${ledger.stderr}`; let result; try { diff --git a/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts b/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts index 98591a38e..23eb45ef1 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts @@ -42,7 +42,8 @@ function artifactFile(body: unknown, name = "Counter.json"): string { } /** Foundry nests the bytecode under `{object}`; Hardhat, sunhat and TronBox store the string. */ -const foundryArtifact = () => artifactFile({ abi: CONSTRUCTOR_ABI, bytecode: { object: "0x6080" } }); +const foundryArtifact = () => + artifactFile({ abi: CONSTRUCTOR_ABI, bytecode: { object: "0x6080" } }); const hardhatArtifact = () => artifactFile({ contractName: "Counter", abi: CONSTRUCTOR_ABI, bytecode: "0x6080" }); @@ -181,7 +182,9 @@ describe("contract deploy — input rules", () => { .safeParse({ dryRun: false, signOnly: false, buildOnly: false, ...input }); const message = (input: Record) => - parse(input).error?.issues.map((i) => i.message).join(" | ") ?? ""; + parse(input) + .error?.issues.map((i) => i.message) + .join(" | ") ?? ""; it("accepts --artifact as a bytecode source", () => { expect(parse({ artifact: "./out/Counter.sol/Counter.json" }).success).toBe(true); @@ -240,8 +243,11 @@ describe("contract deploy — input rules", () => { it("accepts bare values once a type source is present", () => { expect( - parse({ code: "6080", constructorSignature: "constructor(uint256)", constructorArgs: '["42"]' }) - .success, + parse({ + code: "6080", + constructorSignature: "constructor(uint256)", + constructorArgs: '["42"]', + }).success, ).toBe(true); expect(parse({ artifact: "./a.json", constructorArgs: '["42"]' }).success).toBe(true); }); @@ -278,9 +284,7 @@ describe("contract deploy — TRON's ABI requirement", () => { }); it("says plainly that a signature cannot replace the ABI here", () => { - expect(check({ abi: "[]", constructorSignature: "constructor(uint256)" })).toMatch( - /full ABI/, - ); + expect(check({ abi: "[]", constructorSignature: "constructor(uint256)" })).toMatch(/full ABI/); }); it("takes the ABI out of the artifact and passes bare values to TronWeb", async () => { diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index a4b83fa9e..e8b3a42e7 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -10,7 +10,12 @@ import { Schemas, addressFieldsFor } from "../schemas/index.js"; import { gweiToWei } from "../../../../domain/fees/evm-gas.js"; import { toBaseUnits } from "../../../../domain/amounts/index.js"; import { FAMILIES } from "../../../../domain/family/index.js"; -import { governanceTxModeFields, governanceTxRefine, tronTxModeFields, txModeFields } from "./shared.js"; +import { + governanceTxModeFields, + governanceTxRefine, + tronTxModeFields, + txModeFields, +} from "./shared.js"; import { TextFormatters } from "../render/index.js"; function jsonArray(raw: string | undefined, flag = "--params"): unknown[] { @@ -198,8 +203,14 @@ const evmGasFields = z.object({ gasLimit: Schemas.positiveIntString() .optional() .describe("gas units to authorise; defaults to the node's estimate, unpadded"), - maxFee: z.string().optional().describe("maximum total fee per gas, in gwei — 25 or 25gwei (EIP-1559 only)"), - priorityFee: z.string().optional().describe("tip per gas, in gwei — 25 or 25gwei (EIP-1559 only)"), + maxFee: z + .string() + .optional() + .describe("maximum total fee per gas, in gwei — 25 or 25gwei (EIP-1559 only)"), + priorityFee: z + .string() + .optional() + .describe("tip per gas, in gwei — 25 or 25gwei (EIP-1559 only)"), nonce: z.coerce .number() .int() @@ -343,7 +354,8 @@ interface DeployArgInput { /** bare constructor values, from `--constructor-args` or unwrapped from `--constructor-params`. */ function constructorValues(input: DeployArgInput): unknown[] { - if (input.constructorArgs !== undefined) return jsonArray(input.constructorArgs, "--constructor-args"); + if (input.constructorArgs !== undefined) + return jsonArray(input.constructorArgs, "--constructor-args"); return typedConstructorParams(input.constructorParams).map((entry) => entry.value); } @@ -429,12 +441,16 @@ const deployFields = z.object({ .string() .min(1) .optional() - .describe("contract creation bytecode, hex-encoded; provide exactly one of --artifact, --code or --code-file"), + .describe( + "contract creation bytecode, hex-encoded; provide exactly one of --artifact, --code or --code-file", + ), codeFile: z .string() .min(1) .optional() - .describe("path to a file holding the creation bytecode; bytecode often exceeds the shell's argument limit"), + .describe( + "path to a file holding the creation bytecode; bytecode often exceeds the shell's argument limit", + ), constructorSignature: z .string() .min(1) @@ -619,7 +635,8 @@ export const contractDeploySpec: ChainSpec = { capability: "contract.deploy", summary: "Deploy contract bytecode", 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.", + "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: [ @@ -629,10 +646,10 @@ export const contractDeploySpec: ChainSpec = { baseRefine: deployRefine, examples: [ { - cmd: "wallet-cli contract deploy --artifact ./build/contracts/Token.json --constructor-args '[\"18\",\"MyToken\"]' --network nile", + cmd: 'wallet-cli contract deploy --artifact ./build/contracts/Token.json --constructor-args \'["18","MyToken"]\' --network nile', }, { - cmd: "wallet-cli contract deploy --artifact ./out/Token.sol/Token.json --constructor-args '[\"18\",\"MyToken\"]' --network sepolia", + cmd: 'wallet-cli contract deploy --artifact ./out/Token.sol/Token.json --constructor-args \'["18","MyToken"]\' --network sepolia', }, { cmd: "wallet-cli contract deploy --code-file ./Token.bin --constructor-signature 'constructor(uint8,string)' --constructor-args '[\"18\",\"MyToken\"]' --network sepolia", diff --git a/ts/src/adapters/inbound/cli/commands/family-fields.test.ts b/ts/src/adapters/inbound/cli/commands/family-fields.test.ts index ac4b2c381..396a225d0 100644 --- a/ts/src/adapters/inbound/cli/commands/family-fields.test.ts +++ b/ts/src/adapters/inbound/cli/commands/family-fields.test.ts @@ -61,17 +61,18 @@ describe("address flags shared across families", () => { ]; it.each(cases)("%s leaves the base --contract family-neutral", (_name, spec) => { - const parsed = spec.baseFields - .pick({ contract: true }) - .safeParse({ contract: EVM_CONTRACT }); + const parsed = spec.baseFields.pick({ contract: true }).safeParse({ contract: EVM_CONTRACT }); expect(parsed.success && parsed.data.contract).toBe(EVM_CONTRACT); }); - it.each(cases)("%s rejects a non-TRON --contract on the TRON binding", (_n, spec, binding, base) => { - const result = effectiveSchema(spec, binding).safeParse({ ...base, contract: EVM_CONTRACT }); - expect(result.success).toBe(false); - expect(JSON.stringify(result.error?.issues)).toContain("invalid tron address"); - }); + it.each(cases)( + "%s rejects a non-TRON --contract on the TRON binding", + (_n, spec, binding, base) => { + const result = effectiveSchema(spec, binding).safeParse({ ...base, contract: EVM_CONTRACT }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain("invalid tron address"); + }, + ); it.each(cases)("%s accepts a TRON --contract on the TRON binding", (_n, spec, binding, base) => { const result = effectiveSchema(spec, binding).safeParse({ ...base, contract: TRON_CONTRACT }); diff --git a/ts/src/adapters/inbound/cli/commands/message.sign.test.ts b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts index a217c0680..db1807530 100644 --- a/ts/src/adapters/inbound/cli/commands/message.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts @@ -31,9 +31,13 @@ describe("message sign exclusive group", () => { activeAccount: "main", secrets: { pick: (inline: string | undefined) => inline ?? "from-stdin" }, } as never; - await messageSignBinding(service as never).run(ctx, { family: "tron", nativeSymbol: "TRX" } as never, { - message: "hello", - }); + await messageSignBinding(service as never).run( + ctx, + { family: "tron", nativeSymbol: "TRX" } as never, + { + message: "hello", + }, + ); expect(received).toBe("hello"); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/stake.ts b/ts/src/adapters/inbound/cli/commands/stake.ts index 7448af508..0064af4f1 100644 --- a/ts/src/adapters/inbound/cli/commands/stake.ts +++ b/ts/src/adapters/inbound/cli/commands/stake.ts @@ -72,10 +72,8 @@ export function stakeDefinitions( resource: resourceField("resource type to release"), }, ), - stakeCommand( - "withdraw", - "Withdraw expired unfrozen TRX", - (context, network, input) => service.withdraw(context, network, input), + stakeCommand("withdraw", "Withdraw expired unfrozen TRX", (context, network, input) => + service.withdraw(context, network, input), ), stakeCommand( "cancel-unfreeze", 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 d69d3940d..e6ed9706b 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -283,39 +283,48 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(out).not.toContain("Fee"); }); it("tx send TRC20 via --contract --raw-amount (no symbol): never mislabels as TRX", () => { - const out = TextFormatters.txReceipt({ - kind: "send", - stage: "submitted", - txId: "t20", - rawAmount: "10000", - contract: "TXYZtokenContract", - to: "Tdest", - }, ctx()); + const out = TextFormatters.txReceipt( + { + kind: "send", + stage: "submitted", + txId: "t20", + rawAmount: "10000", + contract: "TXYZtokenContract", + to: "Tdest", + }, + ctx(), + ); expect(out).toContain("Sent 10000 TXYZtokenContract"); expect(out).not.toContain("TRX"); }); it("tx send TRC10 via --asset-id --raw-amount (no symbol): labels by asset id, not TRX", () => { - const out = TextFormatters.txReceipt({ - kind: "send", - stage: "submitted", - txId: "t10", - rawAmount: "500000", - assetId: "1005416", - to: "Tdest", - }, ctx()); + const out = TextFormatters.txReceipt( + { + kind: "send", + stage: "submitted", + txId: "t10", + rawAmount: "500000", + assetId: "1005416", + to: "Tdest", + }, + ctx(), + ); expect(out).toContain("Sent 500000 asset 1005416"); expect(out).not.toContain("TRX"); }); it("tx send confirmed (--wait): success receipt with real block + fee", () => { - const out = TextFormatters.txReceipt({ - kind: "send", - stage: "confirmed", - txId: "abc", - rawAmount: "1000000", - to: "Tdest", - blockNumber: 66000000, - feeSun: "268000", - }, ctx()); + const out = TextFormatters.txReceipt( + { + kind: "send", + stage: "confirmed", + txId: "abc", + rawAmount: "1000000", + to: "Tdest", + blockNumber: 66000000, + feeSun: "268000", + }, + ctx(), + ); expect(out).toContain("✅"); expect(out).toContain("Sent 1 TRX"); expect(out).toContain("#66,000,000"); @@ -323,31 +332,37 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(out).toContain("success"); }); it("confirmed receipt preserves legitimate zero-valued chain fields", () => { - const out = TextFormatters.txReceipt({ - kind: "send", - stage: "confirmed", - txId: "zero", - rawAmount: "0", - to: "Tdest", - blockNumber: 0, - energyUsed: 0, - feeSun: 0, - }, ctx()); + const out = TextFormatters.txReceipt( + { + kind: "send", + stage: "confirmed", + txId: "zero", + rawAmount: "0", + to: "Tdest", + blockNumber: 0, + energyUsed: 0, + feeSun: 0, + }, + ctx(), + ); expect(out).toContain("#0"); expect(out).toMatch(/Energy\s+0/); expect(out).toContain("0 TRX"); }); it("contract send failed (--wait): failure receipt with reason", () => { - const out = TextFormatters.txReceipt({ - kind: "contract-send", - stage: "failed", - txId: "abc", - method: "transfer(address,uint256)", - contract: "TR7contract", - result: "OUT_OF_ENERGY", - blockNumber: 1, - failed: true, - }, ctx()); + const out = TextFormatters.txReceipt( + { + kind: "contract-send", + stage: "failed", + txId: "abc", + method: "transfer(address,uint256)", + contract: "TR7contract", + result: "OUT_OF_ENERGY", + blockNumber: 1, + failed: true, + }, + ctx(), + ); expect(out).toContain("❌"); expect(out).toContain("Called transfer"); expect(out).toContain("TR7contract"); @@ -377,30 +392,36 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(out).toContain("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); }); it("dry-run with an energy estimate (TRC20/contract): renders energy, never [object Object]", () => { - const out = TextFormatters.txReceipt({ - kind: "send", - mode: "dry-run", - fee: { feeModel: "tron-resource", energy: 29650, availableEnergy: 133440569 } as any, - tx: { txID: "deadbeef" } as any, - rawAmount: "10000", - contract: "TXYZtoken", - to: "Tdest", - } as any, ctx()); + const out = TextFormatters.txReceipt( + { + kind: "send", + mode: "dry-run", + fee: { feeModel: "tron-resource", energy: 29650, availableEnergy: 133440569 } as any, + tx: { txID: "deadbeef" } as any, + rawAmount: "10000", + contract: "TXYZtoken", + to: "Tdest", + } as any, + ctx(), + ); expect(out).toContain("Dry run"); expect(out).not.toContain("[object Object]"); expect(out).toContain("29,650 energy"); expect(out).toContain("covered by staked energy"); // availableEnergy >= energy }); it("dry-run energy estimate with insufficient available energy: no 'covered' note", () => { - const out = TextFormatters.txReceipt({ - kind: "send", - mode: "dry-run", - fee: { feeModel: "tron-resource", energy: 29650, availableEnergy: 100 } as any, - tx: { txID: "deadbeef" } as any, - rawAmount: "10000", - contract: "TXYZtoken", - to: "Tdest", - } as any, ctx()); + const out = TextFormatters.txReceipt( + { + kind: "send", + mode: "dry-run", + fee: { feeModel: "tron-resource", energy: 29650, availableEnergy: 100 } as any, + tx: { txID: "deadbeef" } as any, + rawAmount: "10000", + contract: "TXYZtoken", + to: "Tdest", + } as any, + ctx(), + ); expect(out).toContain("29,650 energy"); expect(out).not.toContain("covered by staked energy"); }); @@ -415,14 +436,17 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" balanceSun: "1862126000", }; const dryRun = (fee: unknown) => - TextFormatters.txReceipt({ - kind: "account-activate", - mode: "dry-run", - fee, - tx: { txID: "cc0a6f68" }, - address: "TEF2CvkixrkzwbreCRFCQ7sZGj9AVFAkQq", - payer: "TMSgJxtPw29", - } as any, ctx()) as string; + TextFormatters.txReceipt( + { + kind: "account-activate", + mode: "dry-run", + fee, + tx: { txID: "cc0a6f68" }, + address: "TEF2CvkixrkzwbreCRFCQ7sZGj9AVFAkQq", + payer: "TMSgJxtPw29", + } as any, + ctx(), + ) as string; it("account activate dry-run: renders the total creation fee, not [object Object]", () => { const out = dryRun(activateFee); @@ -493,12 +517,15 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }; it("broadcast dry-run: projects the permission and approval block json already carries", () => { - const out = TextFormatters.txReceipt({ - kind: "broadcast", - mode: "dry-run", - transaction: broadcastApproval, - multiSignFeeSun: 1000000, - } as any, ctx()) as string; + const out = TextFormatters.txReceipt( + { + kind: "broadcast", + mode: "dry-run", + transaction: broadcastApproval, + multiSignFeeSun: 1000000, + } as any, + ctx(), + ) as string; expect(out).toContain("Dry run tx broadcast"); expect(out).toContain('Permission active "finance" (id 2) threshold 2'); expect(out).toContain("Progress 2 / 2 — threshold reached"); @@ -507,12 +534,15 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("broadcast dry-run: identifies the transaction instead of leaving an empty Tx row", () => { - const out = TextFormatters.txReceipt({ - kind: "broadcast", - mode: "dry-run", - transaction: broadcastApproval, - multiSignFeeSun: 0, - } as any, ctx()) as string; + const out = TextFormatters.txReceipt( + { + kind: "broadcast", + mode: "dry-run", + transaction: broadcastApproval, + multiSignFeeSun: 0, + } as any, + ctx(), + ) as string; expect(out).toContain("abc123"); }); @@ -520,24 +550,30 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" ["non-zero multi-sign fee", 1000000, "1 TRX"], ["zero multi-sign fee", 0, "0 TRX"], ])("broadcast dry-run: states the multi-sign fee exactly once (%s)", (_n, fee, expected) => { - const out = TextFormatters.txReceipt({ - kind: "broadcast", - mode: "dry-run", - transaction: broadcastApproval, - multiSignFeeSun: fee, - } as any, ctx()) as string; + const out = TextFormatters.txReceipt( + { + kind: "broadcast", + mode: "dry-run", + transaction: broadcastApproval, + multiSignFeeSun: fee, + } as any, + ctx(), + ) as string; expect(out.match(/multi-sign fee/gi) ?? []).toHaveLength(1); expect(out).toContain(expected); }); it("broadcast submitted: keeps txid, status and the tracking hint, and does not duplicate the fee", () => { - const out = TextFormatters.txReceipt({ - kind: "broadcast", - stage: "submitted", - txId: "abc123", - transaction: broadcastApproval, - multiSignFeeSun: 1000000, - } as any, ctx()) as string; + const out = TextFormatters.txReceipt( + { + kind: "broadcast", + stage: "submitted", + txId: "abc123", + transaction: broadcastApproval, + multiSignFeeSun: 1000000, + } as any, + ctx(), + ) as string; expect(out).toContain("abc123"); expect(out).toContain("pending — not yet on-chain"); expect(out).toContain("Track it:"); @@ -545,13 +581,16 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("stake freeze submitted: renders staked amount and resource", () => { - const out = TextFormatters.txReceipt({ - kind: "stake-freeze", - stage: "submitted", - txId: "abc", - amountSun: "2000000", - resource: "energy", - }, ctx()); + const out = TextFormatters.txReceipt( + { + kind: "stake-freeze", + stage: "submitted", + txId: "abc", + amountSun: "2000000", + resource: "energy", + }, + ctx(), + ); expect(out).toContain("Staked"); expect(out).toContain("2 TRX"); expect(out).toContain("energy"); @@ -861,7 +900,10 @@ describe("sign-only receipt", () => { address: "TSigner", txId: "abc123", }; - const ctx = { command: "tx sign", net: { family: "tron", nativeSymbol: "TRX", id: "nile" } } as never; + const ctx = { + command: "tx sign", + net: { family: "tron", nativeSymbol: "TRX", id: "nile" }, + } as never; // The signature is the product of a signing command and has to be copied somewhere, so it must // never be shortened. Before this it showed a truncated txID — redundant with the TxID row and @@ -958,14 +1000,12 @@ describe("portfolio price vs valuation precision", () => { // vocabulary the user never needs otherwise. Externally the book is a flat name↔address map. describe("contact list is a flat name-to-address map", () => { const listed = () => - TextFormatters.contactList( - { - contacts: [ - { name: "tron-friend", address: "TWer2Ygk5", note: null }, - { name: "evm-friend", address: "0xe2E1a549", note: "team" }, - ], - }, - ) as string; + TextFormatters.contactList({ + contacts: [ + { name: "tron-friend", address: "TWer2Ygk5", note: null }, + { name: "evm-friend", address: "0xe2E1a549", note: "team" }, + ], + }) as string; it("has no Family column — the address already tells you the chain", () => { expect(listed().split("\n")[0]).not.toMatch(/\bFamily\b/); @@ -1044,17 +1084,15 @@ describe("list shows one family's addresses at a time", () => { // command on two machines silently produces different secrets with nothing to tell them apart. describe("keystore receipt names the exported family", () => { const receipt = (extra: Record) => - TextFormatters.walletBackup( - { - accountId: "wlt_a.0", - out: "/tmp/x.keystore.json", - format: "keystore", - secretType: "privateKey", - fileMode: "0600", - bytes: 491, - ...extra, - }, - ) as string; + TextFormatters.walletBackup({ + accountId: "wlt_a.0", + out: "/tmp/x.keystore.json", + format: "keystore", + secretType: "privateKey", + fileMode: "0600", + bytes: 491, + ...extra, + }) as string; it("shows the family a keystore export used", () => { expect(receipt({ family: "evm" })).toMatch(/^\s*Family\s+evm$/m); @@ -1063,9 +1101,12 @@ describe("keystore receipt names the exported family", () => { // A mnemonic covers every family, so there is nothing to disambiguate and a row would imply // a choice that was never made. it("omits the row for a native backup", () => { - const out = TextFormatters.walletBackup( - { accountId: "wlt_a.0", out: "/tmp/x.json", secretType: "mnemonic", bytes: 313 }, - ) as string; + const out = TextFormatters.walletBackup({ + accountId: "wlt_a.0", + out: "/tmp/x.json", + secretType: "mnemonic", + bytes: 313, + }) as string; expect(out).not.toMatch(/\bFamily\b/); }); diff --git a/ts/src/adapters/inbound/cli/commands/token.ts b/ts/src/adapters/inbound/cli/commands/token.ts index 09fcddfea..5c826d8b4 100644 --- a/ts/src/adapters/inbound/cli/commands/token.ts +++ b/ts/src/adapters/inbound/cli/commands/token.ts @@ -155,8 +155,7 @@ export const tokenRemoveSpec: ChainSpec = { summary: "Remove a user-added token", // §5.5: the refusal on an official entry is a rule worth stating before it is hit. description: - "Remove a user-added token from the address book. Official entries cannot be\n" + - "removed.", + "Remove a user-added token from the address book. Official entries cannot be\n" + "removed.", baseFields: selectorFields, examples: [ { cmd: "wallet-cli token remove --contract TR7... --network nile" }, diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index ed0d68ad7..e50ed07d3 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -131,12 +131,7 @@ export const txBroadcastEvmBinding = (svc: EvmTransactionService): FamilyBinding if (input.dryRun && ctx.wait) { throw new UsageError("invalid_option", "--wait cannot be used with --dry-run"); } - return svc.broadcast( - ctx, - net, - evmHexOnly(input, ctx.secrets.has("tx")), - input.dryRun === true, - ); + return svc.broadcast(ctx, net, evmHexOnly(input, ctx.secrets.has("tx")), input.dryRun === true); }, }); @@ -166,12 +161,12 @@ const tronBroadcastFields = z.object({ }); const broadcastFields = z.object({ - hex: z.string().min(2).optional().describe("signed transaction hex: protobuf hex for TRON, RLP for EVM"), - file: z + hex: z .string() - .min(1) + .min(2) .optional() - .describe("file containing the signed transaction hex"), + .describe("signed transaction hex: protobuf hex for TRON, RLP for EVM"), + file: z.string().min(1).optional().describe("file containing the signed transaction hex"), dryRun: z .boolean() .default(false) @@ -349,8 +344,12 @@ export const txSignSpec: ChainSpec = { { cmd: `wallet-cli tx sign --transaction '{"txID":"...","raw_data":{...},"raw_data_hex":"..."}'`, }, - { cmd: "wallet-cli tx sign --file unsigned.hex --out signed.hex --network nile --password-stdin" }, - { cmd: "wallet-cli tx sign --file unsigned.hex --out signed.hex --network sepolia --password-stdin" }, + { + cmd: "wallet-cli tx sign --file unsigned.hex --out signed.hex --network nile --password-stdin", + }, + { + cmd: "wallet-cli tx sign --file unsigned.hex --out signed.hex --network sepolia --password-stdin", + }, { cmd: "wallet-cli tx sign --file partially-signed.hex --offline --password-stdin" }, ], formatText: TextFormatters.txSign, diff --git a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts index 5c0a3cf05..7bb3d4833 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts @@ -39,7 +39,13 @@ function command( } // ExecutionContext always carries these; --qr now reads the selected network to choose which // family's address to encode, so a fixture without them represents no real invocation. - const tronNet = { id: "tron:mainnet", family: "tron", nativeSymbol: "TRX", chainId: "mainnet", capabilities: [] }; + const tronNet = { + id: "tron:mainnet", + family: "tron", + nativeSymbol: "TRX", + chainId: "mainnet", + capabilities: [], + }; const context = { activeAccount: options.account ?? "wlt_selected", output: options.output ?? "text", @@ -53,7 +59,9 @@ function command( 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, fixture.tronNet as never, { qr: true }); + const result = await fixture.current.run(fixture.context as never, fixture.tronNet as never, { + qr: true, + }); expect(fixture.walletService.current).toHaveBeenCalledWith("wlt_selected"); expect(fixture.qr.encode).toHaveBeenCalledWith(ADDRESS); @@ -63,17 +71,24 @@ describe("current --qr", () => { }); }); - it("keeps JSON data unchanged and never builds terminal art", async () => { + // json gets the answer --qr was asked for, and no terminal art: the QR is the only text-shaped + // part of this command, so it is the only part the output format decides. + it("gives JSON the receive address and never builds terminal art", async () => { const fixture = command({ output: "json" }); - const result = await fixture.current.run(fixture.context as never, fixture.tronNet as never, { qr: true }); + const result = await fixture.current.run(fixture.context as never, fixture.tronNet as never, { + qr: true, + }); - expect(result).toEqual(descriptor); + expect(result).toEqual({ ...descriptor, receiveAddress: ADDRESS }); + expect(result).not.toHaveProperty("receiveQr"); 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, fixture.tronNet as never, { qr: true }); + const result = await fixture.current.run(fixture.context as never, fixture.tronNet as never, { + qr: true, + }); expect(result).toEqual(descriptor); expect(fixture.context.warn).toHaveBeenCalledWith(expect.stringContaining("too narrow")); @@ -100,7 +115,11 @@ function withNetwork( if (!current || isChainCommand(current)) throw new Error("current command missing"); const net = (family: string) => ({ id: `${family}:x`, family, chainId: "x", capabilities: [] }); - const context = { activeAccount: "wlt_selected", output: "text" as const, warn: vi.fn() }; + const context = { + activeAccount: "wlt_selected", + output: "text" as "text" | "json", + warn: vi.fn(), + }; // the shell resolves --network (else config.defaultNetwork) and hands it to run() const network = net(selected ? (selected.startsWith("evm") ? "evm" : "tron") : defaultFamily); return { current, context, network, qr }; @@ -154,6 +173,19 @@ describe("current --qr picks the address by network family", () => { expect(f.qr.encode).not.toHaveBeenCalled(); }); + // The regression this replaces: the family check sat behind `output === "text"`, so the same + // command exited 2 with family_mismatch for a human and 0 with success for an agent — and the + // agent is the one that cannot see the QR it was supposedly refusing to draw. + it("refuses under -o json too, not just in text", async () => { + const f = withNetwork({ evm: EVM_ADDRESS }, "tron:nile"); + f.context.output = "json"; + + await expect( + f.current.run(f.context as never, f.network as never, { qr: true }), + ).rejects.toMatchObject({ code: "family_mismatch" }); + expect(f.qr.encode).not.toHaveBeenCalled(); + }); + // The error is scoped to --qr. Looking at an account is local and must not depend on which // network happens to be selected. it("still shows a mismatched single-family account when --qr is absent", async () => { diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index 1401f070a..bb2793ff8 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -12,6 +12,7 @@ import type { LedgerDevice } from "../../../../application/ports/ledger-device.j import type { QrEncoder } from "../../../../application/ports/qr-encoder.js"; import type { WalletService } from "../../../../application/use-cases/wallet-service.js"; import { + DEFAULT_SCAN_LIMIT, resolveLedgerPath, selectLedgerPath, } from "../../../../application/services/ledger-account.js"; @@ -53,9 +54,7 @@ export const walletImportLedgerFields = z.object({ path: z .string() .optional() - .describe( - "explicit derivation path; mutually exclusive with --index and --address", - ), + .describe("explicit derivation path; mutually exclusive with --index and --address"), address: z .string() .optional() @@ -66,10 +65,13 @@ export const walletImportLedgerFields = z.object({ .number() .int() .positive() - .optional() - // The default lives in the service (DEFAULT_SCAN_LIMIT); stating it here too would be a - // second copy to drift. - .describe("how many indexes to scan when using --address; omit to scan 20"), + // Declared from the service's own constant: `--json-schema` publishes what the schema says, + // so a default living only in prose is one an agent has to read English to learn — and taking + // the value from DEFAULT_SCAN_LIMIT keeps it one constant rather than a second copy. + // (--index cannot do this: it counts as "given" once it has a default, which breaks the + // --index/--path/--address exclusivity rule. This flag has no such constraint.) + .default(DEFAULT_SCAN_LIMIT) + .describe("how many indexes to scan when using --address"), label: Schemas.label() .optional() .describe("human-friendly unique account label, 1-64 chars; omit to auto-generate"), @@ -198,7 +200,11 @@ export function registerWalletCommands( promptHints: { label: "default-label" }, summary: "Import a BIP39 mnemonic phrase", description: - "Import a BIP39 mnemonic phrase. The recovery phrase and master password are read\n" + + "Import a BIP39 mnemonic phrase. Derives one address per chain family from the same\n" + + // §3.2's topic sentence. Its four siblings (create, derive, import private-key, + // import watch) each say what they produce per family; silence here reads as "this one + // does not". + "seed, the same as `create`. The recovery phrase and master password are read\n" + "interactively from the TTY (hidden input); they never touch argv or stdin.", fields: importMnemonicFields, input: importMnemonicFields, @@ -341,7 +347,9 @@ export function registerWalletCommands( address: z .string() .min(1) - .describe("watch-only address to track; TRON base58 (T...) or EVM hex (0x...), detected from the value"), + .describe( + "watch-only address to track; TRON base58 (T...) or EVM hex (0x...), detected from the value", + ), label: Schemas.label() .optional() .describe("human-friendly unique account label, 1-64 chars; omit to auto-generate"), @@ -464,7 +472,7 @@ export function registerWalletCommands( formatText: TextFormatters.walletCurrent, run: async (context, network, input) => { const descriptor = wallets.current(context.activeAccount); - if (!input.qr || context.output !== "text") return descriptor; + if (!input.qr) return descriptor; // The network is a DISPLAY SELECTOR here, not a target: this command performs no chain I/O, // so it stays `network: "none"` and resolves lazily, only for --qr. That keeps a plain // `current` working for an account whose family does not match the active network — you @@ -478,6 +486,12 @@ export function registerWalletCommands( `selected account has no ${network?.family} address; ${network?.id} cannot receive to it`, ); } + // The check above runs whatever the output format is (§3.8 lists this error with no + // "text only" clause): `-o json` is a different RENDERING of the same run, not a different + // meaning, and the same command answering "cannot receive here" to a human and "success" to + // an agent is the worse of the two lies. Only the QR itself is text-shaped, so json gets the + // address it asked for and no picture. + if (context.output !== "text") return { ...descriptor, receiveAddress: address }; const qr = services.qr?.encode(address) ?? null; if (!qr) { context.warn( diff --git a/ts/src/adapters/inbound/cli/context/context.test.ts b/ts/src/adapters/inbound/cli/context/context.test.ts index 614fbf80f..b63e12148 100644 --- a/ts/src/adapters/inbound/cli/context/context.test.ts +++ b/ts/src/adapters/inbound/cli/context/context.test.ts @@ -52,7 +52,12 @@ describe("resolveAddress on a family the account does not have", () => { const EVM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; function ctxForEvmWatch() { - const sm = new StreamManager("json", false, () => {}, () => {}); + const sm = new StreamManager( + "json", + false, + () => {}, + () => {}, + ); const deps = { config: { timeoutMs: 1 }, streams: sm, diff --git a/ts/src/adapters/inbound/cli/help/examples-are-runnable.test.ts b/ts/src/adapters/inbound/cli/help/examples-are-runnable.test.ts index 2eb7b9126..56427c9a0 100644 --- a/ts/src/adapters/inbound/cli/help/examples-are-runnable.test.ts +++ b/ts/src/adapters/inbound/cli/help/examples-are-runnable.test.ts @@ -32,9 +32,9 @@ describe("help examples only use flags the command declares", () => { /** zod field names are camelCase; the CLI spells them kebab-case. */ const kebab = (name: string): string => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); - function declaredFlags(cmd: ReturnType["registry"] extends never - ? never - : any): Set { + function declaredFlags( + cmd: ReturnType["registry"] extends never ? never : any, + ): Set { const out = new Set(); for (const g of GLOBAL_FLAGS) out.add(g.flag.replace(/^--/, "")); for (const g of inputFlagsFor(isChainCommand(cmd) ? cmd.spec : cmd)) diff --git a/ts/src/adapters/inbound/cli/help/group-family-tags.test.ts b/ts/src/adapters/inbound/cli/help/group-family-tags.test.ts index 81053f14c..c3bc3d680 100644 --- a/ts/src/adapters/inbound/cli/help/group-family-tags.test.ts +++ b/ts/src/adapters/inbound/cli/help/group-family-tags.test.ts @@ -27,7 +27,10 @@ describe("group help family tags are derived from the registry", () => { else process.env.WALLET_CLI_HOME = previousHome; }); - function groupHelp(group: string): { rows: Map; families: Map } { + function groupHelp(group: string): { + rows: Map; + families: Map; + } { const runtime = composeCliRuntime({ globals: { output: "text", verbose: false }, secretPaths: {}, diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index 3d2b132ad..b25d4502f 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -91,10 +91,7 @@ export class HelpService { // the second is what a family-prefixed query (`evm account history --help`) really hit, // and answering it with unknown_command would send the reader looking for a typo. if (family && this.registry.resolveChain(path)) { - throw new UsageError( - "family_mismatch", - `${path.join(" ")} has no ${family} implementation`, - ); + throw new UsageError("family_mismatch", `${path.join(" ")} has no ${family} implementation`); } throw new UsageError("unknown_command", `unknown command: ${path.join(" ")}`); } diff --git a/ts/src/adapters/inbound/cli/render/account.ts b/ts/src/adapters/inbound/cli/render/account.ts index ba7f3ec0f..dde7e90ba 100644 --- a/ts/src/adapters/inbound/cli/render/account.ts +++ b/ts/src/adapters/inbound/cli/render/account.ts @@ -1,13 +1,6 @@ import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; import { fromBaseUnits } from "../../../../domain/amounts/index.js"; -import { - formatScalar, - formatUsd, - formatUsdPrice, - formatTime, - num, - quote, -} from "./scalars.js"; +import { formatScalar, formatUsd, formatUsdPrice, formatTime, num, quote } from "./scalars.js"; import { type Obj, type Pair, asObj, query, receipt, table, ok, fail, warn } from "./layout.js"; import { FAMILY_RENDER, renderFamily, renderSymbol } from "./family.js"; diff --git a/ts/src/adapters/inbound/cli/render/family-render.test.ts b/ts/src/adapters/inbound/cli/render/family-render.test.ts index 33adc1fed..858416931 100644 --- a/ts/src/adapters/inbound/cli/render/family-render.test.ts +++ b/ts/src/adapters/inbound/cli/render/family-render.test.ts @@ -12,12 +12,15 @@ describe("FAMILY_RENDER parity", () => { expect(FAMILY_RENDER.tron.addressLabel).toBe("TRON address"); }); it("tron txInfoRows include Energy + Fee in TRX", () => { - const rows = FAMILY_RENDER.tron.txInfoRows({ - txid: "t", - status: "SUCCESS", - feeSun: "1000000", - energyUsed: 5, - } as any, "TRX"); + const rows = FAMILY_RENDER.tron.txInfoRows( + { + txid: "t", + status: "SUCCESS", + feeSun: "1000000", + energyUsed: 5, + } as any, + "TRX", + ); expect(rows).toContainEqual(["Fee", "1 TRX"]); expect(rows.map((r) => r[0])).toContain("Energy"); }); @@ -39,13 +42,16 @@ describe("FAMILY_RENDER evm", () => { // The cross-cutting rule is that EVM reuses TRON's field set and only changes values and // units — with the fee as the stated exception, because the unit is IN the field name. it("renders gas used and the fee in ETH", () => { - const rows = FAMILY_RENDER.evm.txInfoRows({ - txid: "0xabc", - transaction: {}, - status: "confirmed", - gasUsed: 21_000, - feeWei: "441000000000000", - }, "ETH"); + const rows = FAMILY_RENDER.evm.txInfoRows( + { + txid: "0xabc", + transaction: {}, + status: "confirmed", + gasUsed: 21_000, + feeWei: "441000000000000", + }, + "ETH", + ); const byLabel = Object.fromEntries(rows); expect(byLabel.Gas).toBe("21,000"); @@ -60,7 +66,11 @@ describe("FAMILY_RENDER evm", () => { // TxInfoView is a cross-family superset; each family picks only the fields it populates, so // the EVM rows must not carry TRON's resource accounting. it("omits TRON-only rows from its tx info", () => { - const labels = FAMILY_RENDER.evm.txInfoRows({ txid: "0xabc", transaction: {}, from: "0xa", to: "0xb", status: "confirmed" }, "ETH") + const labels = FAMILY_RENDER.evm + .txInfoRows( + { txid: "0xabc", transaction: {}, from: "0xa", to: "0xb", status: "confirmed" }, + "ETH", + ) .map(([label]) => label); expect(labels).not.toContain("Energy"); @@ -70,7 +80,9 @@ describe("FAMILY_RENDER evm", () => { describe("renderFamily", () => { it("reads the family from the resolved network", () => { - expect(renderFamily({ command: "tx.info", net: { family: "evm", nativeSymbol: "ETH" } as never })).toBe("evm"); + expect( + renderFamily({ command: "tx.info", net: { family: "evm", nativeSymbol: "ETH" } as never }), + ).toBe("evm"); }); // The old default was "tron". With one family that was unreachable; with two it silently @@ -148,7 +160,10 @@ describe("FAMILY_RENDER accountInfoRows", () => { // bytes, and a row reading "0 bytes" would say it is. it("sizes a contract's code and leaves the row off an EOA", () => { expect( - FAMILY_RENDER.evm.accountInfoRows({ ...EVM_ACCOUNT, type: "contract", codeSize: 3124 }, "ETH"), + FAMILY_RENDER.evm.accountInfoRows( + { ...EVM_ACCOUNT, type: "contract", codeSize: 3124 }, + "ETH", + ), ).toContainEqual(["Code size", "3,124 bytes"]); expect(FAMILY_RENDER.evm.accountInfoRows(EVM_ACCOUNT, "ETH").map((r) => r[0])).not.toContain( "Code size", @@ -222,7 +237,11 @@ describe("FAMILY_RENDER chainPricesRows", () => { it("keeps the TRON rows intact", () => { const rows = FAMILY_RENDER.tron.chainPricesRows( - { energy: { currentSunPerUnit: 100 }, bandwidth: { currentSunPerUnit: 1000 }, memoFeeSun: "1000000" }, + { + energy: { currentSunPerUnit: 100 }, + bandwidth: { currentSunPerUnit: 1000 }, + memoFeeSun: "1000000", + }, "TRX", ); @@ -239,7 +258,12 @@ describe("FAMILY_RENDER chainPricesRows", () => { describe("FAMILY_RENDER — receipt settlement rows", () => { it("states the EVM fee AND what it is the product of", () => { const rows = FAMILY_RENDER.evm.receiptSettlementRows( - { kind: "send", feeWei: "441000000000000", gasUsed: 21000, effectiveGasPriceWei: "21000000000" } as never, + { + kind: "send", + feeWei: "441000000000000", + gasUsed: 21000, + effectiveGasPriceWei: "21000000000", + } as never, "ETH", ); @@ -335,4 +359,3 @@ describe("FAMILY_RENDER — chain node rows", () => { expect(rows).toContainEqual(["Peers", "30 connected / 27 active"]); }); }); - diff --git a/ts/src/adapters/inbound/cli/render/family.ts b/ts/src/adapters/inbound/cli/render/family.ts index 57ecac001..f93161d7d 100644 --- a/ts/src/adapters/inbound/cli/render/family.ts +++ b/ts/src/adapters/inbound/cli/render/family.ts @@ -89,7 +89,9 @@ export const FAMILY_RENDER: Record = { accountInfoRows: (d, symbol) => { const account = asObj(d.account); const owner = asObj(account.owner_permission); - const active = Array.isArray(account.active_permission) ? account.active_permission.length : 0; + const active = Array.isArray(account.active_permission) + ? account.active_permission.length + : 0; const created = account.create_time ? new Date(Number(account.create_time)).toISOString().slice(0, 10) : ""; @@ -104,7 +106,10 @@ export const FAMILY_RENDER: Record = { if (resources.energy) rows.push(["Energy", `used ${formatInt(energy.used)} / ${formatInt(energy.limit)}`]); if (resources.bandwidth) - rows.push(["Bandwidth", `used ${formatInt(bandwidth.used)} / ${formatInt(bandwidth.limit)}`]); + rows.push([ + "Bandwidth", + `used ${formatInt(bandwidth.used)} / ${formatInt(bandwidth.limit)}`, + ]); rows.push(["Created", created]); rows.push([ "Permissions", @@ -202,7 +207,8 @@ export const FAMILY_RENDER: Record = { if (d.baseFeeWei !== undefined) rows.push(["Base fee", `${formatGwei(d.baseFeeWei)} gwei`]); if (d.priorityFeeWei !== undefined) rows.push(["Priority fee", `${formatGwei(d.priorityFeeWei)} gwei`]); - if (d.gasPriceWei !== undefined) rows.push(["Gas price", `${formatGwei(d.gasPriceWei)} gwei`]); + if (d.gasPriceWei !== undefined) + rows.push(["Gas price", `${formatGwei(d.gasPriceWei)} gwei`]); // The per-gas numbers above answer "how expensive is gas"; this answers "what will a // transfer cost me", which is the question most readers actually have. if (d.transferCostWei !== undefined) { diff --git a/ts/src/adapters/inbound/cli/render/misc.ts b/ts/src/adapters/inbound/cli/render/misc.ts index f6dbf8a56..3fd8a11cc 100644 --- a/ts/src/adapters/inbound/cli/render/misc.ts +++ b/ts/src/adapters/inbound/cli/render/misc.ts @@ -113,7 +113,10 @@ function renderConfig(d: Obj): string { if ("key" in d) { // A map-valued key (networks, aliases) gets its own titled block; a scalar stays one line. return isMap(d.value) - ? titled(String(d.key), Object.entries(d.value).map(([k, v]) => [k, configValue(v)] as Pair)) + ? titled( + String(d.key), + Object.entries(d.value).map(([k, v]) => [k, configValue(v)] as Pair), + ) : kv([[String(d.key), configValue(d.value)]], ""); } return kv( diff --git a/ts/src/adapters/inbound/cli/render/scalars.test.ts b/ts/src/adapters/inbound/cli/render/scalars.test.ts index 9a29cecd2..8b8512cfa 100644 --- a/ts/src/adapters/inbound/cli/render/scalars.test.ts +++ b/ts/src/adapters/inbound/cli/render/scalars.test.ts @@ -27,9 +27,12 @@ describe("formatAmount", () => { it.each([ ["1", 18], ["999999999999", 18], - ])("renders a non-zero amount below display precision as <0.000001 (%s @ %i)", (raw, decimals) => { - expect(formatAmount(raw, decimals)).toBe("<0.000001"); - }); + ])( + "renders a non-zero amount below display precision as <0.000001 (%s @ %i)", + (raw, decimals) => { + expect(formatAmount(raw, decimals)).toBe("<0.000001"); + }, + ); // The boundary: 0.000001 is exactly representable, so it prints in full. At 6 decimals one // base unit IS 0.000001, which is why a TRON amount can never fall below display precision. diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index d57628178..d5b7d80e4 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -58,7 +58,9 @@ function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string { // receiptRows already states a multi-sign fee; only estimated fees need their own row here. const body = receipt(pending(), `Dry run ${actionLabel(r.kind)}`, [ ...receiptRows(r), - ...(r.multiSignFeeSun === undefined ? [["Fee", formatFee(r.fee, family, symbol)] as Pair] : []), + ...(r.multiSignFeeSun === undefined + ? [["Fee", formatFee(r.fee, family, symbol)] as Pair] + : []), ["Tx", summarizeTx(r.tx ?? r.transaction)], ]); // `tx broadcast --dry-run` resolves the full approval state to decide broadcastability; show diff --git a/ts/src/adapters/inbound/cli/render/wallet.ts b/ts/src/adapters/inbound/cli/render/wallet.ts index 5cb6ebcf5..31ee95087 100644 --- a/ts/src/adapters/inbound/cli/render/wallet.ts +++ b/ts/src/adapters/inbound/cli/render/wallet.ts @@ -144,9 +144,7 @@ function renderWalletList(items: Obj[], family?: string): string { // One family at a time (§3.7): showing both side by side doubles the table's width, and the // user only cares about the chain they are on. An account with no address in this family is // dropped rather than given an empty row — json still carries every family. - const shown = family - ? items.filter((d) => addressFor(d, family) !== undefined) - : items; + const shown = family ? items.filter((d) => addressFor(d, family) !== undefined) : items; if (shown.length === 0) return "No wallets found."; items = shown; // group seeds by their seed id (wlt_x); non-HD accounts by type. Insertion order preserved. diff --git a/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts index 7dc531b54..631b9e30e 100644 --- a/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts +++ b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts @@ -296,9 +296,9 @@ describe("--dry-run bars broadcasting", () => { return { stage: "submitted" }; }); - await expect(buildCli(shellOpts).parseAsync(["tx", "broadcast", "--dry-run"])).rejects.toMatchObject( - { code: "dry_run_violation" }, - ); + await expect( + buildCli(shellOpts).parseAsync(["tx", "broadcast", "--dry-run"]), + ).rejects.toMatchObject({ code: "dry_run_violation" }); expect(submitted).toEqual([]); }); diff --git a/ts/src/adapters/outbound/chain/broadcast-guard-coverage.test.ts b/ts/src/adapters/outbound/chain/broadcast-guard-coverage.test.ts index 9266d4493..88549eb09 100644 --- a/ts/src/adapters/outbound/chain/broadcast-guard-coverage.test.ts +++ b/ts/src/adapters/outbound/chain/broadcast-guard-coverage.test.ts @@ -46,7 +46,10 @@ describe("broadcast guard coverage", () => { for (const match of source.matchAll(SUBMIT_METHOD)) { // The call must come before anything else the method does: a guard placed after the // first await has already let a request go. - const body = source.slice(match.index + match[0].length, match.index + match[0].length + 400); + const body = source.slice( + match.index + match[0].length, + match.index + match[0].length + 400, + ); const guardAt = body.indexOf("assertBroadcastAllowed()"); const awaitAt = body.indexOf("await "); if (guardAt === -1 || (awaitAt !== -1 && awaitAt < guardAt)) { diff --git a/ts/src/adapters/outbound/chain/evm/evm.test.ts b/ts/src/adapters/outbound/chain/evm/evm.test.ts index f88e5ff5f..7ee2d44d1 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.test.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.test.ts @@ -66,7 +66,10 @@ describe("EvmRpcClient.getNativeBalance", () => { }); it("surfaces a non-200 response as rpc_error", async () => { - vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 429, text: async () => "" }))); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 429, text: async () => "" })), + ); await expect( new EvmRpcClient("https://node.example", 5_000).getNativeBalance(ADDR), @@ -300,7 +303,7 @@ describe("EvmRpcClient.callFunction", () => { }); it("returns the result untouched", async () => { - const raw = `0x${(7n).toString(16).padStart(64, "0")}`; + const raw = `0x${7n.toString(16).padStart(64, "0")}`; stubRpc(raw); expect( @@ -470,12 +473,15 @@ describe("EvmRpcClient.getErc20Balance", () => { function stubCall(body: unknown) { vi.stubGlobal( "fetch", - vi.fn(async () => ({ ok: true, text: async () => JSON.stringify({ id: 1, ...(body as object) }) })), + vi.fn(async () => ({ + ok: true, + text: async () => JSON.stringify({ id: 1, ...(body as object) }), + })), ); } it("returns the decoded balance", async () => { - stubCall({ result: `0x${(1234n).toString(16).padStart(64, "0")}` }); + stubCall({ result: `0x${1234n.toString(16).padStart(64, "0")}` }); await expect(client().getErc20Balance(TOKEN, OWNER)).resolves.toBe("1234"); }); @@ -499,7 +505,12 @@ describe("EvmRpcClient.getErc20Balance", () => { // The line the classification must not cross: a node that cannot be reached is still a node // that cannot be reached. it("leaves a transport failure as rpc_error", async () => { - vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("connect ECONNREFUSED"); })); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("connect ECONNREFUSED"); + }), + ); await expect(client().getErc20Balance(TOKEN, OWNER)).rejects.toMatchObject({ code: "rpc_error", @@ -520,29 +531,49 @@ describe("EvmRpcClient.getErc20Metadata", () => { // symbol/name are dynamic strings; decimals is a word. Encoded as ethers would return them. const str = (v: string) => { const hex = Buffer.from(v, "utf8").toString("hex"); - return ("0x" + (32n).toString(16).padStart(64, "0") + BigInt(v.length).toString(16).padStart(64, "0") + hex.padEnd(64, "0")); + return ( + "0x" + + 32n.toString(16).padStart(64, "0") + + BigInt(v.length).toString(16).padStart(64, "0") + + hex.padEnd(64, "0") + ); }; - const answers = [str("USDC"), "0x" + (6n).toString(16).padStart(64, "0"), str("USD Coin")]; + const answers = [str("USDC"), "0x" + 6n.toString(16).padStart(64, "0"), str("USD Coin")]; let i = 0; - vi.stubGlobal("fetch", vi.fn(async () => ({ - ok: true, - text: async () => JSON.stringify({ id: 1, result: answers[i++] }), - }))); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + text: async () => JSON.stringify({ id: 1, result: answers[i++] }), + })), + ); - await expect(client().getErc20Metadata(TOKEN)).resolves.toMatchObject({ symbol: "USDC", decimals: 6 }); + await expect(client().getErc20Metadata(TOKEN)).resolves.toMatchObject({ + symbol: "USDC", + decimals: 6, + }); }); it("returns nothing for a contract that implements none of them", async () => { - vi.stubGlobal("fetch", vi.fn(async () => ({ - ok: true, - text: async () => JSON.stringify({ id: 1, error: { code: -32000, message: "execution reverted" } }), - }))); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + text: async () => + JSON.stringify({ id: 1, error: { code: -32000, message: "execution reverted" } }), + })), + ); await expect(client().getErc20Metadata(TOKEN)).resolves.toEqual({}); }); it("propagates a transport failure instead of reporting absent metadata", async () => { - vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("connect ECONNREFUSED"); })); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("connect ECONNREFUSED"); + }), + ); await expect(client().getErc20Metadata(TOKEN)).rejects.toMatchObject({ code: "rpc_error" }); }); @@ -648,14 +679,24 @@ describe("EvmRpcClient.getTransactionReceipt", () => { // A receipt is NOT proof of success: status 0x0 is a transaction that was mined, paid gas, and // reverted. Reporting that as confirmed would be the worst lie this CLI could tell. it("reports a reverted transaction as failed, not confirmed", async () => { - stubRpc({ status: "0x0", gasUsed: "0x5208", effectiveGasPrice: "0x3b9aca00", blockNumber: "0x10" }); + stubRpc({ + status: "0x0", + gasUsed: "0x5208", + effectiveGasPrice: "0x3b9aca00", + blockNumber: "0x10", + }); const r = await new EvmRpcClient("https://node.example", 5_000).getTransactionReceipt("0xabc"); expect(r).toMatchObject({ success: false, gasUsed: "21000", blockNumber: 16 }); }); it("reports a successful transaction with its realised fee", async () => { - stubRpc({ status: "0x1", gasUsed: "0x5208", effectiveGasPrice: "0x3b9aca00", blockNumber: "0x10" }); + stubRpc({ + status: "0x1", + gasUsed: "0x5208", + effectiveGasPrice: "0x3b9aca00", + blockNumber: "0x10", + }); const r = await new EvmRpcClient("https://node.example", 5_000).getTransactionReceipt("0xabc"); // feeWei is gasUsed × effectiveGasPrice — what was actually paid, not the ceiling. @@ -679,7 +720,7 @@ describe("EvmRpcClient.encodeErc20Transfer", () => { // 0xa9059cbb = transfer(address,uint256); then the padded recipient, then the amount. expect(data).toBe( - `0xa9059cbb${"0".repeat(24)}${ADDR.slice(2).toLowerCase()}${(5000000n) + `0xa9059cbb${"0".repeat(24)}${ADDR.slice(2).toLowerCase()}${5000000n .toString(16) .padStart(64, "0")}`, ); @@ -710,7 +751,8 @@ describe("EvmRpcClient.broadcast (Broadcaster port)", () => { "fetch", vi.fn(async () => ({ ok: true, - text: async () => JSON.stringify({ id: 1, error: { code: -32000, message: "already known" } }), + text: async () => + JSON.stringify({ id: 1, error: { code: -32000, message: "already known" } }), })), ); const out = await new EvmRpcClient("https://node.example", 5_000).broadcast({ @@ -812,7 +854,12 @@ describe("EvmRpcClient contract-write encoding", () => { it.each(["constructor(uint256)", "(uint256)", "uint256"])( "accepts the signature written as %s", (signature) => { - const data = client().encodeDeploy("0x6080", { source: "signature", signature, values: [7], flag: "--x" }); + const data = client().encodeDeploy("0x6080", { + source: "signature", + signature, + values: [7], + flag: "--x", + }); expect(data).toBe(`0x6080${WORD(7n)}`); }, diff --git a/ts/src/adapters/outbound/chain/evm/evm.ts b/ts/src/adapters/outbound/chain/evm/evm.ts index 28705098c..68d6435be 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.ts @@ -39,7 +39,10 @@ export class EvmRpcClient implements EvmGateway { } /** the account's nonce — a QUANTITY. */ - async getTransactionCount(address: string, block: "latest" | "pending" = "latest"): Promise { + async getTransactionCount( + address: string, + block: "latest" | "pending" = "latest", + ): Promise { return toDecimalString(await this.#call("eth_getTransactionCount", [address, block])); } @@ -181,7 +184,9 @@ export class EvmRpcClient implements EvmGateway { // The two numbers feeWei is the product of. A receipt that states only the total leaves the // reader unable to tell an expensive call from a cheap one at a high gas price. ...(price === undefined ? {} : { effectiveGasPriceWei: price.toString(10) }), - ...(r.blockNumber === undefined ? {} : { blockNumber: Number(BigInt(String(r.blockNumber))) }), + ...(r.blockNumber === undefined + ? {} + : { blockNumber: Number(BigInt(String(r.blockNumber))) }), ...(r.contractAddress === undefined || r.contractAddress === null ? {} : { contractAddress: r.contractAddress }), @@ -260,10 +265,7 @@ export class EvmRpcClient implements EvmGateway { } /** calldata for a `{type, value}` call, without sending it — the write half of callFunction. */ - encodeFunctionCall( - signature: string, - params: Array<{ type: string; value: unknown }>, - ): string { + encodeFunctionCall(signature: string, params: Array<{ type: string; value: unknown }>): string { try { const iface = new Interface([`function ${signature}`]); return iface.encodeFunctionData( @@ -587,7 +589,12 @@ const ERC20 = new Interface([ /** the pre-standard `bytes32` spelling of symbol()/name(): fixed width, NUL-padded on the right. */ function decodeBytes32(raw: string): string { - const text = toUtf8String(`0x${raw.replace(/^0x/, "").slice(0, 64).replace(/(00)+$/, "")}`); + const text = toUtf8String( + `0x${raw + .replace(/^0x/, "") + .slice(0, 64) + .replace(/(00)+$/, "")}`, + ); if (text === "") throw new ChainError("rpc_error", "empty bytes32 text"); return text; } diff --git a/ts/src/adapters/outbound/chain/evm/node-errors.ts b/ts/src/adapters/outbound/chain/evm/node-errors.ts index 10bd8bc95..aaa3ca945 100644 --- a/ts/src/adapters/outbound/chain/evm/node-errors.ts +++ b/ts/src/adapters/outbound/chain/evm/node-errors.ts @@ -11,8 +11,16 @@ export interface EvmRejection { } const PATTERNS: Array<[RegExp, string, string]> = [ - [/nonce too low|nonce is too low/i, "nonce_too_low", "nonce already used; the account has moved on"], - [/nonce too high/i, "nonce_too_high", "nonce is ahead of the account; an earlier transaction is missing"], + [ + /nonce too low|nonce is too low/i, + "nonce_too_low", + "nonce already used; the account has moved on", + ], + [ + /nonce too high/i, + "nonce_too_high", + "nonce is ahead of the account; an earlier transaction is missing", + ], [ /insufficient funds/i, "insufficient_balance", @@ -39,7 +47,9 @@ const PATTERNS: Array<[RegExp, string, string]> = [ /** `already known` / `known transaction`: the transaction is ALREADY in the mempool, so the * submission succeeded earlier. Reporting a failure would deny something that already holds. */ export function isAlreadyKnown(message: string): boolean { - return /already known|known transaction|already exists|transaction already in pool/i.test(message); + return /already known|known transaction|already exists|transaction already in pool/i.test( + message, + ); } /** the codes this table can produce — the error-code registry checks itself against it, so a new diff --git a/ts/src/adapters/outbound/chain/evm/signing-strategy.ts b/ts/src/adapters/outbound/chain/evm/signing-strategy.ts index 9871563f9..d85c9e4e2 100644 --- a/ts/src/adapters/outbound/chain/evm/signing-strategy.ts +++ b/ts/src/adapters/outbound/chain/evm/signing-strategy.ts @@ -88,8 +88,7 @@ export const evmSignStrategy: SignStrategy = { return { signature: signDigest(pkHex, digest), digest, - primaryType: - payload.primaryType ?? TypedDataEncoder.from(structTypes).primaryType, + primaryType: payload.primaryType ?? TypedDataEncoder.from(structTypes).primaryType, }; } catch (e) { throw new ChainError( diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 7a331952e..54d05ab93 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -575,30 +575,30 @@ export class TronRpcClient implements TronGateway, Broadcaster { async getTokenInfo(contract: string): Promise { return this.#wrap("trc20 tokenInfo", async () => this.#notAToken(contract, "the TRC-20 view methods", async () => { - // Read the view methods by selector rather than via contract().at(): tokens deployed without a - // published ABI (e.g. USDD on Nile) resolve to a contract object with no methods at all. - // - // No catch here on purpose. A method the contract does not implement is NOT an error at this - // layer — the node answers `result: true` with an empty `constant_result`, which decodes to - // undefined on its own. The only failures that reach this point are a transport fault and - // "no contract at this address", and swallowing either would report a node outage as missing - // token metadata — which callers then escalate into far more specific (and wrong) claims. - const read = async (fn: string): Promise => - this.#constant(contract, fn, []).then(([hex]) => hex); - const [name, symbol, decimals, totalSupply] = await Promise.all([ - read("name()"), - read("symbol()"), - read("decimals()"), - read("totalSupply()"), - ]); - const scale = decodeAbiUint(decimals); - return { - contract, - name: decodeAbiString(name), - symbol: decodeAbiString(symbol), - decimals: scale !== undefined && scale <= 255n ? Number(scale) : undefined, - totalSupply: decodeAbiUint(totalSupply)?.toString(), - }; + // Read the view methods by selector rather than via contract().at(): tokens deployed without a + // published ABI (e.g. USDD on Nile) resolve to a contract object with no methods at all. + // + // No catch here on purpose. A method the contract does not implement is NOT an error at this + // layer — the node answers `result: true` with an empty `constant_result`, which decodes to + // undefined on its own. The only failures that reach this point are a transport fault and + // "no contract at this address", and swallowing either would report a node outage as missing + // token metadata — which callers then escalate into far more specific (and wrong) claims. + const read = async (fn: string): Promise => + this.#constant(contract, fn, []).then(([hex]) => hex); + const [name, symbol, decimals, totalSupply] = await Promise.all([ + read("name()"), + read("symbol()"), + read("decimals()"), + read("totalSupply()"), + ]); + const scale = decodeAbiUint(decimals); + return { + contract, + name: decodeAbiString(name), + symbol: decodeAbiString(symbol), + decimals: scale !== undefined && scale <= 255n ? Number(scale) : undefined, + totalSupply: decodeAbiUint(totalSupply)?.toString(), + }; }), ); } diff --git a/ts/src/adapters/outbound/config/config.test.ts b/ts/src/adapters/outbound/config/config.test.ts index 379ff5f0b..5866a71cb 100644 --- a/ts/src/adapters/outbound/config/config.test.ts +++ b/ts/src/adapters/outbound/config/config.test.ts @@ -159,7 +159,8 @@ describe("builtin EVM networks", () => { it("keeps the TRON networks unchanged", () => { expect(registry().resolve("tron:nile")).toMatchObject({ - family: "tron", nativeSymbol: "TRX", + family: "tron", + nativeSymbol: "TRX", feeModel: "tron-resource", }); }); @@ -232,11 +233,14 @@ describe("network keys in config.yaml are normalised to canonical ids", () => { }); it("keeps the rest of the builtin descriptor when merging an alias-keyed entry", () => { - const config = load(["networks:", " nile:", " httpEndpoint: https://mine.example"].join("\n")); + const config = load( + ["networks:", " nile:", " httpEndpoint: https://mine.example"].join("\n"), + ); expect(config.networks["tron:nile"]).toMatchObject({ id: "tron:nile", - family: "tron", nativeSymbol: "TRX", + family: "tron", + nativeSymbol: "TRX", httpEndpoint: "https://mine.example", }); }); @@ -255,9 +259,13 @@ describe("network keys in config.yaml are normalised to canonical ids", () => { it("leaves an unrecognised key alone so a user-defined network still works", () => { const config = load( - ["networks:", " evm:137:", " family: evm", ' chainId: "137"', " nativeSymbol: MATIC"].join( - "\n", - ), + [ + "networks:", + " evm:137:", + " family: evm", + ' chainId: "137"', + " nativeSymbol: MATIC", + ].join("\n"), ); expect(config.networks["evm:137"]).toMatchObject({ id: "evm:137", family: "evm" }); @@ -324,7 +332,7 @@ describe("a hand-added network is validated at load", () => { ["networks:", " evm:137:", ...extra.map((l) => ` ${l}`)].join("\n"); it("accepts a complete definition", () => { - const net = load(custom(['family: evm', 'chainId: "137"', 'nativeSymbol: MATIC'])).networks[ + const net = load(custom(["family: evm", 'chainId: "137"', "nativeSymbol: MATIC"])).networks[ "evm:137" ]!; expect(net).toMatchObject({ family: "evm", chainId: "137", nativeSymbol: "MATIC" }); @@ -333,7 +341,7 @@ describe("a hand-added network is validated at load", () => { // Traits are a list of extras; having none is the normal case, not an error. it("defaults capabilities to none rather than leaving it undefined", () => { expect( - load(custom(['family: evm', 'chainId: "137"', 'nativeSymbol: MATIC'])).networks["evm:137"]! + load(custom(["family: evm", 'chainId: "137"', "nativeSymbol: MATIC"])).networks["evm:137"]! .capabilities, ).toEqual([]); }); diff --git a/ts/src/adapters/outbound/contactbook/contactbook.test.ts b/ts/src/adapters/outbound/contactbook/contactbook.test.ts index f1e49e080..c11e85473 100644 --- a/ts/src/adapters/outbound/contactbook/contactbook.test.ts +++ b/ts/src/adapters/outbound/contactbook/contactbook.test.ts @@ -81,7 +81,8 @@ describe("ContactBook", () => { entries: { tron: [ { - family: "tron", nativeSymbol: "TRX", + family: "tron", + nativeSymbol: "TRX", name: "Alice", nameKey: "bob", address: ADDRESS, diff --git a/ts/src/adapters/outbound/contactbook/index.ts b/ts/src/adapters/outbound/contactbook/index.ts index d1eec9af2..14f362cef 100644 --- a/ts/src/adapters/outbound/contactbook/index.ts +++ b/ts/src/adapters/outbound/contactbook/index.ts @@ -98,7 +98,11 @@ export class ContactBook implements ContactRepository { const result: ContactDocument = { version: 1, entries: {} }; for (const [key, items] of Object.entries(root.entries as Record)) { const family = key as ChainFamily; - if (!CHAIN_FAMILIES.includes(family) || !Array.isArray(items) || items.length > MAX_CONTACTS) { + if ( + !CHAIN_FAMILIES.includes(family) || + !Array.isArray(items) || + items.length > MAX_CONTACTS + ) { throw corrupt(); } const seen = new Set(); diff --git a/ts/src/adapters/outbound/keystore/index.ts b/ts/src/adapters/outbound/keystore/index.ts index b8ccc6904..879cdf9c9 100644 --- a/ts/src/adapters/outbound/keystore/index.ts +++ b/ts/src/adapters/outbound/keystore/index.ts @@ -92,8 +92,11 @@ export class Keystore { if (p.type === "seed") { const mnemonic = p.secret.trim(); + // Its own code, not the generic bucket: the phrase is entered at a hidden prompt, so the + // envelope carries no field path to say WHICH value was wrong — the code is all the + // caller gets, and `invalid_value` says nothing it did not already know. if (!Derivation.validateMnemonic(mnemonic)) { - throw new WalletError("invalid_value", "invalid BIP39 mnemonic"); + throw new WalletError("invalid_mnemonic", "invalid BIP39 mnemonic"); } const entropy = Derivation.mnemonicToEntropy(mnemonic); const seed = Derivation.mnemonicToSeed(mnemonic, p.passphrase); @@ -121,9 +124,20 @@ export class Keystore { ); source = { type: "seed", vaultId, addresses: { "0": addr0 } }; } else { - const pk = hexToBytes(p.secret.trim().replace(/^0x/, "")); + // hexToBytes throws its own Error on a non-hex character, which classifyError would turn + // into a REDACTED internal_error — the two ways to mistype a private key would then answer + // with two different codes, one of them wrong. Both are the same mistake, so both say so. + let pk: Uint8Array; + try { + pk = hexToBytes(p.secret.trim().replace(/^0x/, "")); + } catch { + throw new WalletError( + "invalid_private_key", + "private key must be 32 bytes of hex, with or without a 0x prefix", + ); + } if (pk.length !== 32) - throw new WalletError("invalid_value", "private key must be 32 bytes"); + throw new WalletError("invalid_private_key", "private key must be 32 bytes"); const addr = derivePrivAddresses(pk); const dup = findByAddress(file, addr); if (dup) { @@ -214,13 +228,13 @@ export class Keystore { return this.store.withLock(this.walletsPath, () => { const file = this.#read(); const wallet = file.wallets.find((w) => w.id === walletId); - if (!wallet) throw new WalletError("invalid_value", `unknown wallet ${walletId}`); + if (!wallet) throw new WalletError("account_not_found", `unknown wallet ${walletId}`); // only seed wallets are HD: privateKey has no derivation, ledger must be re-imported per path. if (wallet.source.type !== "seed") { const hint = wallet.source.type === "ledger" ? " — import another path with 'import ledger'" : ""; throw new WalletError( - "invalid_value", + "seed_not_found", `${wallet.source.type} wallets are not HD; cannot add accounts${hint}`, ); } @@ -246,7 +260,7 @@ export class Keystore { const ref = this.#toRef(file, refOrLabel); const [walletId, idxStr] = ref.split("."); const wallet = file.wallets.find((w) => w.id === walletId); - if (!wallet) throw new WalletError("invalid_value", `unknown account ${refOrLabel}`); + if (!wallet) throw new WalletError("account_not_found", `unknown account ${refOrLabel}`); if (wallet.source.type !== "seed") return { wallet, index: -1 }; let index: number; if (idxStr === undefined) { @@ -274,7 +288,7 @@ export class Keystore { const ref = this.#toRef(file, idOrLabel); const walletId = ref.split(".")[0]!; const wallet = file.wallets.find((w) => w.id === walletId); - if (!wallet) throw new WalletError("invalid_value", `unknown wallet ${idOrLabel}`); + if (!wallet) throw new WalletError("account_not_found", `unknown wallet ${idOrLabel}`); return wallet; } @@ -607,7 +621,7 @@ export class Keystore { } } if (hits.length === 0) - throw new WalletError("invalid_value", `no account with address ${input}`); + throw new WalletError("account_not_found", `no account with address ${input}`); if (hits.length > 1) { throw new UsageError( "invalid_value", @@ -619,8 +633,13 @@ export class Keystore { const matches = Object.entries(file.labels).filter( ([, label]) => label.trim().toLowerCase() === v.toLowerCase(), ); + // §4.3 names this code for exactly this case. `invalid_value` is the bucket every malformed + // option lands in; "that account does not exist here" has one obvious next step (`list`), and + // an agent can only take it if the code says so — the message is not something to match on. + // The ambiguous cases below keep `invalid_value`: the reference IS valid, it just picks more + // than one account, and the fix is to narrow it rather than to go looking for it. if (matches.length === 0) - throw new WalletError("invalid_value", `no account labelled '${input}'`); + throw new WalletError("account_not_found", `no account labelled '${input}'`); if (matches.length > 1) { throw new UsageError( "invalid_value", diff --git a/ts/src/adapters/outbound/keystore/keystore.test.ts b/ts/src/adapters/outbound/keystore/keystore.test.ts index beb853e9d..f80639ea1 100644 --- a/ts/src/adapters/outbound/keystore/keystore.test.ts +++ b/ts/src/adapters/outbound/keystore/keystore.test.ts @@ -364,6 +364,57 @@ describe("Keystore", () => { }); }); +/** + * The codes these three failures answer with. + * + * All three used to be `invalid_value` — the bucket every malformed option lands in. For a value + * typed at a hidden prompt there is no field path in the envelope either, so the code was the only + * thing the caller got, and it said nothing. §11 names all three. + */ +describe("lookup and secret-shape failures carry their own codes", () => { + let ks: Keystore; + beforeEach(() => { + ks = freshKeystore(); + }); + + it("reports a reference that matches no account as account_not_found", () => { + ks.import({ secret: MNEMONIC, type: "seed", label: "main" }); + + for (const ref of ["nosuchlabel", "wlt_doesnotexist", TRON0.replace(/.$/, "x")]) { + expect(() => ks.resolveAccount(ref), ref).toThrowError( + expect.objectContaining({ code: "account_not_found" }), + ); + } + }); + + // An ambiguous reference is NOT the same failure: the value is valid and simply picks more than + // one account, so the fix is to narrow it, not to go looking for a missing account. + it("keeps invalid_value for a reference that matches more than one account", () => { + const a = ks.import({ secret: MNEMONIC, type: "seed", label: "main" }); + ks.addAccount(a.accountId.split(".")[0]!, 1); + + expect(() => ks.resolveAccount(a.accountId.split(".")[0]!)).toThrowError( + expect.objectContaining({ code: "invalid_value" }), + ); + }); + + it("reports a bad recovery phrase as invalid_mnemonic", () => { + expect(() => ks.import({ secret: "not a mnemonic at all", type: "seed" })).toThrowError( + expect.objectContaining({ code: "invalid_mnemonic" }), + ); + }); + + // Both ways to mistype a private key answer the same, including the non-hex one — which + // previously escaped as a REDACTED internal_error from the hex decoder. + it("reports either shape of bad private key as invalid_private_key", () => { + for (const bad of ["zz".repeat(32), "ab".repeat(31)]) { + expect(() => ks.import({ secret: bad, type: "privateKey" }), bad).toThrowError( + expect.objectContaining({ code: "invalid_private_key" }), + ); + } + }); +}); + describe("password sentinel queries", () => { it("isInitialized flips after the first import; verifyPassword checks the sentinel", () => { const root = mkdtempSync(join(tmpdir(), "ks-sentinel-")); @@ -551,7 +602,11 @@ describe("descriptor carries each family's derivation path", () => { it("gives a ledger account only its own family's path", () => { const root = mkdtempSync(join(tmpdir(), "ks-")); const ks = new Keystore(root, new AtomicFileStore(), () => "masterpw123A"); - ks.registerLedger({ family: "tron", path: "m/44'/195'/5'/0/0", address: "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6" }); + ks.registerLedger({ + family: "tron", + path: "m/44'/195'/5'/0/0", + address: "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6", + }); expect(ks.list()[0]!.derivationPath).toEqual({ tron: "m/44'/195'/5'/0/0" }); }); diff --git a/ts/src/adapters/outbound/ledger/evm.test.ts b/ts/src/adapters/outbound/ledger/evm.test.ts index b3160cd19..30ae23dbb 100644 --- a/ts/src/adapters/outbound/ledger/evm.test.ts +++ b/ts/src/adapters/outbound/ledger/evm.test.ts @@ -178,9 +178,7 @@ describe("Ledger signs EVM typed data", () => { const [, domainHash, structHash] = calls[0]!.args as [string, string, string]; expect(domainHash).toBe(TypedDataEncoder.hashDomain(DOMAIN).replace(/^0x/, "")); - expect(structHash).toBe( - TypedDataEncoder.hashStruct("Mail", TYPES, MESSAGE).replace(/^0x/, ""), - ); + expect(structHash).toBe(TypedDataEncoder.hashStruct("Mail", TYPES, MESSAGE).replace(/^0x/, "")); }); }); diff --git a/ts/src/adapters/outbound/ledger/index.ts b/ts/src/adapters/outbound/ledger/index.ts index ade32307c..f9f644f28 100644 --- a/ts/src/adapters/outbound/ledger/index.ts +++ b/ts/src/adapters/outbound/ledger/index.ts @@ -203,10 +203,7 @@ export class Ledger { private assertWired(family: ChainFamily): void { if (!FAMILIES[family].ledger) { - throw new ExecutionError( - "auth_required", - `Ledger ${family} app is not wired yet`, - ); + throw new ExecutionError("auth_required", `Ledger ${family} app is not wired yet`); } } @@ -307,13 +304,17 @@ export class Ledger { const existing = (tx as { signature?: unknown }).signature; const prior = Array.isArray(existing) ? existing : []; try { - return await this.#bound(family, async (trx) => { - const signature = await trx.signTransaction(ledgerPath(path), rawTxHex, []); - return { - ...(tx as object), - signature: prior.includes(signature) ? prior : [...prior, signature], - }; - }, signal); + return await this.#bound( + family, + async (trx) => { + const signature = await trx.signTransaction(ledgerPath(path), rawTxHex, []); + return { + ...(tx as object), + signature: prior.includes(signature) ? prior : [...prior, signature], + }; + }, + signal, + ); } catch (e) { throw classifyDeviceError(e); } @@ -328,10 +329,12 @@ export class Ledger { this.assertWired(family); const messageHex = Buffer.from(message, "utf8").toString("hex"); try { - return await this.#bound(family, async (app) => { - const signed = await app.signPersonalMessage(ledgerPath(path), messageHex); - return typeof signed === "string" ? `0x${signed}` : joinVrs(signed); - }, + return await this.#bound( + family, + async (app) => { + const signed = await app.signPersonalMessage(ledgerPath(path), messageHex); + return typeof signed === "string" ? `0x${signed}` : joinVrs(signed); + }, signal, ); } catch (e) { @@ -372,20 +375,24 @@ export class Ledger { ); } try { - return await this.#bound(family, async (trx) => { - if (typeof trx.signTIP712HashedMessage !== "function") { - throw new WalletError( - "ledger_unsupported", - "this Ledger TRON app version cannot sign TIP-712 typed data; update the app", + return await this.#bound( + family, + async (trx) => { + if (typeof trx.signTIP712HashedMessage !== "function") { + throw new WalletError( + "ledger_unsupported", + "this Ledger TRON app version cannot sign TIP-712 typed data; update the app", + ); + } + const signature = await trx.signTIP712HashedMessage( + ledgerPath(path), + domainHash, + messageHash, ); - } - const signature = await trx.signTIP712HashedMessage( - ledgerPath(path), - domainHash, - messageHash, - ); - return { signature: `0x${signature}`, digest, primaryType }; - }, signal); + return { signature: `0x${signature}`, digest, primaryType }; + }, + signal, + ); } catch (e) { throw classifyDeviceError(e); } @@ -453,7 +460,10 @@ export class Ledger { "", ); } catch (e) { - throw new ChainError("invalid_transaction", `typed data could not be hashed: ${errMessage(e)}`); + throw new ChainError( + "invalid_transaction", + `typed data could not be hashed: ${errMessage(e)}`, + ); } try { return await this.#bound( diff --git a/ts/src/adapters/outbound/price/coingecko.test.ts b/ts/src/adapters/outbound/price/coingecko.test.ts index 2d04af541..10586e950 100644 --- a/ts/src/adapters/outbound/price/coingecko.test.ts +++ b/ts/src/adapters/outbound/price/coingecko.test.ts @@ -136,12 +136,15 @@ describe("CoinGeckoPriceProvider — EVM", () => { * were removed from the maps so a future edit cannot quietly re-enable mainnet pricing for a * chain whose coins are free. */ - it.each(["evm:11155111", "evm:97"])("no longer prices the testnet %s at all", async (networkId) => { - const spy = stub({ ethereum: { usd: 2500 }, binancecoin: { usd: 600 } }); - - expect(await new CoinGeckoPriceProvider().nativeUsd(networkId)).toBeNull(); - expect(spy).not.toHaveBeenCalled(); - }); + it.each(["evm:11155111", "evm:97"])( + "no longer prices the testnet %s at all", + async (networkId) => { + const spy = stub({ ethereum: { usd: 2500 }, binancecoin: { usd: 600 } }); + + expect(await new CoinGeckoPriceProvider().nativeUsd(networkId)).toBeNull(); + expect(spy).not.toHaveBeenCalled(); + }, + ); // The exposure this closes: deterministic deployment can put one address on both chains, so a // testnet token looked up against a mainnet platform could take a real token's price. diff --git a/ts/src/adapters/outbound/tronlink/client.test.ts b/ts/src/adapters/outbound/tronlink/client.test.ts index 21f276152..f69b029aa 100644 --- a/ts/src/adapters/outbound/tronlink/client.test.ts +++ b/ts/src/adapters/outbound/tronlink/client.test.ts @@ -18,7 +18,8 @@ const CONFIG = { } as Config; const NETWORK = { id: "tron:mainnet", - family: "tron", nativeSymbol: "TRX", + family: "tron", + nativeSymbol: "TRX", chainId: "mainnet", tronlinkHttpEndpoint: "https://api.walletadapter.org", } as NetworkDescriptor; diff --git a/ts/src/application/ports/chain/gateway-provider.ts b/ts/src/application/ports/chain/gateway-provider.ts index d65ba22e9..dc5bfb171 100644 --- a/ts/src/application/ports/chain/gateway-provider.ts +++ b/ts/src/application/ports/chain/gateway-provider.ts @@ -63,7 +63,9 @@ export interface EvmGateway extends NativeBalanceReader, Broadcaster { /** ERC-20 balance as a decimal base-unit string. */ getErc20Balance(contract: string, owner: string): Promise; /** best-effort ERC-20 metadata; a field the contract does not answer is absent, never defaulted. */ - getErc20Metadata(contract: string): Promise<{ symbol?: string; decimals?: number; name?: string }>; + getErc20Metadata( + contract: string, + ): Promise<{ symbol?: string; decimals?: number; name?: string }>; } /** diff --git a/ts/src/application/services/evm-confirmation.test.ts b/ts/src/application/services/evm-confirmation.test.ts index 480326fd6..4d705ed39 100644 --- a/ts/src/application/services/evm-confirmation.test.ts +++ b/ts/src/application/services/evm-confirmation.test.ts @@ -34,7 +34,12 @@ const gatewayReturning = (...receipts: Array | null>) => describe("evmConfirmation", () => { it("reports a mined, successful transaction as confirmed", async () => { const out = await evmConfirmation( - gatewayReturning({ success: true, gasUsed: "21000", feeWei: "22436119209000", blockNumber: 11551817 }), + gatewayReturning({ + success: true, + gasUsed: "21000", + feeWei: "22436119209000", + blockNumber: 11551817, + }), scope(), )(HASH); @@ -64,8 +69,9 @@ describe("evmConfirmation", () => { const out = await evmConfirmation(gateway, scope(5_000))(HASH); expect(out).toMatchObject({ confirmed: true, blockNumber: 7 }); - expect((gateway.getTransactionReceipt as ReturnType).mock.calls.length) - .toBeGreaterThan(1); + expect( + (gateway.getTransactionReceipt as ReturnType).mock.calls.length, + ).toBeGreaterThan(1); }); it("gives up at the wait timeout rather than hanging", async () => { diff --git a/ts/src/application/services/evm-gas-estimate.test.ts b/ts/src/application/services/evm-gas-estimate.test.ts index 663a175f3..7bb2d6618 100644 --- a/ts/src/application/services/evm-gas-estimate.test.ts +++ b/ts/src/application/services/evm-gas-estimate.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { resolveGasLimit } from "./evm-gas-estimate.js"; +import { ChainError } from "../../domain/errors/index.js"; describe("resolveGasLimit", () => { it("returns the node's estimate", async () => { @@ -29,10 +30,44 @@ describe("resolveGasLimit", () => { const error = await resolveGasLimit(gateway, { from: "0xabc" }).catch((e) => e); - expect(error).toMatchObject({ code: "invalid_option" }); expect(error.message).not.toContain("21000"); }); + /** + * The second regression: every failure here used to be reported as `invalid_option` — exit 2, + * "fix your invocation". A timeout, an HTTP 503 and an unreachable endpoint all landed there, so + * a caller that retries on exit 1 and gives up on exit 2 gave up on a transient network fault. + */ + it("keeps a typed failure's own code and exit class, adding only the way out", async () => { + const gateway = { + estimateGas: vi.fn(async () => { + throw new ChainError("timeout", "eth_estimateGas failed: The operation was aborted"); + }), + }; + + const error = await resolveGasLimit(gateway, { from: "0xabc" }).catch((e) => e); + + expect(error.code).toBe("timeout"); + expect(error.exitCode()).toBe(1); + expect(error.message).toMatch(/--gas-limit/); + }); + + // An untyped throw would otherwise be redacted to a bare internal_error at the top level, + // taking the node's words with it. + it("reports an untyped failure as rpc_error rather than letting it be redacted", async () => { + const gateway = { + estimateGas: vi.fn(async () => { + throw new Error("insufficient funds for transfer"); + }), + }; + + const error = await resolveGasLimit(gateway, { from: "0xabc" }).catch((e) => e); + + expect(error.code).toBe("rpc_error"); + expect(error.exitCode()).toBe(1); + expect(error.message).toContain("insufficient funds"); + }); + it("carries the node's own words, which are the useful part", async () => { const gateway = { estimateGas: vi.fn(async () => { diff --git a/ts/src/application/services/evm-gas-estimate.ts b/ts/src/application/services/evm-gas-estimate.ts index b619fbfd1..22b5b8400 100644 --- a/ts/src/application/services/evm-gas-estimate.ts +++ b/ts/src/application/services/evm-gas-estimate.ts @@ -10,8 +10,15 @@ * A failed estimate is almost always the node telling you something true: the call reverts, the * account cannot cover it, the contract is not what you think. That message is the useful part, * so it is carried through rather than replaced by a guess. + * + * What it is NOT is a bad command line. Every failure here used to become `invalid_option`, i.e. + * exit 2 — the code that means "fix your invocation". An unreachable endpoint, an HTTP 503 and a + * timeout all landed there, so a caller retrying on exit 1 and giving up on exit 2 gave up on a + * transient network failure. The suggestion to pass `--gas-limit` is worth making either way, but + * it does not turn a node outage into a typo: the original code and exit class are kept, and only + * the way out is appended. */ -import { UsageError } from "../../domain/errors/index.js"; +import { ChainError, CliError, UsageError } from "../../domain/errors/index.js"; interface GasEstimator { estimateGas(tx: Record): Promise; @@ -30,9 +37,22 @@ export async function resolveGasLimit( try { return await gateway.estimateGas(request); } catch (e) { - throw new UsageError( - "invalid_option", - `the node could not estimate gas for this transaction; pass --gas-limit to proceed. The node said: ${(e as Error).message}`, + const way = "pass --gas-limit to proceed without an estimate"; + // A typed error already says what happened (rpc_error, timeout, …) and which exit class it + // belongs to. Rebuilding it with the same code keeps both and still points at the way out. + if (e instanceof CliError) { + const message = `${e.message}; ${way}`; + // Same kind, not just the same code: kind is what decides exit 1 vs exit 2, and a usage + // error arriving from below is still a usage error. + throw e.kind === "usage" + ? new UsageError(e.code, message, e.details) + : new ChainError(e.code, message, e.details); + } + // Anything else would be redacted to a bare internal_error at the top level, taking the node's + // words with it — and those words are the reason this function does not guess. + throw new ChainError( + "rpc_error", + `the node could not estimate gas for this transaction; ${way}. The node said: ${(e as Error).message}`, ); } } diff --git a/ts/src/application/services/ledger-account.test.ts b/ts/src/application/services/ledger-account.test.ts index 6dafdf828..e3c1f9193 100644 --- a/ts/src/application/services/ledger-account.test.ts +++ b/ts/src/application/services/ledger-account.test.ts @@ -30,6 +30,22 @@ describe("resolveLedgerPath", () => { }); }); + /** + * A malformed path and a wrong-coin path are different mistakes. + * + * Both used to answer "--path coin_type ? does not match --app tron", which describes a mismatch + * the caller never had. And the old check matched only the `m/44'/'/` prefix, so a path + * with rubbish after it was accepted and sent to the device. + */ + it("rejects a value that is not a derivation path with invalid_path", async () => { + for (const bad of ["notapath", "m/44'/195'/garbage", "m/44'/195'", "44'/195'/0'/0/0", ""]) { + await expect( + resolveLedgerPath(fakeLedger(), "tron", { path: bad }), + bad, + ).rejects.toMatchObject({ code: "invalid_path" }); + } + }); + it("locates a known --address by bounded scan and returns its path", async () => { const target = "addr@m/44'/195'/2'/0/0"; const path = await resolveLedgerPath(fakeLedger(), "tron", { address: target, scanLimit: 10 }); diff --git a/ts/src/application/services/ledger-account.ts b/ts/src/application/services/ledger-account.ts index c760688df..0b997ed96 100644 --- a/ts/src/application/services/ledger-account.ts +++ b/ts/src/application/services/ledger-account.ts @@ -12,8 +12,15 @@ export interface LedgerLocator { scanLimit?: number; } -const DEFAULT_SCAN_LIMIT = 20; -const PATH_PATTERN = /^m\/44'\/(\d+)'\//; +/** Exported so the `--scan-limit` schema can declare THIS value as its default: one constant, and + * `--json-schema` still publishes it. */ +export const DEFAULT_SCAN_LIMIT = 20; +/** A whole BIP32 path, not just its head: `m` followed by 2-6 levels, each a number with an + * optional hardened mark. The old check only matched the `m/44'/'/` PREFIX, so + * `m/44'/195'/garbage` passed validation and went to the device as-is. */ +const BIP32_PATH = /^m(?:\/\d+'?){2,6}$/; +/** the BIP44 template's first two levels, which is what --app has to agree with. */ +const BIP44_HEAD = /^m\/44'\/(\d+)'\//; /** Resolve a Ledger account locator without depending on a concrete transport. */ export async function resolveLedgerPath( @@ -23,13 +30,24 @@ export async function resolveLedgerPath( ): Promise { if (locator.index !== undefined) return Derivation.path(family, locator.index); if (locator.path !== undefined) { - const match = PATH_PATTERN.exec(locator.path); - const coinType = match ? Number(match[1]) : Number.NaN; + // Two different failures, told apart. A malformed path is a bad VALUE — reporting it as + // "coin_type ? does not match --app tron" describes a mismatch the user never had, and sends + // them to look at --app when the problem is the string they typed. + const match = BIP44_HEAD.exec(locator.path); + if (!BIP32_PATH.test(locator.path) || !match) { + throw new UsageError( + "invalid_path", + `--path must be a BIP44 derivation path like m/44'/${FAMILIES[family].coinType}'/0'/0/0, not '${locator.path}'`, + ); + } + // Whereas THIS is a genuine disagreement between two flags the caller gave, so it stays a + // cross-flag usage error and names both sides. + const coinType = Number(match[1]); const expected = FAMILIES[family].coinType; if (coinType !== expected) { throw new UsageError( "invalid_option", - `--path coin_type ${match ? coinType : "?"} does not match --app ${family} (expected ${expected})`, + `--path coin_type ${coinType} does not match --app ${family} (expected ${expected})`, ); } return locator.path; diff --git a/ts/src/application/services/recipient-resolver.test.ts b/ts/src/application/services/recipient-resolver.test.ts index 220b1c4a1..9b27b6f47 100644 --- a/ts/src/application/services/recipient-resolver.test.ts +++ b/ts/src/application/services/recipient-resolver.test.ts @@ -14,7 +14,8 @@ describe("RecipientResolver", () => { find: (_family: string, key: string) => key === "alice" ? { - family: "tron", nativeSymbol: "TRX", + family: "tron", + nativeSymbol: "TRX", name: "Alice", nameKey: "alice", address: ALICE, @@ -84,7 +85,13 @@ describe("RecipientResolver — EVM", () => { const impostor = "0xe2e1a54926527Fbb4E4420DE4c6BAb82beAEE24D"; const resolverWithImpostor = new RecipientResolver( repoWith([ - { family: "evm", nativeSymbol: "ETH", name: impostor, nameKey: impostor.toLowerCase(), address: "0xdead" }, + { + family: "evm", + nativeSymbol: "ETH", + name: impostor, + nameKey: impostor.toLowerCase(), + address: "0xdead", + }, ]), ); @@ -93,7 +100,9 @@ describe("RecipientResolver — EVM", () => { it("resolves a contact filed under evm", () => { const withFriend = new RecipientResolver( - repoWith([{ family: "evm", nativeSymbol: "ETH", name: "Friend", nameKey: "friend", address: EVM }]), + repoWith([ + { family: "evm", nativeSymbol: "ETH", name: "Friend", nameKey: "friend", address: EVM }, + ]), ); expect(withFriend.resolve("evm", "friend")).toEqual({ address: EVM, contactName: "Friend" }); @@ -101,7 +110,9 @@ describe("RecipientResolver — EVM", () => { it("does not see a contact filed under another family", () => { const tronOnly = new RecipientResolver( - repoWith([{ family: "tron", nativeSymbol: "TRX", name: "Friend", nameKey: "friend", address: TRON }]), + repoWith([ + { family: "tron", nativeSymbol: "TRX", name: "Friend", nameKey: "friend", address: TRON }, + ]), ); expect(() => tronOnly.resolve("evm", "friend")).toThrow(); @@ -228,4 +239,3 @@ describe("RecipientResolver — a value that is neither", () => { expect(error.message).toMatch(/not an address either/); }); }); - diff --git a/ts/src/application/services/recipient-resolver.ts b/ts/src/application/services/recipient-resolver.ts index 8dacf217b..9cf3215ed 100644 --- a/ts/src/application/services/recipient-resolver.ts +++ b/ts/src/application/services/recipient-resolver.ts @@ -88,4 +88,3 @@ export class RecipientResolver { function looksLikeAddressAttempt(value: string): boolean { return /^0x/i.test(value) || /^T[1-9A-HJ-NP-Za-km-z]{10,}$/.test(value); } - diff --git a/ts/src/application/services/target/index.ts b/ts/src/application/services/target/index.ts index 5ececc200..c366eca3c 100644 --- a/ts/src/application/services/target/index.ts +++ b/ts/src/application/services/target/index.ts @@ -48,8 +48,6 @@ export class TargetResolver { ); } - return { network }; } - } diff --git a/ts/src/application/use-cases/account-balance-service.test.ts b/ts/src/application/use-cases/account-balance-service.test.ts index d7d07ad58..2836bfa00 100644 --- a/ts/src/application/use-cases/account-balance-service.test.ts +++ b/ts/src/application/use-cases/account-balance-service.test.ts @@ -14,7 +14,9 @@ import type { NetworkDescriptor } from "../../domain/types/index.js"; const scope: AccountScope = { activeAccount: "wlt_test.0", resolveAddress: () => "0xADDR" }; const gateways = (balance: string) => - ({ client: () => ({ getNativeBalance: async () => balance }) }) as unknown as ChainGatewayProvider; + ({ + client: () => ({ getNativeBalance: async () => balance }), + }) as unknown as ChainGatewayProvider; const network = (over: Partial): NetworkDescriptor => ({ diff --git a/ts/src/application/use-cases/config-service.test.ts b/ts/src/application/use-cases/config-service.test.ts index 474bcd425..0dd693957 100644 --- a/ts/src/application/use-cases/config-service.test.ts +++ b/ts/src/application/use-cases/config-service.test.ts @@ -183,14 +183,22 @@ describe("ConfigService networks..httpEndpoint", () => { it("rejects an unknown network in the key", () => { const { svc } = service(); expect(() => - svc.execute({ key: "networks.dogechain.httpEndpoint", value: "https://x" }, twoNetworks, registry), + svc.execute( + { key: "networks.dogechain.httpEndpoint", value: "https://x" }, + twoNetworks, + registry, + ), ).toThrow(/dogechain/); }); it("rejects a non-https endpoint", () => { const { svc } = service(); expect(() => - svc.execute({ key: "networks.nile.httpEndpoint", value: "ftp://nope" }, twoNetworks, registry), + svc.execute( + { key: "networks.nile.httpEndpoint", value: "ftp://nope" }, + twoNetworks, + registry, + ), ).toThrow(); }); @@ -234,23 +242,26 @@ describe("ConfigService alias book view", () => { describe("ConfigService reads a nested network key", () => { it("returns the endpoint instead of demanding a value", () => { const { svc } = service(); - expect(svc.execute({ key: "networks.evm:11155111.httpEndpoint" }, twoNetworks, registry)).toEqual( - { key: "networks.evm:11155111.httpEndpoint", value: "https://sepolia.example/abc123" }, - ); + expect( + svc.execute({ key: "networks.evm:11155111.httpEndpoint" }, twoNetworks, registry), + ).toEqual({ + key: "networks.evm:11155111.httpEndpoint", + value: "https://sepolia.example/abc123", + }); }); it("resolves an alias in the key when reading, exactly as when writing", () => { const { svc } = service(); - expect(svc.execute({ key: "networks.sepolia.httpEndpoint" }, twoNetworks, registry)).toMatchObject( - { key: "networks.evm:11155111.httpEndpoint" }, - ); + expect( + svc.execute({ key: "networks.sepolia.httpEndpoint" }, twoNetworks, registry), + ).toMatchObject({ key: "networks.evm:11155111.httpEndpoint" }); }); it("reads back what was just written", () => { const { svc } = service(); - expect( - svc.execute({ key: "networks.nile.httpEndpoint" }, twoNetworks, registry), - ).toMatchObject({ value: "https://nile.trongrid.io" }); + expect(svc.execute({ key: "networks.nile.httpEndpoint" }, twoNetworks, registry)).toMatchObject( + { value: "https://nile.trongrid.io" }, + ); }); it("still rejects an unwritable sub-key when reading", () => { 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 4cee56144..4b077e953 100644 --- a/ts/src/application/use-cases/evm/account-service.test.ts +++ b/ts/src/application/use-cases/evm/account-service.test.ts @@ -83,14 +83,16 @@ describe("EvmAccountService.portfolio", () => { { kind: "erc20", id: USDT, symbol: "USDT", decimals: 6, source: "official" as const }, ]; - function portfolioService(over: { - native?: string; - balances?: Record; - nativePrice?: number | null; - tokenPrices?: Map; - pricesThrow?: boolean; - book?: unknown[]; - } = {}) { + function portfolioService( + over: { + native?: string; + balances?: Record; + nativePrice?: number | null; + tokenPrices?: Map; + pricesThrow?: boolean; + book?: unknown[]; + } = {}, + ) { const gateway = { getNativeBalance: async () => over.native ?? "1000000000000000000", getErc20Balance: async (contract: string) => { @@ -110,7 +112,7 @@ describe("EvmAccountService.portfolio", () => { return over.tokenPrices ?? new Map([[USDT, 1]]); }, }; - const tokens = { effective: () => (over.book ?? BOOK) }; + const tokens = { effective: () => over.book ?? BOOK }; return new EvmAccountService( { get: () => gateway } as unknown as ChainGatewayProvider, tokens as never, diff --git a/ts/src/application/use-cases/evm/account-service.ts b/ts/src/application/use-cases/evm/account-service.ts index 89ec9be16..b51a40850 100644 --- a/ts/src/application/use-cases/evm/account-service.ts +++ b/ts/src/application/use-cases/evm/account-service.ts @@ -66,13 +66,7 @@ export class EvmAccountService { } const holdings: Array> = [ - holding( - "native", - network.nativeSymbol, - FAMILIES.evm.nativeDecimals, - nativeRaw, - nativePrice, - ), + holding("native", network.nativeSymbol, FAMILIES.evm.nativeDecimals, nativeRaw, nativePrice), ...tokens.map((token: EffectiveTokenEntry, index) => { const result = balances[index]!; const extra = { id: token.id, name: token.name, source: token.source }; 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 14efc5d12..f6e4a72b3 100644 --- a/ts/src/application/use-cases/evm/chain-service.test.ts +++ b/ts/src/application/use-cases/evm/chain-service.test.ts @@ -74,7 +74,10 @@ describe("EvmChainService.node", () => { * handed over — a named read rather than a listing. */ it("never echoes an endpoint's path or query, which is where API keys live", async () => { - const withKey = { ...net, httpEndpoint: "https://eth.example/v2/SECRET-KEY?apikey=ALSO-SECRET" }; + const withKey = { + ...net, + httpEndpoint: "https://eth.example/v2/SECRET-KEY?apikey=ALSO-SECRET", + }; const out = await service().node(withKey as NetworkDescriptor); expect(out.endpoint).toBe("eth.example"); @@ -91,7 +94,9 @@ describe("EvmChainService.node", () => { }); it("degrades the chain id to null rather than failing the command", async () => { - const out = await service({ chainId: new ChainError("rpc_error", "method not found") }).node(net); + const out = await service({ chainId: new ChainError("rpc_error", "method not found") }).node( + net, + ); expect(out.chainId).toBeNull(); expect(out.headBlock).toMatchObject({ number: 1234567 }); @@ -118,7 +123,9 @@ describe("EvmChainService.node", () => { }); 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); + const out = await service({ finalized: new ChainError("rpc_error", "unknown block") }).node( + net, + ); expect(out.solidBlock).toBeNull(); expect(out.lagBlocks).toBeNull(); @@ -187,8 +194,8 @@ describe("EvmChainService.prices", () => { }); it("honours a network that pins itself to legacy", async () => { - await expect(priced({ baseFeeWei: "100", gasPriceWei: "110" }, "legacy")).resolves.toMatchObject( - { feeModel: "legacy" }, - ); + await expect( + priced({ baseFeeWei: "100", gasPriceWei: "110" }, "legacy"), + ).resolves.toMatchObject({ feeModel: "legacy" }); }); }); 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 f9f91c81c..9879fe61e 100644 --- a/ts/src/application/use-cases/evm/contract-service.test.ts +++ b/ts/src/application/use-cases/evm/contract-service.test.ts @@ -39,7 +39,7 @@ describe("EvmContractService.call", () => { }); it("returns the node's result as raw hex, undecoded", async () => { - const raw = `0x${(123n).toString(16).padStart(64, "0")}`; + const raw = `0x${123n.toString(16).padStart(64, "0")}`; const { svc } = service(raw); await expect(svc.call(net, TOKEN, "decimals()", [])).resolves.toEqual({ @@ -208,7 +208,11 @@ describe("EvmContractService.send — approve", () => { function approveHarness(decimals: number | Error = 6) { const gateway = { getTransactionCount: vi.fn(async () => "7"), - feeData: vi.fn(async () => ({ baseFeeWei: "100", gasPriceWei: "110", suggestedPriorityWei: "10" })), + feeData: vi.fn(async () => ({ + baseFeeWei: "100", + gasPriceWei: "110", + suggestedPriorityWei: "10", + })), estimateGas: vi.fn(async () => "46200"), encodeFunctionCall: vi.fn(() => "0x095ea7b3"), getErc20Metadata: vi.fn(async () => { @@ -286,4 +290,3 @@ describe("EvmContractService.send — approve", () => { expect(out).not.toHaveProperty("allowance"); }); }); - 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 3a4907c1c..9f9b2d6ae 100644 --- a/ts/src/application/use-cases/evm/transaction-service.test.ts +++ b/ts/src/application/use-cases/evm/transaction-service.test.ts @@ -39,14 +39,19 @@ function scope(): TransactionScope { function harness(over: Partial> = {}) { const gateway = { getTransactionCount: vi.fn(async () => (over.nonce as string) ?? "5"), - feeData: vi.fn(async () => (over.fee as object) ?? { - baseFeeWei: "100", - gasPriceWei: "110", - suggestedPriorityWei: "10", - }), + feeData: vi.fn( + async () => + (over.fee as object) ?? { + baseFeeWei: "100", + gasPriceWei: "110", + suggestedPriorityWei: "10", + }, + ), estimateGas: vi.fn(async () => (over.gasEstimate as string) ?? "21000"), encodeErc20Transfer: vi.fn(() => "0xa9059cbb-encoded"), - getErc20Metadata: vi.fn(async () => (over.metadata as object) ?? { symbol: "TKN", decimals: 6 }), + getErc20Metadata: vi.fn( + async () => (over.metadata as object) ?? { symbol: "TKN", decimals: 6 }, + ), }; const built: Record[] = []; const pipeline = { @@ -195,7 +200,16 @@ describe("EvmTransactionService.send — the transaction it hands over", () => { expect(built[0]).not.toHaveProperty("fee"); expect(Object.keys(built[0]!).sort()).toEqual( - ["chainId", "gasLimit", "maxFeePerGas", "maxPriorityFeePerGas", "nonce", "to", "type", "value"].sort(), + [ + "chainId", + "gasLimit", + "maxFeePerGas", + "maxPriorityFeePerGas", + "nonce", + "to", + "type", + "value", + ].sort(), ); }); @@ -306,7 +320,10 @@ describe("EvmTransactionService.send — gas estimation", () => { .send(scope(), SEPOLIA, { to: RECEIVER, amount: "1", dryRun: true } as never) .catch((e) => e); - expect(error).toMatchObject({ code: "invalid_option" }); + // A node-side refusal, so a node-side code and exit 1 — not `invalid_option` / exit 2, which + // would tell a caller its command line was wrong (see evm-gas-estimate.ts). + expect(error).toMatchObject({ code: "rpc_error" }); + expect(error.exitCode()).toBe(1); expect(error.message).toMatch(/insufficient funds/); expect(error.message).toMatch(/--gas-limit/); }); @@ -545,7 +562,10 @@ describe("EvmTransactionService.broadcast --dry-run", () => { it("does not submit the transaction", async () => { const { service, gateway, scope } = dryHarness(); - const out = (await service.broadcast(scope(), SEPOLIA, SIGNED, true)) as Record; + const out = (await service.broadcast(scope(), SEPOLIA, SIGNED, true)) as Record< + string, + unknown + >; expect(gateway.sendRawTransaction).not.toHaveBeenCalled(); expect(out.mode).toBe("dry-run"); @@ -554,7 +574,10 @@ describe("EvmTransactionService.broadcast --dry-run", () => { it("reports the transaction it validated, without asking the node for its identity", async () => { const { service, scope } = dryHarness(); - const out = (await service.broadcast(scope(), SEPOLIA, SIGNED, true)) as Record; + const out = (await service.broadcast(scope(), SEPOLIA, SIGNED, true)) as Record< + string, + unknown + >; expect(out.txId).toBe("0x6bfa290e4749ac903192c155d9b0f534ec9a8c8ab9dbb55bd155a91e3c0d7026"); expect(out.rawAmount).toBe(String(VALUE)); @@ -601,12 +624,10 @@ describe("EvmTransactionService.broadcast --dry-run", () => { getTransactionCount: vi.fn(async () => "2"), getNativeBalance: vi.fn(async () => String(VALUE + 21000n * 2033623170n)), }); - const out = (await service.broadcast( - scope(), - SEPOLIA, - SIGNED_NONCE_5, - true, - )) as Record; + const out = (await service.broadcast(scope(), SEPOLIA, SIGNED_NONCE_5, true)) as Record< + string, + unknown + >; expect(out.mode).toBe("dry-run"); expect(out.checks).toEqual( @@ -623,7 +644,10 @@ describe("EvmTransactionService.broadcast --dry-run", () => { throw new Error("connect ECONNREFUSED"); }), }); - const out = (await service.broadcast(scope(), SEPOLIA, SIGNED, true)) as Record; + const out = (await service.broadcast(scope(), SEPOLIA, SIGNED, true)) as Record< + string, + unknown + >; expect(out.mode).toBe("dry-run"); expect(out.checks).toEqual( @@ -687,7 +711,10 @@ describe("EvmTransactionService.status", () => { }); it("reports a mined but reverted transaction as failed", async () => { - const { service, scope: s } = statusHarness({ hash: HASH }, { success: false, blockNumber: 10 }); + const { service, scope: s } = statusHarness( + { hash: HASH }, + { success: false, blockNumber: 10 }, + ); await expect(service.status(s, SEPOLIA, HASH)).resolves.toMatchObject({ state: "failed", @@ -715,7 +742,11 @@ describe("EvmTransactionService.status", () => { // invites the reader to conclude the transaction never happened, which may be false. // Same field, same arithmetic as TRON's: §6.4 makes it a two-family field, not an EVM one. it("reports head minus the transaction's block as confirmations", async () => { - const { service, scope: s } = statusHarness({ hash: HASH }, { success: true, blockNumber: 5 }, "42"); + const { service, scope: s } = statusHarness( + { hash: HASH }, + { success: true, blockNumber: 5 }, + "42", + ); await expect(service.status(s, SEPOLIA, HASH)).resolves.toMatchObject({ confirmations: 37 }); }); @@ -748,7 +779,11 @@ describe("EvmTransactionService.status", () => { }); describe("EvmTransactionService.info", () => { - function infoHarness(tx: unknown, receipt: unknown = null, meta: unknown = { symbol: "USDT", decimals: 6 }) { + function infoHarness( + tx: unknown, + receipt: unknown = null, + meta: unknown = { symbol: "USDT", decimals: 6 }, + ) { const gateway = { getTransactionByHash: vi.fn(async () => tx), getTransactionReceipt: vi.fn(async () => receipt), @@ -816,7 +851,13 @@ describe("EvmTransactionService.info", () => { // The detail view must not fail because a second, optional read did. it("still answers when the block's timestamp cannot be read", async () => { const gateway = { - getTransactionByHash: async () => ({ hash: HASH, from: OWNER, to: TO, value: "0x0", input: "0x" }), + getTransactionByHash: async () => ({ + hash: HASH, + from: OWNER, + to: TO, + value: "0x0", + input: "0x", + }), getTransactionReceipt: async () => ({ success: true, blockNumber: 5 }), getBlockNumber: async () => "42", getBlock: async () => { @@ -840,7 +881,7 @@ describe("EvmTransactionService.info", () => { // an ERC-20 transfer would name the CONTRACT as the recipient and the amount as zero. it("decodes an ERC-20 transfer to its real recipient and amount", async () => { // transfer(0xbBbB…, 5000000) - const input = `0xa9059cbb${"0".repeat(24)}${TO.slice(2).toLowerCase()}${(5000000n) + const input = `0xa9059cbb${"0".repeat(24)}${TO.slice(2).toLowerCase()}${5000000n .toString(16) .padStart(64, "0")}`; const svc = infoHarness({ hash: HASH, from: OWNER, to: USDT, value: "0x0", input }); @@ -858,7 +899,7 @@ describe("EvmTransactionService.info", () => { }); it("falls back to the base-unit amount when the token's decimals are unreadable", async () => { - const input = `0xa9059cbb${"0".repeat(24)}${TO.slice(2).toLowerCase()}${(5000000n) + const input = `0xa9059cbb${"0".repeat(24)}${TO.slice(2).toLowerCase()}${5000000n .toString(16) .padStart(64, "0")}`; const svc = infoHarness({ hash: HASH, from: OWNER, to: USDT, value: "0x0", input }, null, {}); @@ -867,7 +908,13 @@ describe("EvmTransactionService.info", () => { }); it("leaves calldata it does not recognise alone", async () => { - const svc = infoHarness({ hash: HASH, from: OWNER, to: USDT, value: "0x0", input: "0xdeadbeef" }); + const svc = infoHarness({ + hash: HASH, + from: OWNER, + to: USDT, + value: "0x0", + input: "0xdeadbeef", + }); const out = await svc.info(scope(), SEPOLIA, HASH); // still the contract, because guessing at unknown calldata is exactly what was ruled out diff --git a/ts/src/application/use-cases/evm/transaction-service.ts b/ts/src/application/use-cases/evm/transaction-service.ts index 2d88ece24..787f5ba8a 100644 --- a/ts/src/application/use-cases/evm/transaction-service.ts +++ b/ts/src/application/use-cases/evm/transaction-service.ts @@ -139,7 +139,12 @@ export class EvmTransactionService { } if (contract === undefined) { const native = FAMILIES.evm.nativeDecimals; - return { contract, decimals, symbol, rawAmount: toBaseUnits(input.amount!, native, "amount") }; + return { + contract, + decimals, + symbol, + rawAmount: toBaseUnits(input.amount!, native, "amount"), + }; } if (decimals === undefined) { // `--contract` names a token that need not be in the address book, so the contract itself @@ -173,7 +178,9 @@ export class EvmTransactionService { contract: string, transfer: { to: string; rawAmount: string }, ) { - const meta = await gateway.getErc20Metadata(contract).catch(() => ({}) as { symbol?: string; decimals?: number }); + const meta = await gateway + .getErc20Metadata(contract) + .catch(() => ({}) as { symbol?: string; decimals?: number }); return { to: transfer.to, contract, @@ -199,7 +206,11 @@ export class EvmTransactionService { // An ERC-20 transfer moves no native coin: the recipient and amount live in the calldata, // and the transaction is addressed to the contract. const call = transfer.contract - ? { to: transfer.contract, value: "0", data: gateway.encodeErc20Transfer(to, transfer.rawAmount) } + ? { + to: transfer.contract, + value: "0", + data: gateway.encodeErc20Transfer(to, transfer.rawAmount), + } : { to, value: transfer.rawAmount }; const [nonce, fee] = await Promise.all([ @@ -315,7 +326,11 @@ export class EvmTransactionService { ); return submitted; } - return { ...submitted, stage: confirmed.failed ? ("failed" as const) : ("confirmed" as const), ...confirmed }; + return { + ...submitted, + stage: confirmed.failed ? ("failed" as const) : ("confirmed" as const), + ...confirmed, + }; } /** @@ -339,7 +354,11 @@ export class EvmTransactionService { parsed: Transaction, ) { const checks: Array<{ name: string; status: "ok" | "warning" | "skipped"; detail: string }> = [ - { name: "signature", status: "ok", detail: `recovers to ${parsed.from ?? "an unknown signer"}` }, + { + name: "signature", + status: "ok", + detail: `recovers to ${parsed.from ?? "an unknown signer"}`, + }, ]; // Local, and the cheapest way to catch a transaction signed for another chain: a replay of it @@ -392,7 +411,11 @@ export class EvmTransactionService { `--dry-run: nonce ${parsed.nonce} leaves a gap after ${pending}; this transaction cannot be mined until the missing one is broadcast`, ); } else { - checks.push({ name: "nonce", status: "ok", detail: `${parsed.nonce} is the next to be mined` }); + checks.push({ + name: "nonce", + status: "ok", + detail: `${parsed.nonce} is the next to be mined`, + }); } const required = parsed.value + maxCostWei; @@ -466,9 +489,7 @@ export class EvmTransactionService { state, confirmed, failed, - ...(receipt?.blockNumber === undefined - ? {} - : { blockNumber: receipt.blockNumber as number }), + ...(receipt?.blockNumber === undefined ? {} : { blockNumber: receipt.blockNumber as number }), ...confirmationsOf(head, receipt?.blockNumber), }; } @@ -514,9 +535,7 @@ export class EvmTransactionService { // The transaction's own nonce, flattened out of the node object: §4.3 makes it the entry // point for diagnosing a stuck transaction, and digging it out of a passthrough field is // not what "the detail view" should ask of a reader. - ...(transaction.nonce === undefined - ? {} - : { nonce: quantityToNumber(transaction.nonce) }), + ...(transaction.nonce === undefined ? {} : { nonce: quantityToNumber(transaction.nonce) }), ...(transfer ? await this.#erc20Parties(gateway, checksummed(transaction.to), transfer) : { @@ -585,10 +604,7 @@ function parseEvmTransaction(hex: string): Transaction { * one), and everything else is `contract-call`. Naming the METHOD would mean decoding calldata we * have chosen not to decode. */ -function transactionType( - transaction: Record, - isErc20Transfer: boolean, -): string { +function transactionType(transaction: Record, isErc20Transfer: boolean): string { if (transaction.to === null || transaction.to === undefined) return "contract-creation"; if (isErc20Transfer) return "transfer"; const input = String(transaction.input ?? "0x"); diff --git a/ts/src/application/use-cases/message-service.test.ts b/ts/src/application/use-cases/message-service.test.ts index cd2c955c2..eebbfd349 100644 --- a/ts/src/application/use-cases/message-service.test.ts +++ b/ts/src/application/use-cases/message-service.test.ts @@ -45,9 +45,9 @@ describe("MessageService.sign", () => { throw new WalletError("watch_only_no_signer", "watch-only account cannot sign"); }), }); - await expect(new MessageService(signers).sign(scope, "evm", "acct", "hi")).rejects.toMatchObject( - { code: "watch_only_no_signer" }, - ); + await expect( + new MessageService(signers).sign(scope, "evm", "acct", "hi"), + ).rejects.toMatchObject({ code: "watch_only_no_signer" }); expect(signers.resolve).not.toHaveBeenCalled(); }); }); diff --git a/ts/src/application/use-cases/portfolio-holdings.test.ts b/ts/src/application/use-cases/portfolio-holdings.test.ts index 692ec9fb8..30e54a3c9 100644 --- a/ts/src/application/use-cases/portfolio-holdings.test.ts +++ b/ts/src/application/use-cases/portfolio-holdings.test.ts @@ -31,9 +31,9 @@ describe("holding", () => { }); it("carries extra identity fields through", () => { - expect(holding("erc20", "USDT", 6, "1", null, { id: "0xdAC1", source: "official" })).toMatchObject( - { id: "0xdAC1", source: "official" }, - ); + expect( + holding("erc20", "USDT", 6, "1", null, { id: "0xdAC1", source: "official" }), + ).toMatchObject({ id: "0xdAC1", source: "official" }); }); }); @@ -64,9 +64,7 @@ describe("unavailableHolding", () => { describe("portfolioTotal", () => { it("sums only the rows that have a value", () => { - expect( - portfolioTotal([{ valueUsd: 10 }, { valueUsd: null }, { valueUsd: 2.5 }]), - ).toBe(12.5); + expect(portfolioTotal([{ valueUsd: 10 }, { valueUsd: null }, { valueUsd: 2.5 }])).toBe(12.5); }); it("reports null when nothing could be valued", () => { diff --git a/ts/src/application/use-cases/tron/account-service.ts b/ts/src/application/use-cases/tron/account-service.ts index 8e26e54b9..561201f76 100644 --- a/ts/src/application/use-cases/tron/account-service.ts +++ b/ts/src/application/use-cases/tron/account-service.ts @@ -1,7 +1,4 @@ -import type { - EffectiveTokenEntry, - NetworkDescriptor, -} from "../../../domain/types/index.js"; +import type { EffectiveTokenEntry, NetworkDescriptor } from "../../../domain/types/index.js"; import { ChainError, UsageError } from "../../../domain/errors/index.js"; import { FAMILIES } from "../../../domain/family/index.js"; import { TronAddress, tronHexToBase58 } from "../../../domain/address/index.js"; diff --git a/ts/src/application/use-cases/tron/contract-service.deploy.test.ts b/ts/src/application/use-cases/tron/contract-service.deploy.test.ts index d1cd0eaab..102a9a507 100644 --- a/ts/src/application/use-cases/tron/contract-service.deploy.test.ts +++ b/ts/src/application/use-cases/tron/contract-service.deploy.test.ts @@ -6,7 +6,12 @@ import type { TxPipeline } from "../../services/pipeline/index.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; -const NET = { id: "tron:nile", family: "tron", nativeSymbol: "TRX", chainId: "nile" } as unknown as NetworkDescriptor; +const NET = { + id: "tron:nile", + family: "tron", + nativeSymbol: "TRX", + chainId: "nile", +} as unknown as NetworkDescriptor; const SCOPE = {} as unknown as TransactionScope; const DEPLOY_INPUT = { abi: [], bytecode: "0x00", feeLimit: "1000000000", parameters: [] }; const CONTRACT_HEX = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"; diff --git a/ts/src/application/use-cases/tron/contract-service.fee-limit.test.ts b/ts/src/application/use-cases/tron/contract-service.fee-limit.test.ts index fcca431bb..09219abbc 100644 --- a/ts/src/application/use-cases/tron/contract-service.fee-limit.test.ts +++ b/ts/src/application/use-cases/tron/contract-service.fee-limit.test.ts @@ -8,7 +8,8 @@ import { TronContractService } from "./contract-service.js"; const NETWORK = { id: "tron:nile", - family: "tron", nativeSymbol: "TRX", + family: "tron", + nativeSymbol: "TRX", chainId: "nile", } as unknown as NetworkDescriptor; diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index b138ec3a5..cc59b6fce 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -62,10 +62,11 @@ export class TronContractService { const approval = await approveRows({ method: input.method, params: input.parameters, - metadata: () => gateway.getTokenInfo(input.contract).then((info) => ({ - decimals: info.decimals ?? info.precision, - symbol: typeof info.symbol === "string" ? info.symbol : undefined, - })), + metadata: () => + gateway.getTokenInfo(input.contract).then((info) => ({ + decimals: info.decimals ?? info.precision, + symbol: typeof info.symbol === "string" ? info.symbol : undefined, + })), // A TRON address may arrive as 41-hex from a caller pasting what a node returned. displayAddress: tronHexToBase58, fromBaseUnits, 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 1ec9f6251..f32ed071d 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 @@ -4,7 +4,12 @@ import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js import type { TronGateway, TronTxInfo, TronTx } from "../../ports/chain/tron-gateway.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; -const NET = { id: "tron:nile", family: "tron", nativeSymbol: "TRX", chainId: "nile" } as unknown as NetworkDescriptor; +const NET = { + id: "tron:nile", + family: "tron", + nativeSymbol: "TRX", + chainId: "nile", +} as unknown as NetworkDescriptor; // Minimal fake gateway: status() only touches the two lookup endpoints. function service(opts: { tx?: TronTx | Error; info?: TronTxInfo; head?: number | Error }) { @@ -111,10 +116,12 @@ describe("TronTransactionService.status — confirmations", () => { }); it("omits the field while the transaction has no block", async () => { - const s = await service({ tx: { txID: "abc" } as TronTx, info: {}, head: 78 }).status(NET, "abc"); + const s = await service({ tx: { txID: "abc" } as TronTx, info: {}, head: 78 }).status( + NET, + "abc", + ); expect(s.state).toBe("pending"); expect(s.confirmations).toBeUndefined(); }); }); - diff --git a/ts/src/application/use-cases/tron/transaction-service.ts b/ts/src/application/use-cases/tron/transaction-service.ts index e6e264cf0..cd3dac34e 100644 --- a/ts/src/application/use-cases/tron/transaction-service.ts +++ b/ts/src/application/use-cases/tron/transaction-service.ts @@ -320,8 +320,7 @@ export class TronTransactionService { async function headBlockNumber(gateway: TronGateway): Promise { try { const block = (await gateway.getBlock()) as - | { block_header?: { raw_data?: { number?: number } } } - | undefined; + { block_header?: { raw_data?: { number?: number } } } | undefined; return block?.block_header?.raw_data?.number; } catch { return undefined; @@ -332,4 +331,3 @@ async function headBlockNumber(gateway: TronGateway): Promise s.backup(id, undefined)], - ["keystore backup", (s: WalletService, id: string) => s.backupKeystore(id, undefined, PW, "tron")], + [ + "keystore backup", + (s: WalletService, id: string) => s.backupKeystore(id, undefined, PW, "tron"), + ], ])("%s still fails, but names the file it already committed", (_label, run) => { const h = harness(); const { accountId } = h.keystore.import({ secret: RAW_KEY, type: "privateKey" }); @@ -493,4 +496,3 @@ describe("WalletService.backupKeystore — what the audit log records", () => { expect(records[0]).toMatchObject({ family: "evm" }); }); }); - diff --git a/ts/src/application/use-cases/wallet-service.ts b/ts/src/application/use-cases/wallet-service.ts index 7298499da..51b07ab63 100644 --- a/ts/src/application/use-cases/wallet-service.ts +++ b/ts/src/application/use-cases/wallet-service.ts @@ -9,7 +9,11 @@ import { import { resembledFamily } from "../../domain/contact/index.js"; import { KeystoreV3 } from "../../domain/keystore/index.js"; import { derivePrivAddresses } from "../../domain/wallet/index.js"; -import { TronAddress, evmAddressFromPublicKey, tronHexAddress } from "../../domain/address/index.js"; +import { + TronAddress, + evmAddressFromPublicKey, + tronHexAddress, +} from "../../domain/address/index.js"; import type { Bytes } from "../../domain/types/index.js"; import { ExecutionError, UsageError, WalletError } from "../../domain/errors/index.js"; import type { BackupWriter } from "../ports/backup-writer.js"; @@ -117,8 +121,11 @@ export class WalletService { } const wallet = this.wallets.resolveWallet(id); if (wallet.source.type !== "seed") { + // Its own code, for the same reason `account_not_found` has one: "that reference is not a + // seed wallet" has an obvious next step (`list`, and read the HD group headers), and an + // agent can only take it if the code says so rather than the English. throw new UsageError( - "invalid_value", + "seed_not_found", `${wallet.source.type} wallet is not HD; derive needs a seed wallet`, ); } @@ -267,7 +274,9 @@ export class WalletService { if ( target && r.accountId !== target.accountId && - !CHAIN_FAMILIES.some((f) => target.addresses[f] !== undefined && r.account === target.addresses[f]) + !CHAIN_FAMILIES.some( + (f) => target.addresses[f] !== undefined && r.account === target.addresses[f], + ) ) return false; return true; @@ -392,4 +401,3 @@ function addressRejection(value: string): string { ? `${value} looks like a ${resembles} address but its length or checksum is wrong` : `unrecognised address format: ${value}`; } - diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index c76011121..b3f1a9988 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -146,7 +146,8 @@ export function composeCliRuntime(options: BootstrapOptions) { (isTronNetwork(network) && Boolean(network.tronlinkHttpEndpoint)), ) .filter( - (key) => !key.startsWith("gasfree.") || (isTronNetwork(network) && Boolean(network.gasfree)), + (key) => + !key.startsWith("gasfree.") || (isTronNetwork(network) && Boolean(network.gasfree)), ) .map((key) => ({ key, diff --git a/ts/src/bootstrap/families/evm.ts b/ts/src/bootstrap/families/evm.ts index 43e187a22..a21cfe270 100644 --- a/ts/src/bootstrap/families/evm.ts +++ b/ts/src/bootstrap/families/evm.ts @@ -108,11 +108,7 @@ export function registerEvmChainCommands( deps: EvmChainCommandDependencies, ): void { reg.addChain(messageSignSpec, "evm", messageSignBinding(new MessageService(deps.signers))); - reg.addChain( - typedDataSignSpec, - "evm", - typedDataSignBinding(new TypedDataService(deps.signers)), - ); + reg.addChain(typedDataSignSpec, "evm", typedDataSignBinding(new TypedDataService(deps.signers))); const account = new EvmAccountService(deps.gateways, deps.tokens, deps.prices); reg.addChain(accountBalanceSpec, "evm", accountBalanceBinding(deps.balances)); diff --git a/ts/src/bootstrap/migration-gate.test.ts b/ts/src/bootstrap/migration-gate.test.ts index 7357d0f0a..0456965fa 100644 --- a/ts/src/bootstrap/migration-gate.test.ts +++ b/ts/src/bootstrap/migration-gate.test.ts @@ -28,7 +28,9 @@ describe("runMigrationGate", () => { const wallets = join(seededRoot(), "wallets.json"); const runner = new MigrationRunner(new AtomicFileStore()); - const error = await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { password: async () => null }) + const error = await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { + password: async () => null, + }) .then(() => null) .catch((e: unknown) => e as CliError); @@ -53,7 +55,9 @@ describe("runMigrationGate", () => { const wallets = join(seededRoot(), "wallets.json"); const runner = new MigrationRunner(new AtomicFileStore()); - await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { password: async () => "hunter2" }); + await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { + password: async () => "hunter2", + }); expect(JSON.parse(readFileSync(wallets, "utf8")).sawPassword).toBe("hunter2"); }); @@ -150,10 +154,14 @@ describe("runMigrationGate consent", () => { writeFileSync(wallets, JSON.stringify({ version: 2, wallets: [] })); const confirm = vi.fn(async () => true); - await runMigrationGate(new MigrationRunner(new AtomicFileStore()), [stalePasswordStep(wallets, true)], { - confirm, - password: async () => null, - }); + await runMigrationGate( + new MigrationRunner(new AtomicFileStore()), + [stalePasswordStep(wallets, true)], + { + confirm, + password: async () => null, + }, + ); expect(confirm).not.toHaveBeenCalled(); }); @@ -171,16 +179,19 @@ describe("runMigrationGate consent", () => { password: async () => "hunter2", }); - expect(seen).toEqual([ - { path: wallets, from: 1, to: 2, backup: `${wallets}.v1.bak` }, - ]); + expect(seen).toEqual([{ path: wallets, from: 1, to: 2, backup: `${wallets}.v1.bak` }]); }); }); describe("the upgrade notice", () => { const notice = () => upgradeNotice([ - { path: "/home/u/.wallet-cli/wallets.json", from: 1, to: 2, backup: "/home/u/.wallet-cli/wallets.json.v1.bak" }, + { + path: "/home/u/.wallet-cli/wallets.json", + from: 1, + to: 2, + backup: "/home/u/.wallet-cli/wallets.json.v1.bak", + }, ]).join("\n"); it("names the file and shows the version change", () => { diff --git a/ts/src/bootstrap/migration-steps.test.ts b/ts/src/bootstrap/migration-steps.test.ts index ded79a331..7e47a6ab5 100644 --- a/ts/src/bootstrap/migration-steps.test.ts +++ b/ts/src/bootstrap/migration-steps.test.ts @@ -22,7 +22,9 @@ function realV1Keystore() { const path = join(root, "wallets.json"); const doc = JSON.parse(readFileSync(path, "utf8")); doc.version = 1; - for (const byIndex of Object.values(doc.wallets[0].source.addresses as Record>)) { + for (const byIndex of Object.values( + doc.wallets[0].source.addresses as Record>, + )) { delete byIndex.evm; // wind back to what a pre-EVM keystore actually looks like } writeFileSync(path, JSON.stringify(doc)); diff --git a/ts/src/bootstrap/migration-wiring.test.ts b/ts/src/bootstrap/migration-wiring.test.ts index f9495348f..4ab87f85a 100644 --- a/ts/src/bootstrap/migration-wiring.test.ts +++ b/ts/src/bootstrap/migration-wiring.test.ts @@ -34,7 +34,10 @@ const v1SeedDoc = { activeAccount: "wlt_s.0", labels: {}, wallets: [ - { id: "wlt_s", source: { type: "seed", vaultId: "vlt_1", addresses: { "0": { tron: TRON_ADDR } } } }, + { + id: "wlt_s", + source: { type: "seed", vaultId: "vlt_1", addresses: { "0": { tron: TRON_ADDR } } }, + }, ], }; @@ -42,7 +45,9 @@ const v1PrivateKeyDoc = { version: 1, activeAccount: "wlt_k", labels: {}, - wallets: [{ id: "wlt_k", source: { type: "privateKey", keyId: "key_1", addresses: { tron: TRON_ADDR } } }], + wallets: [ + { id: "wlt_k", source: { type: "privateKey", keyId: "key_1", addresses: { tron: TRON_ADDR } } }, + ], }; /** @@ -141,22 +146,19 @@ describe("the startup migration gate is wired into main()", () => { describe("TRON-only commands on an EVM network", () => { // Reachable for the first time now that EVM networks are builtin: dispatch looks up the // command's family binding and finds none, so it must refuse before touching any RPC. - it.each([["gasfree", "info"], ["stake", "info"], ["permission", "show"]])( - "refuses `%s %s` on evm:1", - async (group, verb) => { - const { code, stdout } = await runIn({ version: 2, activeAccount: null, labels: {}, wallets: [] }, [ - "-o", - "json", - group, - verb, - "--network", - "evm:1", - ]); - - expect(JSON.parse(stdout).error.code).toBe("family_mismatch"); - expect(code).toBe(2); - }, - ); + it.each([ + ["gasfree", "info"], + ["stake", "info"], + ["permission", "show"], + ])("refuses `%s %s` on evm:1", async (group, verb) => { + const { code, stdout } = await runIn( + { version: 2, activeAccount: null, labels: {}, wallets: [] }, + ["-o", "json", group, verb, "--network", "evm:1"], + ); + + expect(JSON.parse(stdout).error.code).toBe("family_mismatch"); + expect(code).toBe(2); + }); }); describe("aliases resolve at selection and nowhere else", () => { diff --git a/ts/src/bootstrap/runner.test.ts b/ts/src/bootstrap/runner.test.ts index 2fa766720..8ccc9ffda 100644 --- a/ts/src/bootstrap/runner.test.ts +++ b/ts/src/bootstrap/runner.test.ts @@ -24,14 +24,11 @@ describe("FAMILY_REGISTRY (composition manifest)", () => { expect([...CHAIN_FAMILIES].filter((f) => !registered.has(f))).toEqual([]); }); - it.each(["signStrategy", "createGateway"] as const)( - "gives every family a %s", - (capability) => { - for (const plugin of FAMILY_REGISTRY) { - expect(plugin[capability], `${plugin.meta.family} is missing ${capability}`).toBeDefined(); - } - }, - ); + it.each(["signStrategy", "createGateway"] as const)("gives every family a %s", (capability) => { + for (const plugin of FAMILY_REGISTRY) { + expect(plugin[capability], `${plugin.meta.family} is missing ${capability}`).toBeDefined(); + } + }); }); describe("hasCommand (bare invocation → root help)", () => { @@ -169,7 +166,11 @@ describe("bootstrap error boundary", () => { // The registry guard above proves a factory EXISTS; this proves the factory, the descriptor and // the gateway registry actually line up — that `--network sepolia` would reach a live client. describe("composition resolves a gateway per family", () => { - const gateways = () => new ChainGatewayRegistry(familyMap((p) => p.createGateway), 5_000); + const gateways = () => + new ChainGatewayRegistry( + familyMap((p) => p.createGateway), + 5_000, + ); const sepolia = { id: "evm:11155111", family: "evm" as const, diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index 3c06ce885..f80e7289a 100644 --- a/ts/src/bootstrap/runner.ts +++ b/ts/src/bootstrap/runner.ts @@ -136,7 +136,10 @@ export function upgradeNotice(pending: PendingUpgrade[]): string[] { "", ...pending.map((f) => ` ${f.path} v${f.from} \u2192 v${f.to}`), "", - ...pending.map((f) => `A copy of the current file is kept at\n ${f.backup}\nand is never removed automatically. The upgrade runs once.`), + ...pending.map( + (f) => + `A copy of the current file is kept at\n ${f.backup}\nand is never removed automatically. The upgrade runs once.`, + ), "", "Release details: https://github.com/tronprotocol/wallet-cli/releases", "", diff --git a/ts/src/domain/address/address.test.ts b/ts/src/domain/address/address.test.ts index 5eb21b62f..4ba64c2c3 100644 --- a/ts/src/domain/address/address.test.ts +++ b/ts/src/domain/address/address.test.ts @@ -129,4 +129,3 @@ describe("AddressCodec.canonical", () => { expect(new TronAddress().canonical(tron)).toBe(tron); }); }); - diff --git a/ts/src/domain/contact/contact.test.ts b/ts/src/domain/contact/contact.test.ts index 52808f319..e548a296f 100644 --- a/ts/src/domain/contact/contact.test.ts +++ b/ts/src/domain/contact/contact.test.ts @@ -115,4 +115,3 @@ describe("createContact — canonical address", () => { ); }); }); - diff --git a/ts/src/domain/errors/codes.ts b/ts/src/domain/errors/codes.ts index 54b90bc60..57d5338f3 100644 --- a/ts/src/domain/errors/codes.ts +++ b/ts/src/domain/errors/codes.ts @@ -28,6 +28,8 @@ export const ERROR_CODES = { unsupported_network: "no network by that id or alias", unsupported_network_capability: "the selected network does not offer what this command needs", missing_wallet_address: "no account is available to act as", + account_not_found: "no local account by that id, label or address", + seed_not_found: "the reference does not name a seed (HD) wallet", account_exists: "an account with that address is already in the keystore", invalid_account: "the account reference is not well-formed", not_exportable: "the account holds no exportable secret (watch-only or Ledger)", @@ -40,6 +42,9 @@ export const ERROR_CODES = { weak_password: "the proposed master password does not meet the strength rule", wrong_keystore_password: "the keystore file's own password was wrong", invalid_keystore: "the file is not a valid V3 keystore", + invalid_mnemonic: "the phrase is not a valid BIP39 mnemonic", + invalid_path: "the value is not a usable BIP44 derivation path", + invalid_private_key: "the private key is not 32 bytes of hex", keystore_not_found: "no keystore file at that path", secret_source_error: "a secret channel (stdin / TTY) could not be read", tty_required: "the operation only accepts input from a terminal, and there is none", diff --git a/ts/src/domain/fees/evm-gas.test.ts b/ts/src/domain/fees/evm-gas.test.ts index a70f979a5..f35cc8dfc 100644 --- a/ts/src/domain/fees/evm-gas.test.ts +++ b/ts/src/domain/fees/evm-gas.test.ts @@ -85,8 +85,9 @@ describe("planEvmFee — EIP-1559", () => { }); it("takes the gas limit override over the estimate", () => { - expect(planEvmFee({ ...base, gasLimit: GAS_LIMIT, overrides: { gasLimit: "90000" } }).gasLimit) - .toBe("90000"); + expect( + planEvmFee({ ...base, gasLimit: GAS_LIMIT, overrides: { gasLimit: "90000" } }).gasLimit, + ).toBe("90000"); }); // BSC: base fee zero means the whole fee is the tip, and the formula produces exactly that. @@ -182,7 +183,12 @@ describe("gweiToWei", () => { * worse. */ describe("planEvmFee — warnings", () => { - const chain = { baseFeeWei: "1000000000", gasPriceWei: "1100000000", suggestedPriorityWei: "1000000", gasLimit: "21000" }; + const chain = { + baseFeeWei: "1000000000", + gasPriceWei: "1100000000", + suggestedPriorityWei: "1000000", + gasLimit: "21000", + }; it("says so when the suggested tip had to be cut down to the fee cap", () => { const plan = planEvmFee({ ...chain, overrides: { maxFeeWei: "500000" } }); @@ -211,4 +217,3 @@ describe("planEvmFee — warnings", () => { expect(planEvmFee(chain).warnings).toBeUndefined(); }); }); - diff --git a/ts/src/domain/migration/wallets-v2.test.ts b/ts/src/domain/migration/wallets-v2.test.ts index 574718dc3..60be33ef3 100644 --- a/ts/src/domain/migration/wallets-v2.test.ts +++ b/ts/src/domain/migration/wallets-v2.test.ts @@ -8,9 +8,18 @@ const seedWallet = { id: "wlt_s", source: { type: "seed", vaultId: "v1", address const pkWallet = { id: "wlt_k", source: { type: "privateKey", keyId: "k1", addresses: {} } }; const ledgerWallet = { id: "wlt_l", - source: { type: "ledger", family: "tron", nativeSymbol: "TRX", path: "m/44'/195'/0'/0/0", address: "T1" }, + source: { + type: "ledger", + family: "tron", + nativeSymbol: "TRX", + path: "m/44'/195'/0'/0/0", + address: "T1", + }, +}; +const watchWallet = { + id: "wlt_w", + source: { type: "watch", family: "tron", nativeSymbol: "TRX", address: "T2" }, }; -const watchWallet = { id: "wlt_w", source: { type: "watch", family: "tron", nativeSymbol: "TRX", address: "T2" } }; describe("walletsNeedPassword", () => { // The migration re-runs the SAME derivation the creation path uses, so any source holding a @@ -71,7 +80,10 @@ describe("migrateWalletsToV2 — privateKey", () => { const doc = { version: 1, wallets: [ - { id: "wlt_k", source: { type: "privateKey", keyId: "k1", addresses: { tron: "T-stale" } } }, + { + id: "wlt_k", + source: { type: "privateKey", keyId: "k1", addresses: { tron: "T-stale" } }, + }, ], }; @@ -86,11 +98,20 @@ describe("migrateWalletsToV2 — privateKey", () => { describe("migrateWalletsToV2 — the untouched sources", () => { it("leaves ledger and watch accounts alone without touching any secret", () => { - const ledger = { type: "ledger", family: "tron", nativeSymbol: "TRX", path: "m/44'/195'/0'/0/0", address: TRON_ADDR }; + const ledger = { + type: "ledger", + family: "tron", + nativeSymbol: "TRX", + path: "m/44'/195'/0'/0/0", + address: TRON_ADDR, + }; const watch = { type: "watch", family: "tron", nativeSymbol: "TRX", address: TRON_ADDR }; const doc = { version: 1, - wallets: [{ id: "wlt_l", source: ledger }, { id: "wlt_w", source: watch }], + wallets: [ + { id: "wlt_l", source: ledger }, + { id: "wlt_w", source: watch }, + ], }; const out = migrateWalletsToV2(doc, noSecrets); diff --git a/ts/src/domain/sources/sources.test.ts b/ts/src/domain/sources/sources.test.ts index 8f7b642a3..9b7d803cd 100644 --- a/ts/src/domain/sources/sources.test.ts +++ b/ts/src/domain/sources/sources.test.ts @@ -39,7 +39,11 @@ describe("source registry", () => { }; const watch: Source = { type: "watch", family: "tron", address: "T..." }; const seed: Source = { type: "seed", vaultId: "vlt_x", addresses: {} }; - const priv: Source = { type: "privateKey", keyId: "key_x", addresses: { tron: "T...", evm: "0x..." } }; + const priv: Source = { + type: "privateKey", + keyId: "key_x", + addresses: { tron: "T...", evm: "0x..." }, + }; expect(sourceFamily(ledger)).toBe("tron"); expect(sourceFamily(watch)).toBe("tron"); expect(sourceFamily(seed)).toBeUndefined(); diff --git a/ts/src/domain/wallet/index.ts b/ts/src/domain/wallet/index.ts index 92ddce7dd..e854d1713 100644 --- a/ts/src/domain/wallet/index.ts +++ b/ts/src/domain/wallet/index.ts @@ -112,7 +112,10 @@ export function enumerateAddresses( ): Array<{ index: number | null; addr: Partial }> { const s = w.source; if (s.type === "seed") { - return accountIndices(s).map((i) => ({ index: i, addr: canonicalAddresses(s.addresses[String(i)]!) })); + return accountIndices(s).map((i) => ({ + index: i, + addr: canonicalAddresses(s.addresses[String(i)]!), + })); } if (s.type === "privateKey") return [{ index: null, addr: canonicalAddresses(s.addresses) }]; return [{ index: null, addr: { [s.family]: addressCodec(s.family).canonical(s.address) } }]; diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index c4e4d5a59..ee65137a2 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -1015,7 +1015,10 @@ describe("golden CLI — startup migration", () => { */ describe("golden CLI — flags spelled like the command path", () => { it.each([ - [["token", "balance", "--token", "USDT", "--contract", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"], "--token"], + [ + ["token", "balance", "--token", "USDT", "--contract", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"], + "--token", + ], [["contract", "deploy", "--contract", "0xabc", "--code", "0x00"], "--contract"], [["import", "watch", "--watch", "x", "--address", "TBy6..."], "--watch"], ])("refuses %o", (tokens, flag) => { @@ -1061,4 +1064,3 @@ describe("golden CLI — networks table", () => { expect(out).not.toContain("https://"); }); }); - diff --git a/ts/test/unknown-command.test.ts b/ts/test/unknown-command.test.ts index 27507afb5..63eea3421 100644 --- a/ts/test/unknown-command.test.ts +++ b/ts/test/unknown-command.test.ts @@ -40,7 +40,12 @@ function run(args: string[]) { * nothing to branch on and a full help page that looks like it answered the question. */ describe("unknown commands fail identically with and without --help", () => { - const unknown: string[][] = [["bogus"], ["tx", "bogus"], ["account", "bogus"], ["contract", "nope"]]; + const unknown: string[][] = [ + ["bogus"], + ["tx", "bogus"], + ["account", "bogus"], + ["contract", "nope"], + ]; it("exits 2 with unknown_command for a bad path (no meta flag)", () => { for (const path of unknown) { @@ -102,7 +107,10 @@ describe("unknown commands fail identically with and without --help", () => { // ...but a prefix that is only a GROUP must not rescue a bad verb: `tx` is not a command, // so `tx bogus` has no resolvable prefix and stays an error. it("does not let a group prefix mask a mistyped verb", () => { - for (const args of [["tx", "bogus", "--help"], ["account", "bogus", "--help"]]) { + for (const args of [ + ["tx", "bogus", "--help"], + ["account", "bogus", "--help"], + ]) { const r = run(args); expect(r.status, args.join(" ")).toBe(2); expect(r.stderr, args.join(" ")).toContain("unknown_command"); From c0bddb6bcd47155c73903c1b52423ab9043e161f Mon Sep 17 00:00:00 2001 From: "Leon.Zhang" Date: Mon, 24 Aug 2026 18:30:01 +0800 Subject: [PATCH 09/23] 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 4f111590d..691c56a95 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 a663fe3f0..e776f48d4 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 b25d4502f..cd1b1269f 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 10/23] 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 c81696933..7845a2052 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 11/23] 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 e776f48d4..529fbbd73 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 cd1b1269f..e6c66c8c1 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 3fd8a11cc..0103f2672 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 12/23] 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 e50ed07d3..4c83c2167 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 bc1620a3d..efcf8980d 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 ee65137a2..05c7c0716 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 13/23] 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 ab2919321..f2951a255 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 787f5ba8a..94fb367fd 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 000000000..ad9ace13d --- /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 14/23] 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 4b077e953..edeaf5839 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 b51a40850..35565d383 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 9879fe61e..a6443dce8 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 f2951a255..c68c593a1 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 15/23] 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 7ee2d44d1..5c1310140 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 68d6435be..ba70ba9de 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 16/23] 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 ba70ba9de..2711634ff 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 17/23] 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 5c1310140..2f425d1ca 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 2711634ff..29d9f886a 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 f6e4a72b3..816da37bc 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 a3a533143..4ae40bfc9 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 a6443dce8..46f48697a 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 9f9b2d6ae..221423103 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 94fb367fd..499b779c8 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 ad9ace13d..97e6c04b4 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 4fe81b792..0981029bc 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 18/23] 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 e8b3a42e7..2bb372d72 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 19/23] 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 05c7c0716..ee65137a2 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)", () => { From 3913909911447bf9e6b100875d3210fc8c8d3cc8 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 27 Aug 2026 00:20:41 +0800 Subject: [PATCH 20/23] chore(ts): drop dead eslint block, redundant #send wrapper, and the gasUsed union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eslint.config.js: the scripts/**/*.mjs block never applied — scripts/** is in the global ignores, so `eslint .` is byte-identical with and without it. - evm.ts: #send was a one-line pass-through to #request with a single caller; its doc comment moves to #request, which is what it describes. - TxInfoView.gasUsed: production only ever writes String(...), so the `number` half was kept alive solely by a stale test fixture. Fixture aligned to the shape production emits; the rendered output is unchanged (formatInt already handles decimal strings). Claude-Session: https://claude.ai/code/session_01Reu4W1oqGVqUWkMpAjR9Xo --- ts/eslint.config.js | 15 --------------- .../inbound/cli/render/family-render.test.ts | 2 +- ts/src/adapters/outbound/chain/evm/evm.ts | 6 +----- ts/src/domain/types/tx.ts | 2 +- 4 files changed, 3 insertions(+), 22 deletions(-) diff --git a/ts/eslint.config.js b/ts/eslint.config.js index 7845a2052..c81696933 100644 --- a/ts/eslint.config.js +++ b/ts/eslint.config.js @@ -39,21 +39,6 @@ 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, ); diff --git a/ts/src/adapters/inbound/cli/render/family-render.test.ts b/ts/src/adapters/inbound/cli/render/family-render.test.ts index 858416931..9bce42cf2 100644 --- a/ts/src/adapters/inbound/cli/render/family-render.test.ts +++ b/ts/src/adapters/inbound/cli/render/family-render.test.ts @@ -47,7 +47,7 @@ describe("FAMILY_RENDER evm", () => { txid: "0xabc", transaction: {}, status: "confirmed", - gasUsed: 21_000, + gasUsed: "21000", feeWei: "441000000000000", }, "ETH", diff --git a/ts/src/adapters/outbound/chain/evm/evm.ts b/ts/src/adapters/outbound/chain/evm/evm.ts index 29d9f886a..091ecf58d 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.ts @@ -142,7 +142,7 @@ export class EvmRpcClient implements EvmGateway { */ async sendRawTransaction(raw: string): Promise<{ hash?: string; alreadyKnown?: boolean }> { assertBroadcastAllowed(); - const body = await this.#send("eth_sendRawTransaction", [raw]); + const body = await this.#request("eth_sendRawTransaction", [raw]); if (body.error) { const message = body.error.message ?? ""; if (isAlreadyKnown(message)) return { alreadyKnown: true }; @@ -202,10 +202,6 @@ 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 }; diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index 0981029bc..b6d78edfa 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 | string; // evm execution resource + gasUsed?: 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 7a33dcd52ce853af0d7fde7e303eb1b65cd4ce5d Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 27 Aug 2026 15:22:35 +0800 Subject: [PATCH 21/23] feat(ts): route every chain HTTP call through one transport, add per-network API keys Consolidates the TRON and EVM client adapters onto a single HttpTransport (adapters/outbound/http) and builds the per-network credential pair on top of it. Transport - TronRpcClient and EvmRpcClient accept a NetworkDescriptor or an endpoint config; raw fetch calls, URL joining and timeout wiring now live in one place. - tronweb is handed explicit HttpProviders so it carries the same headers. - TronGridHistoryReader goes through the transport; its failures no longer echo the underlying message. Per-network API key - networks..apiKeyHeader / .apiKey are readable and writable via `config`. Both must be set before a header is sent, and the header name is validated as an RFC 9110 token so a hand-edited config.yaml cannot smuggle in a second header. - apiKey is write-only on every read surface and forces the 0600 check on config.yaml, which now looks inside `networks` rather than only at top level. - Credentialed requests refuse to follow redirects on both the fetch and the tronweb path, so a redirecting endpoint cannot collect the key. Config surface - A network renders as its configurable fields rather than a bare endpoint, so a new field shows up in every view at once. Listings still trim the endpoint to its host; naming one network reveals it in full. - `config` prints the document as a tree. Nested keys carry no trailing colon -- the ids at that level contain colons themselves. Fixes found while re-testing the above on Nile and Sepolia - EVM transport timeouts report `timeout`, not `rpc_error`, matching TRON and the documented meaning of the code. - --args/--verb/--group/--source are rejected as user-typed flags. They are yargs plumbing keys that must stay in the per-command allowlist, so they worked as undocumented aliases: `contract call --args ` bound into a slot the command has not got and answered 0, a wrong result that reads like a real one. The check runs on the raw tokens, before yargs folds the two sources together, and a meta-test pins that no command declares a field by those names. Startup migration - The gate runs ahead of every surface and stops after upgrading rather than running the command that triggered it. Verified on Nile and Sepolia: reads across every transport path, TRX/TRC20 and ETH transfers confirmed on-chain, the credential observed on the wire on all three paths, and the redirect target receiving nothing once one is configured. Claude-Session: https://claude.ai/code/session_01JSxAgrttq54UacDxykm7gy --- ts/docs/machine-interface.md | 22 + ...wallet-cli-architecture-source-of-truth.md | 808 ------------------ .../adapters/inbound/cli/commands/config.ts | 7 +- .../cli/commands/contract.artifact.test.ts | 2 +- .../cli/commands/contract.deploy.test.ts | 2 +- .../adapters/inbound/cli/commands/contract.ts | 6 +- .../cli/commands/text-formatters.test.ts | 43 +- ts/src/adapters/inbound/cli/commands/tx.ts | 8 +- .../cli/help/group-family-tags.test.ts | 6 +- ts/src/adapters/inbound/cli/help/help.test.ts | 24 +- ts/src/adapters/inbound/cli/help/index.ts | 22 +- ts/src/adapters/inbound/cli/render/misc.ts | 52 +- ts/src/adapters/inbound/cli/shell/index.ts | 33 +- .../inbound/cli/shell/tail-flags.test.ts | 86 ++ .../adapters/outbound/chain/evm/evm.test.ts | 55 ++ ts/src/adapters/outbound/chain/evm/evm.ts | 52 +- .../outbound/chain/tron/history-reader.ts | 19 +- .../outbound/chain/tron/tron.redirect.test.ts | 92 ++ ts/src/adapters/outbound/chain/tron/tron.ts | 198 ++--- .../adapters/outbound/config/config.test.ts | 33 + ts/src/adapters/outbound/config/index.ts | 14 +- ts/src/adapters/outbound/http/index.test.ts | 173 ++++ ts/src/adapters/outbound/http/index.ts | 115 +++ ts/src/adapters/outbound/http/wiring.test.ts | 156 ++++ .../use-cases/config-service.test.ts | 179 +++- .../application/use-cases/config-service.ts | 144 +++- ts/src/bootstrap/families/evm.test.ts | 2 +- ts/src/bootstrap/families/evm.ts | 2 +- ts/src/bootstrap/families/tron.ts | 2 +- ts/src/bootstrap/migration-gate.test.ts | 160 +++- ts/src/bootstrap/migration-gate.ts | 45 +- ts/src/bootstrap/migration-wiring.test.ts | 84 +- ts/src/bootstrap/runner.ts | 145 +++- ts/src/domain/types/network.ts | 10 + ts/test/golden.test.ts | 62 +- 35 files changed, 1755 insertions(+), 1108 deletions(-) delete mode 100644 ts/docs/typescript-wallet-cli-architecture-source-of-truth.md create mode 100644 ts/src/adapters/inbound/cli/shell/tail-flags.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/tron.redirect.test.ts create mode 100644 ts/src/adapters/outbound/http/index.test.ts create mode 100644 ts/src/adapters/outbound/http/index.ts create mode 100644 ts/src/adapters/outbound/http/wiring.test.ts diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index 2d9058cf4..378c6172b 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -12,6 +12,28 @@ wallet-cli -o json [--network ] [--timeout ] [--account ◀ render output"] - end - - subgraph CORE["Core (independently testable)"] - direction TB - APP["🎯 application
use cases · ports"] - DOM["💎 domain
pure rules"] - APP --> DOM - end - - subgraph OUTB["📤 outbound"] - OUT["Keystore · TronWeb
Ledger · CoinGecko"] - end - - USER ==>|drives| IN - IN ==>|calls| APP - APP ==>|"calls port"| OUT - OUT -.->|"implements port (dependency points inward)"| APP - IN ==>|renders result| USER - - classDef ext fill:#e8e8e8,stroke:#888,color:#333 - classDef inb fill:#d4edff,stroke:#1f78b4,color:#0b3d66 - classDef core fill:#d5f5e3,stroke:#27ae60,color:#145a32 - classDef outb fill:#fadbd8,stroke:#c0392b,color:#641e16 - class USER ext - class IN inb - class APP,DOM core - class OUT outb -``` - -> Status: the single current architecture contract -> Applies to version: `wallet-cli 0.1.x` -> Runtime: Node.js 20+, ESM, TypeScript -> Current chain support: TRON (mainnet, Nile, Shasta) - -This document fully defines the system boundaries, dependency direction, composition, command routing, application ports, wallet and transaction flows, persistence, output, and extension rules of the TypeScript Wallet CLI. The document itself is the single specification for architecture and behavior; it does not require any other design document to be understood. - -If the implementation and this document disagree, the change must fix one side or the other — the document must not describe an abstraction that does not exist for any length of time. - ---- - -## 1. System Goals and Boundaries - -### 1.1 Goals - -1. Provide humans and agents with the same stable CLI, JSON envelope, command id, and exit codes. -2. Keep Domain and Application as the core; isolate external I/O behind ports and adapters. -3. inbound CLI and outbound infrastructure are peers, assembled only in Bootstrap. -4. Keep chain-family differences inside the family plugin, family use cases, gateway, and signing strategy. -5. Encrypt private keys, mnemonics, and BIP39 passphrases at rest; Ledger/watch-only hold no secrets. -6. Each execution produces exactly one terminal result on stdout; progress and diagnostics go to stderr. -7. A single Zod schema drives validation, yargs arity, help, and JSON Schema. -8. Use dependency-cruiser, typecheck, contract tests, unit tests, and build to prevent architectural and behavioral regression. - -### 1.2 Current Boundaries - -- `ChainFamily` is `tron | evm`. Each family carries its own BIP44 template via `FamilyMeta.indexAt` — TRON hangs the account number at the `account` level, EVM at `address_index` — so the coin type alone does not determine a path. `FAMILIES` is a mapped type (`{ [F in ChainFamily]: FamilyMeta & { family: F } }`) so each entry keeps its literal family and `FamilyPlugin` still binds. -- Ledger currently implements only the TRON app. -- Network transport is TRON FullNode HTTP / TronWeb; `httpEndpoint` is not an Ethereum JSON-RPC or gRPC endpoint. -- `create`, the various `import` commands, `delete`, and `backup` may be interactive in a controlled way; other commands fail fast when arguments are missing. -- Secrets are not accepted from argv plaintext or ordinary files; only a dedicated stdin channel or hidden TTY prompt is allowed. - ---- - -## 2. Architecture and Dependency Rules - -### 2.1 The Four Architectural Areas - -```mermaid -flowchart LR - BOOTSTRAP[bootstrap
process lifecycle and assembly] --> INBOUND[adapters/inbound
drives Application] - BOOTSTRAP --> OUTBOUND[adapters/outbound
implements Application ports] - INBOUND --> APPLICATION[application
use cases, orchestration, ports] - OUTBOUND --> APPLICATION - APPLICATION --> DOMAIN[domain
pure rules and values] -``` - -| Area | May depend on | Must not depend on | -| --- | --- | --- | -| `domain` | Node / third-party pure libraries, same area | `application`, `adapters`, `bootstrap` | -| `application` | `domain`, application-internal contracts/ports | `adapters`, `bootstrap` | -| `adapters/inbound` | `application`, `domain`, inbound-internal | `adapters/outbound`, `bootstrap` | -| `adapters/outbound` | `application` ports, `domain`, outbound-internal | `adapters/inbound`, `bootstrap` | -| `bootstrap` | all areas | none; but it only does assembly and process lifecycle | - -These are conceptual dependency rules. Even when a type-only import produces no runtime edge, it must still follow the same direction. Circular dependencies are always forbidden. - -The diagram below is a detailed view (dependency view) of the same rules. **Solid lines are the runtime call direction (left to right); dashed lines are the compile-time dependency/implements direction (always pointing inward).** Their opposite directions are exactly what dependency inversion looks like in concrete form: application calls outbound (rightward), but outbound depends on application's port (leftward). This diagram depicts responsibilities and dependencies, not the order of process execution; the real runtime entry/exit is wrapped by `bootstrap/runner.ts` (see §3.1). - -```mermaid -flowchart LR - USER([User / Agent]):::ext - - subgraph INB["inbound · CLI (driving side)"] - direction TB - IN_PARSE["Controller
shell · arity · Zod schemas"] - IN_CMD["commands
argv to use case"] - IN_OUT["Presenter
envelope · render · stream"] - IN_PARSE --> IN_CMD --> IN_OUT - end - - subgraph CORE["Core (independently testable)"] - direction TB - subgraph APP["application"] - direction TB - UC["use-cases
TronTransactionService · WalletService"] - SVC["services
TxPipeline · SignerResolver · Target"] - CON["contracts
ExecutionPolicy · TransactionScope"] - PORT{{"ports (owned by application)
WalletRepository · TronGateway · LedgerDevice"}} - UC --> SVC - UC -.in/out.-> CON - UC --> PORT - SVC --> PORT - end - subgraph DOM["domain (pure rules · zero I/O)"] - DM["address · amounts · derivation
wallet · family · errors"] - end - APP --> DOM - end - - subgraph OUTB["outbound (implements ports)"] - direction TB - O_KS["keystore to WalletRepository"] - O_TRON["chain/tron to TronGateway"] - O_PRICE["price to PriceProvider"] - O_LED["ledger to LedgerDevice"] - O_CFG["config · persistence · tokenbook"] - end - - subgraph EXT["Frameworks & Drivers"] - E["TRON nodes · filesystem
Ledger · CoinGecko"] - end - - %% call direction (runtime, solid) — L→R spine - USER ==>|drives| IN_PARSE - IN_CMD ==>|calls| UC - PORT ==>|resolved to adapter| OUTB - OUTB ==>|I/O| EXT - IN_OUT -. result .-> USER - - %% dependency / implements (compile-time, dashed, points inward) - O_KS -.->|implements| PORT - O_TRON -.->|implements| PORT - O_PRICE -.->|implements| PORT - O_LED -.->|implements| PORT - O_CFG -.->|implements| PORT - - %% bootstrap: bottom rail, injects upward - subgraph BOOT["bootstrap (single composition root)"] - direction LR - COMP["composition.ts
new + inject"] - PLUG["family-registry
FamilyPlugin (TRON · EVM later)"] - end - PLUG -.-> COMP - COMP -.->|inject| UC - COMP -.->|inject| O_KS - COMP -.->|inject| O_TRON - COMP -.->|wire| IN_CMD - - classDef ext fill:#e8e8e8,stroke:#888,color:#333 - classDef boot fill:#fff3cd,stroke:#d4a017,color:#5c4500 - classDef inb fill:#d4edff,stroke:#1f78b4,color:#0b3d66 - classDef app fill:#d5f5e3,stroke:#27ae60,color:#145a32 - classDef dom fill:#fdebd0,stroke:#e67e22,color:#7e3f0b - classDef outb fill:#fadbd8,stroke:#c0392b,color:#641e16 - - class USER,E ext - class COMP,PLUG boot - class IN_PARSE,IN_CMD,IN_OUT inb - class UC,SVC,CON,PORT app - class DM dom - class O_KS,O_TRON,O_PRICE,O_LED,O_CFG outb -``` - -### 2.2 Why inbound and outbound do not depend on each other - -A CLI command should not know about Keystore, TronWeb, CoinGecko, or the Ledger transport; it only calls a use case. An outbound adapter likewise should not know about Zod, yargs, the CLI envelope, or the renderer; it only implements an application port. The two are injected into the same object graph only in `bootstrap/composition.ts`. - -### 2.3 Actual Directory Responsibilities - -```text -src/ -├── index.ts # process entry -├── bootstrap/ -│ ├── argv.ts # global/secret flags scan before yargs -│ ├── runner.ts # invocation lifecycle + terminal error funnel -│ ├── composition.ts # the single general composition root -│ ├── family-registry.ts # enabled family plugins and familyMap -│ └── families/ -│ ├── types.ts # FamilyPlugin contract -│ └── tron.ts # TRON gateway/use cases/commands assembly -├── domain/ -│ ├── address/ amounts/ derivation/# pure value rules -│ ├── errors/ # typed errors + exit semantics -│ ├── family/ resources/ sources/ # exhaustive facts registries -│ ├── types/ # domain data shapes -│ └── wallet/ # account refs, address projections, vault codec -├── application/ -│ ├── contracts/ # execution policy/scope/progress -│ ├── ports/ # required external capabilities -│ ├── services/ # target/capability/signer/pipeline/confirmation -│ └── use-cases/ # wallet/config/message/TRON workflows -└── adapters/ - ├── inbound/cli/ - │ ├── commands/ # schema + use-case translation - │ ├── contracts/ context/ # CLI-only command/runtime contracts - │ ├── globals/ arity/ schemas/ # flag single source + Zod projections - │ ├── shell/ registry/ help/ # routing and discovery - │ ├── input/ # secret + prompt - │ └── output/ render/ stream/ # terminal presentation - └── outbound/ - ├── chain/tron/ # gateway, history, signing strategy - ├── config/ keystore/ # config and wallet persistence - ├── ledger/ # device adapter - ├── persistence/ # crypto, atomic FS, backup writer - ├── tokenbook/ # TokenRepository - └── price/ # PriceProvider -``` - ---- - -## 3. Startup, Composition, and Process Lifecycle - -### 3.1 Startup Flow - -```mermaid -flowchart LR - ARGV[process.argv] --> PRE[parseGlobals] - PRE --> COMPOSE[composeCliRuntime] - COMPOSE --> META{help/version/schema
or bare invocation?} - META -->|yes| HELP[HelpService] - META -->|no| GATE[migration gate] - GATE --> SHELL[buildCli + parseAsync] - HELP --> FUNNEL[Runner terminal boundary] - SHELL --> FUNNEL - FUNNEL --> CLOSE[close Prompter] -``` - -1. `src/index.ts` only calls `main(process.argv)` and sets `process.exitCode`; it does not call `process.exit()`. -2. `bootstrap/argv.ts` scans globals before yargs, because the output mode and secret source must be decided first. -3. `composeCliRuntime()` loads config and builds streams, formatter, outbound adapters, application services/use cases, the command registry, and the target/capability gates. -4. `FAMILY_REGISTRY` assembles each family's metadata, sign strategy, gateway factory, and command module into a plugin. -5. The family plugin builds family-specific use cases and then injects them into the inbound `ChainModule`. -6. Command-backed capabilities are derived from the registry's `capability` field and merged with network traits. -7. A meta request short-circuits before building the yargs execution, but uses the same streams and error-output rules. -8. The Runner catches all typed/unknown errors, normalizes them, emits output, decides the exit code, and finally closes the `/dev/tty` handle. - -### 3.2 The `FamilyPlugin` Contract - -```ts -interface FamilyPlugin { - readonly meta: FamilyMeta & { family: F } - readonly signStrategy: SignStrategy - createGateway(network: NetworkDescriptor): ChainGatewayMap[F] - createModule(deps: FamilyApplicationDependencies): ChainModule -} -``` - -### 3.3 The Migration Gate - -Persisted state is migrated **eagerly and completely at startup, or not at all** (ADR-0008). The -gate sits after the help/version short-circuit — so `--help` stays reachable on a stale or -unmigratable keystore — and before any command dispatches. - -- A registry of steps, one per versioned file, each declaring `currentVersion`, whether migrating - a given document needs the master password, and how to migrate it. `contacts.json` and - `tokens.json` need none: the first is already family-keyed at rest, the second is keyed by - network id, so EVM only adds keys to both. -- Everything stale is applied in **one `writeJsonAll` transaction**, under the same advisory lock - every other mutator takes, so a concurrent process cannot have its work overwritten by a stale - read. A pre-migration copy is kept as `.v.bak` and never pruned: the transaction is - crash-safe but not *change*-safe, and a migration that succeeds while being wrong would - otherwise destroy the only copy of the prior state. -- The password is demanded only if some pending migration needs one, which is - `SOURCE_KINDS[type].hasSecret` — so a keystore of only `ledger` / `watch` accounts upgrades - silently. Failure is `migration_required` (exit 2); `--password-stdin` is honoured, so a - pipeline can self-heal without a human. -- The staleness test is `version < CURRENT`, not `!==`: a file written by a newer binary is left - alone rather than migrated downward. -- The absent-file default must synthesise `CURRENT_VERSION`, not a literal — that default is - persisted on first write, so a literal would stamp every new keystore stale. - -Eagerness is what lets `ChainAddresses` stay a **total** `Record`. A lazy or -partial backfill would force it to `Partial`, making a missing address reachable at every read -site and letting `list` output drift between runs. - -`bootstrap/families/tron.ts` is TRON's concrete composition: it builds the `TronRpcClient`, the TronGrid history reader, the TRON use cases, and registers each command via `registerTronChainCommands`, which `addChain`s the neutral `ChainSpec` for each command together with its TRON `FamilyBinding`. Application and adapters must not import the family registry in reverse. - ---- - -## 4. Command Contract and Dispatch - -### 4.1 `CommandDefinition` - -`CommandDefinition` is the contract of the inbound CLI adapter, not a Domain/Application model. - -| Field | Contract | -| --- | --- | -| `path` | Neutral commands use the full path; chain commands use a cross-family logical path. | -| `family` | Omitted for neutral commands; when present, the resolved network selects the family implementation. | -| `stdin` | A dedicated **command-scoped** stdin channel, one of `tx` or `message` (signed-tx JSON / message to sign). This field does not cover the master password, which is fed by the **global** `--password-stdin` (see the CLI surface section). Wallet secrets (`mnemonic`, `privateKey`) and the master-password *change* are TTY-only and have no stdin flag — see `secretsTtyOnly`. | -| `network` | `none` (never touches a chain) or `optional` (resolves `--network`, else `config.defaultNetwork`). There is no `required`: the default-network fallback always applies, so nothing can demand an explicit `--network`. **A NEUTRAL command may also be `optional`** — `list`, `current` and `backup` are, because the selected network acts as a DISPLAY SELECTOR (which family's address to show, which family's key to export), not as a target to contact. That does not make them chain commands: they are still dispatched by path, not by family. | -| `wallet` | `none` or `optional`. `optional` means the command has an implicit ACTIVE ACCOUNT that `--account` can override; it drives up-front account resolution, the `--account` help line, and the Requires block. It deliberately does NOT gate any account-vs-network compatibility check — see §6.3. | -| `auth` | An unlock declaration for help/catalog; actual software signing uses lazy decrypt. | -| `broadcasts` | Controls whether help reveals `--wait`. | -| `passwordMode` | `establish` or `verify`, controls interactive master-password priming. | -| `interactive` | Only commands that explicitly opt in may open a TTY prompt. | -| `secretsTtyOnly` | The command's secrets (import mnemonic/private-key, `change-password`) are TTY-only; no `--*-stdin` source exists, and dispatch fails fast when not on a TTY. | -| `capability` | A per-network capability that must pass before execution. | -| `fields` / `input` | Zod field metadata and the complete validation schema. | -| `run` | Translates CLI input/context into a use-case call and returns structured data. | -| `formatText` | Optional text renderer; JSON does not use it. | - -The stable command id is derived from metadata as `path.join(".")` for every command — a neutral command is e.g. `import.mnemonic`, and a chain command is e.g. `tx.send` (no family prefix). The family is not encoded in the id; it travels in the envelope's `chain` view (`chain.family`), so the id matches what the user types and is redundancy-free. - -### 4.2 The Two Command Classes and Routing - -A chain command is one `ChainCommandDefinition` — a service-free `ChainSpec` plus a `families` table of per-family `FamilyBinding`s (`run` + optional `fields`/`refine` delta). The registry keys it by logical path; dispatch resolves the network, then selects the binding by `network.family`. When the resolved network's family has no binding for that command, dispatch returns `family_mismatch` (renamed from `network_family_mismatch`; the code also covers an account, or a raw transaction, disagreeing with the target network). The merged input schema is `baseFields` plus each binding's `fields`, and validation composes `baseRefine` then each binding's `refine`. `isChainCommand` (presence of `families`) is the discriminator between the two command kinds. - -```mermaid -flowchart LR - PATH[Parsed path] --> KIND{neutral exact match?} - KIND -->|yes| NEUTRAL[resolveNeutral] - KIND -->|no| CHAIN[resolveChain by logical path] - CHAIN --> NET[resolve explicit/default network] - NET --> BIND[select families network.family binding] - NEUTRAL --> EXEC[executeCommand] - BIND --> XEXEC[executeChainCommand: merge base+delta, run, spec.formatText] -``` - -- `tron` is not a public prefix for ordinary execution commands; `--network` decides the family. -- Help/JSON Schema may accept a leading family token (e.g. `tron block --json-schema`) as an addressing convenience, but the emitted id stays unqualified and the catalog entry lists `families: [...]`. -- An unknown top-level/subcommand/flag must return `unknown_command` or `invalid_option`; yargs must not silently succeed. - -### 4.3 The Fixed Dispatch Order - -```mermaid -flowchart LR - ROUTE[Route] --> FLAGS[Reject unknown flags] - FLAGS --> TARGET[Resolve target] - TARGET --> CAP[Capability gate] - CAP --> PASSWORD[Optional password prime] - PASSWORD --> GAP[Optional TTY gap-fill] - GAP --> ZOD[Zod parse] - ZOD --> CTX[Build ExecutionContext] - CTX --> ACCOUNT[Resolve account if wallet-bound] - ACCOUNT --> RUN[Command → use case] - RUN --> FORMAT[Text or JSON] - FORMAT --> RESULT[Stream result exactly once] -``` - -`ExecutionContext` is the CLI context; an Application workflow receives only the narrower `ExecutionPolicy`, `ExecutionSelection`, `AccountScope`, or `TransactionScope`, and does not depend on the full picture of CLI streams/config/envelope. - ---- - -## 5. The Public Command Surface - -```text -wallet-cli -├── create -├── import mnemonic | private-key | ledger | watch -├── list | use | current | rename | derive | backup | delete -├── change-password -├── config | networks -├── account balance | info | history | portfolio -├── token balance | info | add | list | remove -├── tx send | broadcast | status | info -├── contract call | send | deploy | info | clear-abi | set-origin-energy-limit | set-user-resource-percent | create2 -├── proposal list | show | create | approve | delete -├── witness create | update | set-brokerage -├── stake freeze | unfreeze | withdraw | cancel-unfreeze | delegate | undelegate | info | delegated -├── vote cast | list | status -├── reward balance | withdraw -├── chain params | prices | node -├── message sign -└── block [number] -``` - -`create`, `import`, `list`, `use`, `current`, `rename`, `derive`, `backup`, `delete`, `change-password`, `config`, and `networks` are neutral. `change-password` re-encrypts every software keystore under a new master password; both the old and new passwords are entered interactively (TTY-only). `stake info`/`delegated`, `vote status`, `reward balance`, and `chain *` are read-only chain queries; `vote cast` and `reward withdraw` are transaction-creating. - -Neutral commands do not touch a chain. Chain commands are currently all provided by the TRON plugin. All transaction-creating commands jointly support: - -- `--dry-run`: build + estimate, no decrypt, no sign, no broadcast. -- `--sign-only`: build + estimate + sign, returns a signed transaction. -- Governance writes also support `--build-only` (no signer resolution), `--permission-id`, and an optional `--expiration` extension in build/sign-only modes. -- No mode flag: sign + broadcast. -- `--wait`: wait for confirmation only after broadcast. - -### 5.1 Global Flags - -| Flag | Runtime semantics | -| --- | --- | -| `--output` / `-o` | `text` or `json`; defaults from config. | -| `--network` | Canonical network id; a chain command falls back to `defaultNetwork` when omitted. | -| `--account` | Account ref/label/address; overrides only for this execution. | -| `--timeout` | Timeout for a single RPC/device operation. | -| `--verbose` / `-v` | Additional diagnostics. | -| `--wait` | Poll for confirmation after broadcast. | -| `--wait-timeout` | Upper bound for confirmation polling; defaults to `config.waitTimeoutMs` (built-in 60000 ms). | -| `--password-stdin` | Read the master password from fd 0. | -| `--help` / `--version` / `--json-schema` | Meta requests. | - -The single registration point for global flags is `adapters/inbound/cli/globals/GLOBAL_FLAG_SPECS`; the argv scan, yargs options, and help/catalog are all projected from it. - ---- - -## 6. Domain Model - -### 6.1 Wallet, Account, and Source - -```mermaid -flowchart LR - WALLET[Wallet wlt_x] --> SOURCE[One Source] - SOURCE -->|seed| HD[wlt_x.0 / .1 / ...] - SOURCE -->|privateKey| PK[one account wlt_x] - SOURCE -->|ledger| LEDGER[one single-family account] - SOURCE -->|watch| WATCH[one single-family account] -``` - -```ts -type Source = - | { type: "seed"; vaultId: string; addresses: Record } - | { type: "privateKey"; keyId: string; addresses: ChainAddresses } - | { type: "ledger"; family: ChainFamily; path: string; address: string } - | { type: "watch"; family: ChainFamily; address: string } -``` - -| Source | HD | Local secret | Family scope | Signing | -| --- | --- | --- | --- | --- | -| seed | yes | encrypted entropy/passphrase | all enabled families | software | -| privateKey | no | encrypted raw key | all enabled families | software | -| ledger | no | none | single family/path | device | -| watch | no | none | single family | forbidden | - -The account is the unit of selection and operation. `--account` accepts a canonical ref, a unique label, or a unique address; for a multi-account seed, when only a wallet ref is given, the index must not be guessed. - -### 6.2 Derivation and Addresses - -- BIP39 English wordlist; `create` generates 128-bit entropy (12 words). -- HD path follows each family's own ecosystem template, which differ in SHAPE and not only in coin - type: TRON hangs the account number at the account level (`m/44'/195'/'/0/0`), EVM at the - address_index level (`m/44'/60'/0'/0/`). `FamilyMeta.indexAt` carries which, so the coin type - alone never determines a path. -- secp256k1 derives the address from an uncompressed 65-byte public key. -- The seed vault stores encrypted entropy and an optional BIP39 passphrase, not the mnemonic string directly. -- The public address cache lives in wallet metadata; read/build/estimate do not require decrypting secrets. -- The Domain `family`, `sources`, and `resources` registries must be exhaustively keyed; adding a union member forces the type system to fill in the related facts. - -### 6.3 Active Account - -- A successful `create`, `import`, or `derive` persistently makes its target account active. This - also applies when `import` or `derive` resolves to an existing account rather than creating a new - one. -- `use` persistently changes `activeAccount`; `--account` does not persist. -- When the active account is deleted, the first remaining account is chosen; if none, it is set to `null`. -- `current` returns only the persistent active account. - -**An account is judged against a network where an ADDRESS IS DEMANDED, never where a network is -resolved.** `ExecutionScope.resolveAddress(family)` (and `SignerResolver` on the signing path) -raises `family_mismatch` when the account has no address in that family, naming the account's own -chain and how to switch. `TargetResolver` deliberately does not perform this check. - -The check used to live in `TargetResolver`, firing the moment a network was resolved. Two things -were wrong with that. It prevented nothing — without it, any command that truly needs the address -fails at `resolveAddress`, still before any RPC — so it was only ever a better error, earlier. And -it fired at the wrong moment: a command may resolve a network without ever demanding one family's -address (`current` resolves one to choose which family's receive QR to draw), and such a command -was refused for a condition that did not apply to it. Placing the check at the point of demand is -also self-maintaining: a new command needs no policy flag to opt in or out, because asking for an -address is what triggers it. - ---- - -## 7. Application: Use Cases, Services, and Ports - -### 7.1 Ports - -Application defines capabilities, not concrete technologies: - -| Port | Purpose | Current adapter | -| --- | --- | --- | -| `WalletRepository` / `AccountStore` | wallet/account query, mutation, decrypt | `Keystore` | -| `BackupWriter` | safely write a plaintext backup | `SecureBackupWriter` | -| `ConfigDocumentRepository` | atomic config document update | `YamlConfigDocument` | -| `NetworkRegistry` | canonical network id/default resolution | outbound config registry | -| `LedgerDevice` | address, tx/message signing, app config | `Ledger` | -| `ChainGatewayProvider` | obtain a gateway by network/family | `ChainGatewayRegistry` | -| `TronGateway` | TRON reads/build/estimate/broadcast, plus stake/delegation/vote/reward, proposal/witness, contract-governance, and chain queries | `TronRpcClient` | -| `TronHistoryReader` | TronGrid transaction history | `TronGridHistoryReader` | -| `TokenRepository` | official/user token book | `TokenBook` | -| `PriceProvider` | best-effort USD price | CoinGecko/Null provider | -| `PromptPort` | the minimal interaction capability Application needs | inbound Prompter | - -`PromptPort` is one of the few ports implemented by an inbound adapter and consumed by Application; this does not change the dependency direction, because Application owns only the interface. - -### 7.2 Use Cases - -- `WalletService`: create/import/list/use/current/rename/derive/delete/backup/change-password, with no knowledge of JSON/Zod/yargs. `changePassword` re-encrypts every software keystore under a new master password. -- `ConfigService`: effective config view, key validation, canonical network normalization, and document update. Writable keys are `defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`. -- `MessageService`: sign a message via the signer port. -- TRON use cases: account, token, transaction, contract, proposal, witness, stake, vote, reward, chain, block; they use only the TRON gateway and the necessary shared ports. `TronVoteService` reads voting power authoritatively from `TronStakeService.votingPower` (injected), not from raw balances; its witness/brokerage fan-out is bounded and per-request cached. `TronProposalService` and `TronWitnessService` perform witness/state/fee preflights before entering the shared transaction pipeline. `TronChainService` exposes governance params, energy/bandwidth prices, and node sync status. - -An inbound command's responsibility is to turn argv/Zod input and `ExecutionContext` into use-case input and then choose a stable output view; it must not do persistence or provider transport itself. - -### 7.3 Reusable Services - -- `TargetResolver`: network selection only. It deliberately does **not** judge the active account against the resolved network — see §6.3. -- `CapabilityRegistry`: per-network feature gate. -- `SignerResolver`: source → software/device signer. -- `TxPipeline`: shared build/estimate/sign/broadcast lifecycle. -- `transactionMode`: decides `dryRun`/`signOnly`/broadcast mode. -- `tronConfirmation`: TRON-specific polling/receipt normalization, not pushed into the generic pipeline. - ---- - -## 8. Network, Gateway, and Capability - -`NetworkDescriptor` is a discriminated union on `family`: - -```ts -interface NetworkBase { - id: string - chainId: string - nativeSymbol: string // TRX / ETH / BNB — see below - feeModel?: FeeModel - capabilities: string[] -} -interface TronNetworkDescriptor extends NetworkBase { - family: "tron" - httpEndpoint?: string // TronGrid HTTP fullHost - tronlinkHttpEndpoint?: string - gasfree?: GasFreeNetworkConfig -} -interface EvmNetworkDescriptor extends NetworkBase { - family: "evm" - httpEndpoint?: string // JSON-RPC -} -type NetworkDescriptor = TronNetworkDescriptor | EvmNetworkDescriptor -``` - -`nativeSymbol` is a NETWORK fact, not a family one. `evm:1` and `evm:56` share every encoding and -arithmetic rule that makes them EVM, but their coins are ETH and BNB; a family-level symbol can -only ever be right for one chain of the family, and reading one rendered a BNB balance as "ETH". -The family still owns what is genuinely family-wide — the base-unit name (`wei`) and its decimals. -`FamilyMeta` deliberately has no `nativeSymbol`, so the wrong one cannot be read. - -There are no `aliases` on the descriptor: they live in a flat `config.aliases` book (ADR-0010). - -A network from `config.yaml` is validated at load — missing `family` / `chainId` / `nativeSymbol`, -or an unknown family, raises `invalid_value` naming the network and the field, and `capabilities` -defaults to empty. Without that, an incomplete hand-added network travelled until something -dereferenced it, surfacing as a bare `internal_error` before any command ran. - -| ID | Alias | Endpoint | -| --- | --- | --- | -| `tron:mainnet` | `tron` | `https://api.trongrid.io` | -| `tron:nile` | `nile` | `https://nile.trongrid.io` | -| `tron:shasta` | `shasta` | `https://api.shasta.trongrid.io` | - -Canonical-id resolution is case-insensitive. **Aliases ARE accepted as network selectors** (ADR-0010, superseding the previous rule): they live in a flat `config.aliases` book, not on the descriptor, and are resolved once in `NetworkRegistry.resolve` — canonical id first, book second, so an alias can never shadow a real id. Nothing downstream of resolution ever sees an alias. `network: optional` adopts `config.defaultNetwork` when `--network` is not specified. Ledger/watch pin a single family, and a family mismatch must fail before any RPC. - -`ChainGatewayRegistry` is injected with the family factory by Bootstrap and caches the client by network id. Its generic `client()` may only use the truly common minimal capabilities; a family use case obtains the `TronGateway` via the guarded `get(net, "tron")`. TRON staking and the future EVM gas/nonce must not be forced into a universal gateway. - -Capabilities consist of two parts: the command-backed keys declared by registered commands (e.g. `vote.cast`, `vote.list`, `vote.status`, `reward.balance`, `reward.withdraw`, `staking.freeze`, `staking.delegate`), plus the network traits in `NetworkDescriptor.capabilities`. The gate must happen before the use case. - -The TRON gateway reads account/resource/stake/vote/reward state and confirms transactions against the **full node's unconfirmed view** (`getUnconfirmedTransactionInfo`, `/wallet/getaccount`, `/wallet/getReward`, `/wallet/getBrokerage`, etc.), not the solidified node. Unconfirmed info is available roughly one block after inclusion (~3s) rather than after solidification (~19 blocks / ~60s), so `--wait` confirms at "mined in a block" quickly and all reads are fresh and mutually consistent. - ---- - -## 9. Signer and Transaction Flow - -### 9.1 Signer Resolution - -```mermaid -flowchart LR - REF[account ref] --> SOURCE{source.type} - SOURCE -->|seed| SEED[lazy decrypt vault + derive] - SOURCE -->|privateKey| KEY[lazy decrypt key] - SOURCE -->|ledger| DEVICE[Ledger signer + path] - SOURCE -->|watch| REJECT[watch_only_no_signer] - SEED --> SOFT[SoftwareSigner + family strategy] - KEY --> SOFT -``` - -The software signer obtains the key only at the moment of an actual `sign()`; a dry-run does not trigger decryption. The Ledger signer verifies the app/address before signing, and if the cached address does not match the device it returns `wrong_device_seed`. - -### 9.2 Pipeline - -```mermaid -flowchart LR - RESOLVE[resolve signer] --> BUILD[build + timeout] - BUILD --> EST[estimate + timeout] - EST --> MODE{mode} - MODE -->|dry-run| PLAN[plan] - MODE -->|sign| SIGN[software / Ledger sign] - SIGN --> CAST{broadcast?} - CAST -->|no| SIGNED[signed] - CAST -->|yes| SEND[broadcast] - SEND --> WAIT{--wait?} - WAIT -->|no| SUB[submitted] - WAIT -->|yes| CONFIRM[family confirmation] - CONFIRM --> FINAL[confirmed / failed
or timeout → submitted] -``` - -The pipeline knows only the signer and the `Broadcaster` port; the family use case provides build, estimate, and confirm callbacks. `timeoutMs` limits a single operation; `waitTimeoutMs` (from `config.waitTimeoutMs`, overridable by `--wait-timeout`) limits confirmation polling. TRON's `tronConfirmation` polls the full node's unconfirmed transaction info, so confirmation resolves in a few seconds rather than after solidification. Once a transaction has been broadcast, a polling error/timeout must not reclassify the command as not-broadcast — it returns `submitted`. - -### 9.3 Ledger - -- When `SPECULOS_PORT` is present, use the Speculos HTTP transport; otherwise USB/HID. -- Transports and `hw-app-trx` are lazily imported and closed after each operation. -- The `m/` is stripped from the Ledger path before it is passed to the app. -- APDU `0x6985` → `signing_rejected`; an unavailable device/app/transport → `auth_required`. - ---- - -## 10. Persistence and Cryptography - -### 10.1 Root and Files - -The root uses a non-empty `WALLET_CLI_HOME` in order of preference, otherwise `$HOME/.wallet-cli`. - -```text -/ -├── config.yaml -├── wallets.json -├── tokens.json -├── verifier.json -├── vaults/vlt_.json -├── keys/key_.json -└── backups/-.json -``` - -`AtomicFileStore` writes use a unique temp file in the same directory, mode `0600`, and an atomic rename. Mutations are serialized with `.lock` + `O_EXCL`; a dead PID/stale lock can be reclaimed. - -### 10.2 `wallets.json` - -```json -{ - "version": 1, - "activeAccount": "wlt_abcd1234.0", - "wallets": [{ - "id": "wlt_abcd1234", - "source": { - "type": "seed", - "vaultId": "vlt_efgh5678", - "addresses": { "0": { "tron": "T..." } } - } - }], - "labels": { "wlt_abcd1234.0": "main" } -} -``` - -IDs are random 5-byte Crockford base32 lowercase strings. Labels are case-insensitively unique and must not begin with `wlt_`. The seed's known indices equal the `addresses` keys; Ledger/watch are deduplicated by source identity and are not merged with a software wallet that has the same address. - -### 10.3 Token and Config - -The user entries in `tokens.json` are partitioned by `|`; the effective list is official first, then user-only, deduplicated by `(kind,id)`. Official entries cannot be deleted/overwritten. - -`config.yaml` is shallow-merged with the builtin config. The only writable keys are `defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`; `networks` is a CLI read-only view. `waitTimeoutMs` must be a non-negative integer and supplies the default confirmation-polling cap (built-in 60000 ms). Runtime globals are not written back to config. - -### 10.4 Encrypted Blobs - -`verifier.json`, vaults, and keys use scrypt (N=262144, r=8, p=1, dkLen=32), AES-128-CTR, and a `keccak256(derivedKey[16..31] + ciphertext)` MAC. Each blob has its own 32-byte salt and 16-byte IV but shares the keystore master password. A MAC mismatch returns `auth_failed`; the password is never written to disk. - -Backup is allowed only for seed/private-key; the plaintext secret file must be `0600` and must not overwrite an existing file; the terminal/envelope returns only metadata, not the secret. - ---- - -## 11. Secret and Interaction Policy - -```mermaid -flowchart LR - NEED[Secret needed] --> SOURCE{source} - SOURCE -->|--kind-stdin| STDIN[fd 0 once] - SOURCE -->|interactive TTY| HIDDEN[hidden prompt] - SOURCE -->|none| ERROR[missing_option / auth_required] -``` - -- A handler must not read `process.stdin` directly; `StreamManager.readStdinOnce()` reads at most once per execution. -- A single invocation may use fd 0 through only one `--*-stdin` channel. -- Only `password` (`--password-stdin`, a global) and the command-scoped `tx` / `message` channels are stdin-backed. Wallet secrets (`mnemonic`, `privateKey`) and the master-password change have **no** stdin flag — they are TTY-only (`secretsTtyOnly`): a hidden interactive prompt, or fail fast with `tty_required` off a TTY. Importing an existing secret and re-keying the vault are deliberately human moments. -- Secret argv, `MASTER_PASSWORD`, `--*-file`, and ordinary env secrets are not supported. -- A secret must not enter logs, diagnostics, error details, or the result envelope. -- Interactive allowlist: create, the four imports, delete, backup, change-password; the order is password → field gap-fill/account selection → command confirm. - ---- - -## 12. Output, Stream, and Error - -| Data | Text mode | JSON mode | -| --- | --- | --- | -| Successful terminal result | stdout once | one result envelope on stdout | -| Failed terminal result | stderr once | one error envelope on stdout | -| Progress | stderr | stderr JSON event | -| Warning | stderr/collected | `meta.warnings` | -| Debug | verbose stderr | verbose stderr | - -The JSON schema is fixed as `wallet-cli.result.v1`. A chain command envelope includes family, network id/name, and chain id; a neutral command omits chain. `bigint` is converted to a decimal string and `Uint8Array` to hex. A second terminal result must throw `internal_error`. - -Exit codes: success/meta = 0; execution error = 1; usage error = 2. An unknown exception is normalized into a redacted `internal_error`; the raw text of a third-party error must not enter the public envelope. - ---- - -## 13. Help and Machine-Readable Introspection - -Supports root/group/leaf help, version, the full catalog JSON Schema, and a single-command JSON Schema. The data flow: - -```mermaid -flowchart LR - ZOD[fields + input] --> ARITY[yargs arity] - ZOD --> HELP[help flags] - ZOD --> SCHEMA[JSON Schema] - DEF[CommandDefinition] --> HELP - DEF --> CATALOG[machine catalog] - GLOBAL[GLOBAL_FLAG_SPECS] --> ARITY & HELP & CATALOG -``` - -Arity is derived from the field's Zod type: a `z.array(...)` field projects to a first-class repeatable yargs flag (`array: true`), so `--for a --for b` arrives as a `string[]` with no preprocess/pipe patch, and its help renders the element type (``), not the container. A hand-maintained command flag table must not be created separately. The public help/output is a stable contract; when it changes, automated tests must verify root, group, leaf, JSON Schema, and functional scenarios. - -Leaf-help description text: a command's `summary` is the one-line row shown in its parent group's listing and must stay a single terse line. Leaf help (` --help`) renders `description ?? summary`: a command whose behavior needs more than a headline (overwrite semantics, per-call limits, warnings, frequency caps) SHOULD declare a fuller multi-line `description`; there is no requirement that leaf help collapse to one line. Group descriptions (`GROUP_DESCRIPTIONS`) may likewise span multiple lines. Keep the wording in sync with the product command doc, the source of truth for the exact copy. - ---- - -## 14. Rules for Adding Features - -### 14.1 Adding a Command - -1. Decide whether it is a neutral or a family logical command. -2. Application first creates/extends the use case and the required ports. -3. Outbound capabilities implement the port with an adapter; the use case must not import the adapter. -4. The inbound command defines the Zod fields/input, policy metadata, use-case translation, and renderer. -5. Register it with the neutral registrar or the family `ChainModule`. -6. Add use-case, adapter, registry/dispatch, and output/help tests, and update the inventory in this document. - -A command is forbidden to build TronWeb/Keystore directly, write to process stdout, perform a filesystem mutation, or treat a provider wire response as the renderer's business model. - -### 14.2 Adding a Chain Family - -```mermaid -flowchart LR - DOMAIN[1 Domain family/address/network] --> PORT[2 family gateway port] - PORT --> ADAPTER[3 gateway + signing adapters] - ADAPTER --> USECASE[4 family use cases] - USECASE --> CLI[5 family CLI module] - CLI --> PLUGIN[6 bootstrap plugin + registry] - PLUGIN --> TEST[7 routing/output/contract tests] -``` - -Adding a family must extend `ChainFamily`/`FAMILIES`, the discriminated network/address types, `ChainGatewayMap`, the sign strategy, the gateway, and use cases; add a `FamilyBinding` for that family to each shared command's `ChainSpec.families` table (with its option delta in `binding.fields` and family-shaped rows in `FAMILY_RENDER[family]`) rather than defining new command objects; and extend networks/render/tests. Only a genuinely identical intent and I/O shape may be factored into a shared port; the TRON resource model and the EVM gas/nonce must remain separate. - -### 14.3 Adding a Wallet Source - -Synchronously update the `Source` union, `SOURCE_KINDS`, the import workflow, repository persistence/migration, dedup, signer resolution, cleanup, descriptor rendering, and tests. An unknown source must not fall into a silent default. - ---- - -## 15. Invariants That Must Be Maintained - -### 15.1 Architecture - -- Domain has no external I/O and does not depend on upper layers. -- Production Application does not import adapters/bootstrap. -- Inbound/Outbound adapters do not import each other. -- `bootstrap/composition.ts` is the single general composition root; family-specific composition lives in plugins. -- Application owns the ports; adapters implement the ports. -- No circular dependencies and no use of type-only imports to bypass a conceptual boundary. - -### 15.2 Behavior and Security - -- JSON stdout is exactly one terminal frame, schema `wallet-cli.result.v1`. -- The usage/execution/success exit codes are fixed at 2/1/0. -- Secrets do not enter argv/env/log/envelope; stdin uses at most one channel per execution, read once. -- Watch-only never signs; dry-run never decrypts, signs, or broadcasts. -- All persistent mutations are locked, and all replacement writes use an atomic rename. -- A broadcast transaction does not become a command failure because of a confirmation timeout. -- An unknown exception is redacted from the user. - -### 15.3 Verification Gates - -```bash -npm run typecheck -npm run depcruise -npm test -npm run build -``` - -When real TRON behavior is involved, additionally run `npm run test:live:nile` with an isolated wallet home; test secrets must not be logged or copied. An architectural change must at minimum pass typecheck, dependency-cruiser, unit tests, and build. - ---- - -## 16. Architectural Judgment Criteria - -When ownership is disputed, decide in order: - -1. No I/O, describes business values and invariants: Domain. -2. Describes what the product does or what external capability it needs: Application use case/service/port. -3. Turns terminal/argv/Zod into application input: Inbound CLI adapter. -4. Implements filesystem, HTTP, device, price, etc. as a port: Outbound adapter. -5. Chooses a concrete implementation and wires the object graph: Bootstrap. - -If a single module is parsing argv, calling a provider, writing a file, and rendering output all at once, the responsibilities have not yet been separated. The core standard is not the directory name, but whether dependencies point from the outside in, whether external details are replaceable, and whether the use case can be tested with only ports. diff --git a/ts/src/adapters/inbound/cli/commands/config.ts b/ts/src/adapters/inbound/cli/commands/config.ts index 9d5adc9cd..8f40132e6 100644 --- a/ts/src/adapters/inbound/cli/commands/config.ts +++ b/ts/src/adapters/inbound/cli/commands/config.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { CommandDefinition } from "../contracts/index.js"; import { CONFIG_KEYS, + NETWORK_CONFIG_FIELDS, type ConfigService, } from "../../../../application/use-cases/config-service.js"; import { CommandRegistry } from "../registry/index.js"; @@ -9,7 +10,7 @@ import { TextFormatters } from "../render/index.js"; export function registerConfigCommands(registry: CommandRegistry, service: ConfigService): void { const fields = z.object({ - // Not an enum: `networks..httpEndpoint` is a nested path, and the id segment is + // Not an enum: `networks.[.]` is a nested path, and the id segment is // open-ended (any canonical id or alias). The service validates the key and names the // supported ones, so a typo gets a precise message rather than a yargs enum dump. key: z @@ -17,7 +18,7 @@ export function registerConfigCommands(registry: CommandRegistry, service: Confi .min(1) .optional() .describe( - `config key to read or set (${CONFIG_KEYS.join(", ")}, or networks..httpEndpoint); omit to show the whole effective config`, + `config key to read or set (${CONFIG_KEYS.join(", ")}, or networks. / networks..{${NETWORK_CONFIG_FIELDS.join(" | ")}}); omit to show the whole effective config`, ), value: z.string().min(1).optional().describe("new value; omit to read the key"), }); @@ -35,6 +36,8 @@ export function registerConfigCommands(registry: CommandRegistry, service: Confi { cmd: "wallet-cli config" }, { cmd: "wallet-cli config defaultNetwork" }, { cmd: "wallet-cli config defaultNetwork tron:nile" }, + { cmd: "wallet-cli config networks.tron:mainnet" }, + { cmd: "wallet-cli config networks.tron:mainnet.apiKeyHeader TRON-PRO-API-KEY" }, ], formatText: TextFormatters.config, run: async (ctx, _network, input) => service.execute(input, ctx.config, ctx.networkRegistry), diff --git a/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts b/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts index 23eb45ef1..c049db64e 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.artifact.test.ts @@ -313,7 +313,7 @@ describe("contract deploy — TRON's ABI requirement", () => { /** * `--permission-id` and `--expiration` are TRON's multi-signature concepts. They sat in the * shared base fields, so an EVM `--help` listed them untagged beside the flags that are tagged - * `(tron)` — a reader had no way to tell they do nothing here. + * `(tron only)` — a reader had no way to tell they do nothing here. */ describe("contract deploy — TRON-only transaction flags are tagged", () => { it("keeps them off the EVM binding", () => { diff --git a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts index 23574e41c..07d83377f 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts @@ -133,7 +133,7 @@ describe("contract deploy — ABI constructor guard", () => { * guard existed to explain the difference. `--constructor-params` unifies the form, so that * guard now points the other way: the typed form is the accepted one. * - * `--abi` stays REQUIRED on TRON and is tagged (tron). TronWeb's createSmartContract derives + * `--abi` stays REQUIRED on TRON and is tagged (tron only). TronWeb's createSmartContract derives * constructor types from the ABI and takes only bare values; ethers needs no ABI at all. * Synthesising an ABI from the caller's inline types would hand TronWeb something nothing can * check — a mistyped parameter would encode cleanly and deploy a wrong contract. diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index 2bb372d72..8529633fd 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -233,7 +233,7 @@ export const contractSendSpec: ChainSpec = { summary: "State-changing contract call", description: "Call a contract method that changes state, signing and broadcasting the transaction.\n" + - "Flags marked (tron) or (evm) apply only on networks of that family; using one on the other family is rejected.", + "Flags marked (tron only) or (evm only) are accepted only on networks of that family; using one on the other family is rejected.", baseFields: sendFields, baseRefine: governanceTxRefine, examples: [ @@ -570,7 +570,7 @@ function codeSourceRefine( * derives the constructor's types from the ABI and takes only bare values, so without it there * is nothing to encode against. Synthesising an ABI from the caller's inline types would hand * TronWeb something no one can check — a mistyped parameter would encode cleanly and deploy a - * contract built from the wrong arguments. ethers needs no ABI, which is why this is `(tron)`. + * contract built from the wrong arguments. ethers needs no ABI, which is why this is `(tron only)`. */ const tronDeployFields = z.object({ abi: z @@ -625,7 +625,7 @@ export const contractDeploySpec: ChainSpec = { summary: "Deploy contract bytecode", 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.", + "Flags marked (tron only) or (evm only) are accepted only on networks of that family; using one on the other family is rejected.", baseFields: deployFields, baseRefine: deployRefine, examples: [ 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 e6ed9706b..66ddafc4d 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -956,18 +956,49 @@ describe("config renders map-valued keys", () => { expect(out).not.toContain("[object Object]"); }); - // The whole-config view is an overview: it names what exists rather than dumping every value, - // which for 7 networks plus 7 aliases would bury the scalar settings. - it("summarises map-valued keys in the whole-config view", () => { + // The whole-config view used to SUMMARISE a map by listing its keys ("networks tron:nile, + // evm:1"), which said a network existed but never what it was configured with. §2.4 (revised): + // config renders every configurable value, nested — the file's own shape, indented. + it("expands map-valued keys in the whole-config view", () => { const out = TextFormatters.config({ defaultOutput: "text", - networks: { "tron:nile": "nile.trongrid.io", "evm:1": "ethereum-rpc.publicnode.com" }, + networks: { + "tron:nile": { httpEndpoint: "nile.trongrid.io" }, + "evm:1": { httpEndpoint: "ethereum-rpc.publicnode.com" }, + }, }); - expect(out).toContain("tron:nile"); - expect(out).toContain("evm:1"); + // No trailing colon: a network id already contains one, so `tron:nile:` would hide where the + // id ends — and the id is what a reader copies into `--network` / `config networks.`. + expect(out).toMatch(/^networks$/m); + expect(out).toMatch(/^ {2}tron:nile$/m); + expect(out).toMatch(/^ {4}httpEndpoint {2}nile\.trongrid\.io$/m); + expect(out).toMatch(/^ {2}evm:1$/m); expect(out).not.toContain("[object Object]"); }); + + // Two levels deep, under a named read: the block below a network is its fields, indented once. + it("renders a single network read as a nested block", () => { + const out = TextFormatters.config({ + key: "networks.tron:nile", + value: { + httpEndpoint: "https://nile.trongrid.io", + apiKeyHeader: "TRON-PRO-API-KEY", + apiKey: "********", + }, + }); + + expect(out.split("\n")[0]).toBe("networks.tron:nile"); + expect(out).toMatch(/^ {2}httpEndpoint {2}https:\/\/nile\.trongrid\.io$/m); + expect(out).toMatch(/^ {2}apiKey {8}\*{8}$/m); + }); + + // A scalar leaf keeps its one-line form; nesting must not swallow the simple case. + it("keeps a scalar read on one line", () => { + expect(TextFormatters.config({ key: "timeoutMs", value: 60_000 })).toMatch( + /^timeoutMs {2}60000$/, + ); + }); }); // §1.4 draws a distinction the renderer previously did not: a VALUATION gets 2 decimals, a UNIT diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index 4c83c2167..bdfc53c05 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -46,7 +46,7 @@ export const txSendSpec: ChainSpec = { "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 — // help has to be readable on its own, without the reader having seen the spec. - "Flags marked (tron) or (evm) apply only on networks of that family; using one on the other family is rejected.", + "Flags marked (tron only) or (evm only) are accepted only on networks of that family; using one on the other family is rejected.", baseFields: sendFields, exclusive: [ { label: "the amount to send", flags: ["amount", "raw-amount"], select: "exactly-one" }, @@ -154,7 +154,7 @@ export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => }); /** TRON's JSON form of a signed transaction. Declared by the TRON binding alone, which is what - * makes help tag it `(tron)` and every other family refuse it — the same treatment `--asset-id` + * makes help tag it `(tron only)` and every other family refuse it — the same treatment `--asset-id` * gets. EVM has no JSON transaction: it exchanges RLP hex. */ const tronBroadcastFields = z.object({ transaction: z.string().optional().describe("signed transaction JSON"), @@ -179,7 +179,7 @@ export const txBroadcastSpec: ChainSpec = { path: ["tx", "broadcast"], stdin: "tx", // The channel carries TRON's transaction JSON, and only the TRON binding reads it. Declaring - // that is what tags it `(tron)` in help and lets any other family refuse it outright, instead + // that is what tags it `(tron only)` in help and lets any other family refuse it outright, instead // of ignoring a payload the caller piped in. stdinFamily: "tron", network: "optional", @@ -281,7 +281,7 @@ export const txApprovalsTronBinding = (service: TronMultisigService): FamilyBind run: async (_ctx, network, input) => service.approvals(network, hexInput(input)), }); -/** The TRON compatibility path, declared by the TRON binding so help tags it `(tron)`; see +/** The TRON compatibility path, declared by the TRON binding so help tags it `(tron only)`; see * tronBroadcastFields. Its two companion rules (`--out` / `--offline` are hex-only) travel with * it, because they are only meaningful where `--transaction` exists. */ const tronSignFields = z.object({ diff --git a/ts/src/adapters/inbound/cli/help/group-family-tags.test.ts b/ts/src/adapters/inbound/cli/help/group-family-tags.test.ts index c3bc3d680..df288cd54 100644 --- a/ts/src/adapters/inbound/cli/help/group-family-tags.test.ts +++ b/ts/src/adapters/inbound/cli/help/group-family-tags.test.ts @@ -7,11 +7,11 @@ import { isChainCommand, type StreamManager } from "../contracts/index.js"; import { composeCliRuntime } from "../../../../bootstrap/composition.js"; /** - * Group help tags a sub-command with `(tron)` / `(evm)` when only that family can serve it. + * Group help tags a sub-command with `(tron only)` / `(evm only)` when only that family can serve it. * * The tag is DERIVED from the registry, never written by hand, because §10.1 defines it as a * statement about the current bindings ("補齊後標註即摘掉"). A hand-maintained tag goes stale - * silently and then lies: the root listing kept `chain (tron)` long after `chain node` and + * silently and then lies: the root listing kept `chain (tron only)` long after `chain node` and * `chain prices` gained EVM bindings. These tests pin the derivation, not a copy of the text. */ describe("group help family tags are derived from the registry", () => { @@ -59,7 +59,7 @@ describe("group help family tags are derived from the registry", () => { if (inCommands) { if (!line.trim()) break; const verb = line.trim().split(/\s+/)[0]!; - rows.set(verb, /\((tron|evm)\)$/.exec(line.trimEnd())?.[1] ?? ""); + rows.set(verb, /\((tron|evm) only\)$/.exec(line.trimEnd())?.[1] ?? ""); } } diff --git a/ts/src/adapters/inbound/cli/help/help.test.ts b/ts/src/adapters/inbound/cli/help/help.test.ts index 529fbbd73..c8c0ac765 100644 --- a/ts/src/adapters/inbound/cli/help/help.test.ts +++ b/ts/src/adapters/inbound/cli/help/help.test.ts @@ -173,12 +173,12 @@ describe("shipped exclusive groups actually render", () => { // The two TRON-only members say so. A jointly-required group drops "[optional]" from its rows // (that tag would contradict the group), but the family tag is a different fact and survives: // without it, "no tag" would mean both "every family" and "we did not move the flag". - expect(out[1]).toContain("(tron)"); - expect(out[2]).toContain("(tron)"); + expect(out[1]).toContain("(tron only)"); + expect(out[2]).toContain("(tron only)"); expect(out[1]).not.toContain("[optional]"); // --hex and --file are read by both families and stay untagged. - expect(out[3]).not.toContain("(tron)"); - expect(out[4]).not.toContain("(tron)"); + expect(out[3]).not.toContain("(tron only)"); + expect(out[4]).not.toContain("(tron only)"); }); // tx multisig's three modes are rejected in combination by tronLinkMultisigRefine. Without the @@ -198,9 +198,9 @@ describe("shipped exclusive groups actually render", () => { }); }); -// The (tron) tag on the root listing tells a reader which groups disappear on a non-TRON network. +// The (tron only) tag on the root listing tells a reader which groups disappear on a non-TRON network. // It is therefore a claim about the CURRENT bindings, and a stale one actively misleads: `chain` -// carried (tron) for a whole release after `chain node` and `chain prices` gained EVM bindings, +// carried (tron only) for a whole release after `chain node` and `chain prices` gained EVM bindings, // telling every EVM reader that a group they could in fact use was closed to them. A group is // tagged only while EVERY command under it is bound to that one family. describe("root help family tags", () => { @@ -211,18 +211,18 @@ describe("root help family tags", () => { } it("leaves chain untagged, because chain node / chain prices serve EVM too", () => { - expect(rootRow("chain")).not.toMatch(/\(tron\)$/); + expect(rootRow("chain")).not.toMatch(/\(tron only\)$/); }); it("still tags the groups that really are TRON-only", () => { for (const group of ["permission", "gasfree", "stake", "vote", "reward"]) { - expect(rootRow(group)).toMatch(/\(tron\)$/); + expect(rootRow(group)).toMatch(/\(tron only\)$/); } }); it("leaves family-neutral groups untagged", () => { for (const group of ["contract", "message", "block"]) { - expect(rootRow(group)).not.toMatch(/\(tron\)$/); + expect(rootRow(group)).not.toMatch(/\(tron only\)$/); } }); }); @@ -504,9 +504,9 @@ describe("help tags family-specific flags", () => { .split("\n") .find((l) => l.includes(`${flag} `) || l.trimEnd().endsWith(flag))!; - expect(line("--fee-limit").trimEnd()).toMatch(/\(tron\)$/); - expect(line("--gas-limit").trimEnd()).toMatch(/\(evm\)$/); - expect(line("--max-fee").trimEnd()).toMatch(/\(evm\)$/); + expect(line("--fee-limit").trimEnd()).toMatch(/\(tron only\)$/); + expect(line("--gas-limit").trimEnd()).toMatch(/\(evm only\)$/); + expect(line("--max-fee").trimEnd()).toMatch(/\(evm only\)$/); }); // A flag BOTH families declare (each with its own validation) is shared, not family-specific. diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index e6c66c8c1..16ee763d3 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -165,7 +165,7 @@ export class HelpService { ["stake", "Stake / delegate resources & query state", "tron"], ["vote", "Vote for super representatives", "tron"], ["reward", "Query / withdraw voting rewards", "tron"], - // No (tron) tag: `chain node` and `chain prices` both serve EVM. Only `chain params` + // No (tron only) tag: `chain node` and `chain prices` both serve EVM. Only `chain params` // is TRON-only, and that difference belongs on the sub-command row in the group help. ["chain", "Query chain and node state", ""], ["message", "Sign arbitrary messages", ""], @@ -193,7 +193,7 @@ export class HelpService { const commandRow = (name: string, desc: string, tag: string): string => { const body = ` ${name.padEnd(nameWidth)}${dim(desc)}`; return tag - ? `${body}${" ".repeat(Math.max(2, tagCol - desc.length))}(${tag})` + ? `${body}${" ".repeat(Math.max(2, tagCol - desc.length))}(${tag} only)` : body.trimEnd(); }; const row = @@ -247,7 +247,7 @@ export class HelpService { const commands = this.#chainGroupCommands(group); const tags = commands.map((c) => groupRowTag(c.families)); // A group whose every command belongs to the same single family is already tagged as a whole - // at the root (`stake … (tron)`). Repeating it on all six rows adds a column that never + // at the root (`stake … (tron only)`). Repeating it on all six rows adds a column that never // varies — §10.3: "其組 help 內部不再逐條重複". Tag rows only where they DISCRIMINATE. const uniform = tags.length > 0 && tags.every((t) => t !== "" && t === tags[0]); const rows = commands.map( @@ -272,7 +272,9 @@ export class HelpService { for (const [verb, summary, tag] of rows) { const body = ` ${verb.padEnd(width)} ${summary}`; lines.push( - tag ? `${body}${" ".repeat(Math.max(2, tagCol - summary.length))}(${tag})` : body.trimEnd(), + tag + ? `${body}${" ".repeat(Math.max(2, tagCol - summary.length))}(${tag} only)` + : body.trimEnd(), ); } lines.push("", `Run 'wallet-cli ${group} COMMAND --help' for more information on a command.`); @@ -407,7 +409,7 @@ export class HelpService { head: flagHead(f), desc: f.description ?? "", tag: flagTag(f), - ...(family ? { familyTag: `(${family})` } : {}), + ...(family ? { familyTag: `(${family} only)` } : {}), }; }), ...c.inputFlags.map((g) => ({ @@ -415,12 +417,12 @@ export class HelpService { head: globalFlagHead(g), desc: g.description, tag: globalFlagTag(g), - ...(c.stdinFamily ? { familyTag: `(${c.stdinFamily})` } : {}), + ...(c.stdinFamily ? { familyTag: `(${c.stdinFamily} only)` } : {}), })), ]; if (optionRows.length) { const width = Math.min(34, Math.max(...optionRows.map((r) => r.head.length))); - // Two independent tags: "[optional]" says whether the flag may be omitted, "(tron)" says + // Two independent tags: "[optional]" says whether the flag may be omitted, "(tron only)" says // which family reads it. They are joined here so the family tag survives on its own in an // exclusive block, where the optional tag is deliberately dropped. const rowLine = (r: OptionRow, tag: string): string => { @@ -594,7 +596,7 @@ interface OptionRow { desc: string; /** "[optional]" / "[required]" — dropped inside a jointly-required exclusive block. */ tag: string; - /** "(tron)" — which family reads this flag; independent of `tag`, so it survives that block. */ + /** "(tron only)" — which family reads this flag; independent of `tag`, so it survives that block. */ familyTag?: string; } @@ -640,13 +642,13 @@ function globalFlagsForText( } /** - * The `(tron)` / `(evm)` tag for one sub-command row in a group help page. + * The `(tron only)` / `(evm only)` tag for one sub-command row in a group help page. * * §10.1: the tag means "only this family can serve this command IN THE CURRENT VERSION" — it is * not a promise about the future. So it is derived from the registry rather than written down: * a command bound to exactly one family is tagged, one bound to both is not, and the tag drops * off by itself the day the missing binding lands (`contract info` will, once EVM gets an - * indexer). Hand-written tags are how `chain` came to be labelled `(tron)` at the root long + * indexer). Hand-written tags are how `chain` came to be labelled `(tron only)` at the root long * after `chain node` and `chain prices` started serving EVM. */ function groupRowTag(families: readonly string[]): string { diff --git a/ts/src/adapters/inbound/cli/render/misc.ts b/ts/src/adapters/inbound/cli/render/misc.ts index 0103f2672..2780d3fdd 100644 --- a/ts/src/adapters/inbound/cli/render/misc.ts +++ b/ts/src/adapters/inbound/cli/render/misc.ts @@ -1,6 +1,6 @@ import type { TextFormatter } from "../contracts/index.js"; import { formatScalar, num, methodName } from "./scalars.js"; -import { type Obj, type Pair, asObj, kv, query, receipt, table, titled, ok } from "./layout.js"; +import { type Obj, asObj, kv, query, receipt, table, ok } from "./layout.js"; import { FAMILY_RENDER, renderFamily } from "./family.js"; export const MiscFormatters = { @@ -108,18 +108,47 @@ function renderConfig(d: Obj): string { ]); } if ("key" in d) { - // A map-valued key (networks, aliases) gets its own titled block; a scalar stays one line. + // A map-valued key (networks, aliases, one network) gets a titled block whose body may itself + // nest; a scalar stays one line. return isMap(d.value) - ? titled( - String(d.key), - Object.entries(d.value).map(([k, v]) => [k, configValue(v)] as Pair), - ) + ? [String(d.key), configTree(d.value, " ")].filter(Boolean).join("\n") : kv([[String(d.key), configValue(d.value)]], ""); } - return kv( - Object.entries(d).map(([k, v]) => [k, configValue(v)] as Pair), - "", - ); + return configTree(d, ""); +} + +/** + * The config document as it is: scalars as `key value`, a nested map as its bare key plus the + * body indented one level. + * + * No `key:` separator, because the keys here CONTAIN colons — `tron:mainnet:` gives no way to see + * where the network id ends. Indentation already marks the nesting, so the colon only adds + * ambiguity to the one thing a reader needs to copy verbatim. + * + * The whole-config view used to summarise a map by listing its keys, which told a reader that + * `networks.tron:nile` existed but never what it held — and the summary had to be maintained + * separately from the values themselves. Rendering the tree means a new configurable field shows + * up here the moment the service returns it. + */ +function configTree(value: Record, indent: string): string { + const entries = Object.entries(value); + // Scalars align within their own level only: a deeper block has its own column. + const width = entries + .filter(([, v]) => !isMap(v)) + .reduce((max, [key]) => Math.max(max, key.length), 0); + const lines: string[] = []; + for (const [key, v] of entries) { + if (isMap(v)) { + lines.push(`${indent}${key}`); + const body = configTree(v, `${indent} `); + if (body) lines.push(body); + continue; + } + const rendered = configValue(v); + // kv() drops empty values; an unset key is absent rather than a blank line. + if (rendered !== "") lines.push(`${indent}${key.padEnd(width)} ${rendered}`); + } + return lines.join("\n"); } /** a plain object value, i.e. one of the map-valued config keys. */ @@ -130,9 +159,6 @@ function isMap(v: unknown): v is Record { /** config values keep their literal form (no thousands grouping, raw key names). */ function configValue(v: unknown): string { if (Array.isArray(v)) return v.map(String).join(", "); - // In the whole-config overview a map is summarised by its keys — listing every value would - // bury the scalar settings under 7 networks and 7 aliases. Read the key itself for detail. - if (isMap(v)) return Object.keys(v).join(", "); return v === null || v === undefined ? "" : String(v); } diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts index b26cf1d5f..a2903dfa8 100644 --- a/ts/src/adapters/inbound/cli/shell/index.ts +++ b/ts/src/adapters/inbound/cli/shell/index.ts @@ -500,12 +500,43 @@ function otherFamilyFlags( return out; } +/** + * yargs' own keys for a command tail: the group head, the sub-verb, and the captured positionals. + * They have to stay in assertKnownFlags' allowlist, because yargs really does put them in argv — + * but argv cannot say whether YARGS put them there or the USER typed `--args`. + * + * That ambiguity made them work as undocumented aliases: `chain --verb node` ran chain.node, and + * `use --args main` bound `main` as a positional. `contract call --args ` was the bad one — + * it bound into a slot the command does not have, so the call ran with no argument and answered + * `0`, a wrong result that reads like a real one. + * + * No command declares a field by these names (pinned by a meta-test), so a TOKEN spelling one is + * always user-typed and always wrong. The raw tokens still tell the two apart, so the check + * belongs there — before yargs folds them together. + */ +export const YARGS_TAIL_KEYS = ["group", "verb", "args", "source"] as const; + +export function assertNoTailFlags(tokens: string[]): void { + const typed = new Set(); + for (const token of tokens) { + if (token === "--") break; // everything past a bare `--` is a value, not a flag + const match = /^--([A-Za-z0-9-]+)(?:=|$)/.exec(token); + if (match && (YARGS_TAIL_KEYS as readonly string[]).includes(match[1]!)) typed.add(match[1]!); + } + if (typed.size > 0) { + throw new UsageError( + "invalid_option", + `unknown option(s): ${[...typed].map((name) => `--${name}`).join(", ")}`, + ); + } +} + function assertKnownFlags( cmd: Pick, argv: any, otherFamily: Map = new Map(), ): void { - const allowed = new Set(["_", "$0", "group", "verb", "args", "source"]); + const allowed = new Set(["_", "$0", ...YARGS_TAIL_KEYS]); const add = (name: string) => { allowed.add(name); allowed.add(camelToKebab(name)); diff --git a/ts/src/adapters/inbound/cli/shell/tail-flags.test.ts b/ts/src/adapters/inbound/cli/shell/tail-flags.test.ts new file mode 100644 index 000000000..bd65e4f9c --- /dev/null +++ b/ts/src/adapters/inbound/cli/shell/tail-flags.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { assertNoTailFlags, YARGS_TAIL_KEYS } from "./index.js"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { isChainCommand } from "../contracts/index.js"; +import { composeCliRuntime } from "../../../../bootstrap/composition.js"; + +describe("assertNoTailFlags", () => { + it.each([...YARGS_TAIL_KEYS])("rejects --%s typed by the user", (name) => { + expect(() => assertNoTailFlags(["contract", "call", `--${name}`, "x"])).toThrow( + new RegExp(`--${name}`), + ); + }); + + it("rejects the --key=value form too", () => { + expect(() => assertNoTailFlags(["use", "--args=main"])).toThrow(/--args/); + }); + + it("names every offending flag at once", () => { + try { + assertNoTailFlags(["list", "--args", "a", "--verb", "b"]); + expect.unreachable("should have thrown"); + } catch (error) { + expect((error as Error).message).toContain("--args"); + expect((error as Error).message).toContain("--verb"); + } + }); + + it("leaves the ordinary command line alone", () => { + expect(() => + assertNoTailFlags(["tx", "send", "--to", "T…", "--amount", "1", "--network", "nile"]), + ).not.toThrow(); + }); + + // Only a whole flag name counts: `--arguments` is someone else's flag, not this one. + it("does not match a longer flag that merely starts with a tail key", () => { + expect(() => assertNoTailFlags(["x", "--arguments", "1", "--verbose"])).not.toThrow(); + }); + + // A value that happens to spell a flag is a value. + it("stops scanning at a bare --", () => { + expect(() => assertNoTailFlags(["x", "--", "--args", "y"])).not.toThrow(); + }); +}); + +// The blanket rejection above is only safe while no command wants these names as real flags. +// If one ever does, this fails and points at the trade-off rather than letting the flag be +// silently unreachable. +describe("no command declares a field named after a yargs tail key", () => { + let previousHome: string | undefined; + + beforeAll(() => { + previousHome = process.env.WALLET_CLI_HOME; + process.env.WALLET_CLI_HOME = mkdtempSync(join(tmpdir(), "wallet-cli-tail-flags-")); + }); + + afterAll(() => { + if (previousHome === undefined) delete process.env.WALLET_CLI_HOME; + else process.env.WALLET_CLI_HOME = previousHome; + }); + + it("holds across the whole registry", () => { + const runtime = composeCliRuntime({ + globals: { output: "text", verbose: false }, + secretPaths: {}, + startedAt: Date.now(), + }); + const offenders: string[] = []; + for (const cmd of runtime.registry.all()) { + const path = (isChainCommand(cmd) ? cmd.spec.path : cmd.path).join(" "); + const shapes = isChainCommand(cmd) + ? [ + cmd.spec.baseFields.shape, + ...Object.values(cmd.families).flatMap((b) => (b?.fields ? [b.fields.shape] : [])), + ] + : [cmd.fields.shape]; + for (const shape of shapes) { + for (const key of Object.keys(shape)) { + if ((YARGS_TAIL_KEYS as readonly string[]).includes(key)) offenders.push(`${path}.${key}`); + } + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/ts/src/adapters/outbound/chain/evm/evm.test.ts b/ts/src/adapters/outbound/chain/evm/evm.test.ts index 2f425d1ca..08f872f55 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.test.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { barBroadcasts } from "../../../../application/services/broadcast-guard.js"; import { Transaction } from "ethers"; import { EvmRpcClient } from "./evm.js"; +import { HttpTransportError, type HttpTransport } from "../../http/index.js"; const ADDR = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; @@ -26,6 +27,31 @@ function stubRpc(result: unknown) { } describe("EvmRpcClient.getNativeBalance", () => { + it("sends JSON-RPC through the injected HTTP transport seam", async () => { + const requests: unknown[] = []; + const transport: HttpTransport = { + requestText: async (request) => { + requests.push(request); + return JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x0" }); + }, + }; + + await new EvmRpcClient("https://node.example", 5_000, transport).getNativeBalance(ADDR); + + expect(requests).toEqual([ + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "eth_getBalance", + params: [ADDR, "latest"], + }), + }, + ]); + }); + it("asks eth_getBalance for the latest block", async () => { const seen = stubRpc("0x0"); await new EvmRpcClient("https://node.example", 5_000).getNativeBalance(ADDR); @@ -943,3 +969,32 @@ describe("EvmRpcClient.getTransactionByHash", () => { ).toBeNull(); }); }); + +// A stalled node is a `timeout`, not an `rpc_error`: `--json-schema` documents `timeout` as "the +// node, service or device did not answer in time", and TronRpcClient already reports it that way. +// A script that retries on `timeout` but reports `rpc_error` needs the two families to agree. +describe("EvmRpcClient transport failure classification", () => { + const transportThrowing = (error: unknown): HttpTransport => ({ + requestText: () => Promise.reject(error), + }); + + it("maps a transport timeout to ChainError(timeout)", async () => { + const client = new EvmRpcClient("http://node.invalid", 60_000, transportThrowing(new HttpTransportError("timeout"))); + await expect(client.getNativeBalance(ADDR)).rejects.toMatchObject({ + code: "timeout", + message: "eth_getBalance timed out", + }); + }); + + it("keeps every other transport failure an rpc_error", async () => { + for (const kind of ["network", "http_status", "redirect", "invalid_request"] as const) { + const client = new EvmRpcClient("http://node.invalid", 60_000, transportThrowing(new HttpTransportError(kind))); + await expect(client.getNativeBalance(ADDR)).rejects.toMatchObject({ code: "rpc_error" }); + } + }); + + it("keeps a non-transport throw an rpc_error", async () => { + const client = new EvmRpcClient("http://node.invalid", 60_000, transportThrowing(new Error("boom"))); + await expect(client.getNativeBalance(ADDR)).rejects.toMatchObject({ code: "rpc_error" }); + }); +}); diff --git a/ts/src/adapters/outbound/chain/evm/evm.ts b/ts/src/adapters/outbound/chain/evm/evm.ts index 091ecf58d..f905af4ce 100644 --- a/ts/src/adapters/outbound/chain/evm/evm.ts +++ b/ts/src/adapters/outbound/chain/evm/evm.ts @@ -21,6 +21,15 @@ import type { EvmGateway, } from "../../../../application/ports/chain/gateway-provider.js"; import { assertBroadcastAllowed } from "../../../../application/services/broadcast-guard.js"; +import { + FetchHttpTransport, + HttpTransportError, + httpTransportFailure, + networkHttpConfig, + type HttpEndpointConfig, + type HttpTransport, +} from "../../http/index.js"; +import type { NetworkDescriptor } from "../../../../domain/types/index.js"; interface JsonRpcResponse { result?: unknown; @@ -29,11 +38,28 @@ interface JsonRpcResponse { export class EvmRpcClient implements EvmGateway { #id = 0; + readonly #transport: HttpTransport; constructor( - private readonly endpoint: string, - private readonly timeoutMs = 60_000, - ) {} + endpointOrNetwork: string | NetworkDescriptor | HttpEndpointConfig, + timeoutMs = 60_000, + transport?: HttpTransport, + ) { + let config: HttpEndpointConfig; + try { + config = + typeof endpointOrNetwork === "string" + ? { endpoint: endpointOrNetwork, timeoutMs, headers: {} } + : "endpoint" in endpointOrNetwork + ? endpointOrNetwork + : networkHttpConfig(endpointOrNetwork, timeoutMs); + } catch (error) { + const failure = + error instanceof HttpTransportError ? httpTransportFailure(error) : "invalid HTTP endpoint"; + throw new ChainError("rpc_error", `EVM RPC configuration failed: ${failure}`); + } + this.#transport = transport ?? new FetchHttpTransport(config); + } async getNativeBalance(address: string): Promise { return toDecimalString(await this.#call("eth_getBalance", [address, "latest"])); @@ -204,23 +230,27 @@ export class EvmRpcClient implements EvmGateway { /** the JSON-RPC envelope, unthrown — callers that classify errors themselves need to see it. */ async #request(method: string, params: unknown[]): Promise { this.#id += 1; - let response: { ok: boolean; status?: number; text(): Promise }; + let text: string; try { - response = await fetch(this.endpoint, { + text = await this.#transport.requestText({ 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}`); + // A stall is `timeout`, not `rpc_error` — the node never answered, so there is nothing to + // call an RPC failure. TronRpcClient classifies it the same way; scripts that retry on + // `timeout` need both families to agree. + if (e instanceof HttpTransportError && e.kind === "timeout") { + throw new ChainError("timeout", `${method} timed out`); + } + const failure = + e instanceof HttpTransportError ? httpTransportFailure(e) : "network request failed"; + throw new ChainError("rpc_error", `${method} failed: ${failure}`); } let body: unknown; try { - body = JSON.parse(await response.text()); + body = JSON.parse(text); } catch (e) { throw new ChainError( "rpc_error", diff --git a/ts/src/adapters/outbound/chain/tron/history-reader.ts b/ts/src/adapters/outbound/chain/tron/history-reader.ts index 23207ae87..2778bbd9c 100644 --- a/ts/src/adapters/outbound/chain/tron/history-reader.ts +++ b/ts/src/adapters/outbound/chain/tron/history-reader.ts @@ -7,6 +7,7 @@ import type { TronHistoryResult, } from "../../../../application/ports/chain/tron-history-reader.js"; import type { NetworkDescriptor } from "../../../../domain/types/index.js"; +import { FetchHttpTransport, HttpTransportError, networkHttpConfig } from "../../http/index.js"; export class TronGridHistoryReader implements HistoryPort { constructor(private readonly timeoutMs = 60_000) {} @@ -24,23 +25,27 @@ export class TronGridHistoryReader implements HistoryPort { ); } const resource = query.only === "token" ? "transactions/trc20" : "transactions"; - const url = `${endpoint.replace(/\/$/, "")}/v1/accounts/${address}/${resource}?limit=${query.limit}&visible=true`; - let response: Response; + const path = `/v1/accounts/${encodeURIComponent(address)}/${resource}?limit=${query.limit}&visible=true`; + let text: string; try { - response = await fetch(url, { signal: AbortSignal.timeout(this.timeoutMs) }); + const transport = new FetchHttpTransport(networkHttpConfig(network, this.timeoutMs)); + text = await transport.requestText({ method: "GET", path }); } catch (error) { + const status = error instanceof HttpTransportError ? error.status : undefined; throw new ExecutionError( "history_not_supported", - `account history is not supported on this endpoint: ${(error as Error).message}`, + `account history is not supported on this endpoint${status === undefined ? "" : ` (HTTP ${status})`}`, ); } - if (!response.ok) { + let body: { data?: unknown[] }; + try { + body = JSON.parse(text) as { data?: unknown[] }; + } catch { throw new ExecutionError( "history_not_supported", - `account history is not supported on this endpoint (HTTP ${response.status})`, + "account history is not supported on this endpoint (malformed response)", ); } - const body = (await response.json()) as { data?: unknown[] }; const records = (body.data ?? []).map((record) => this.normalize(record, address)); return { address, diff --git a/ts/src/adapters/outbound/chain/tron/tron.redirect.test.ts b/ts/src/adapters/outbound/chain/tron/tron.redirect.test.ts new file mode 100644 index 000000000..fe42a2def --- /dev/null +++ b/ts/src/adapters/outbound/chain/tron/tron.redirect.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { TronRpcClient } from "./tron.js"; +import type { NetworkDescriptor } from "../../../../domain/types/index.js"; + +/** + * A network `apiKey` travels as a request HEADER. `FetchHttpTransport` refuses to follow redirects + * whenever it carries one, because fetch re-sends headers to the redirect target — handing the + * credential to whoever the configured endpoint points at. + * + * tronweb does NOT use that transport: it builds its own axios instance, and axios follows + * redirects by default. These tests pin the guard on that path by asserting what the redirect + * TARGET received, not by asserting a config value that a tronweb upgrade could quietly ignore. + */ +const API_KEY = "test-credential-value"; + +let servers: Server[] = []; +afterEach(async () => { + await Promise.all(servers.map((s) => new Promise((r) => s.close(r)))); + servers = []; +}); + +function listen(handler: Parameters[1]): Promise { + const server = createServer(handler); + servers.push(server); + return new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)), + ); +} + +/** a redirecting endpoint plus the target it points at; the target records what reached it. */ +async function redirectPair() { + const received: Array> = []; + const targetPort = await listen((req, res) => { + received.push(req.headers as Record); + req.resume(); + res.setHeader("content-type", "application/json"); + res.end("{}"); + }); + const sourcePort = await listen((req, res) => { + req.resume(); + res.writeHead(302, { location: `http://127.0.0.1:${targetPort}${req.url}` }); + res.end(); + }); + return { received, endpoint: `http://127.0.0.1:${sourcePort}` }; +} + +function network(endpoint: string, credentialed: boolean): NetworkDescriptor { + return { + id: "tron:nile", + family: "tron", + chainId: "nile", + nativeSymbol: "TRX", + capabilities: [], + httpEndpoint: endpoint, + ...(credentialed ? { apiKeyHeader: "TRON-PRO-API-KEY", apiKey: API_KEY } : {}), + } as NetworkDescriptor; +} + +describe("TronRpcClient does not leak an API key through a redirect", () => { + it("refuses to follow a redirect on the tronweb path when a credential header is set", async () => { + const { received, endpoint } = await redirectPair(); + const client = new TronRpcClient(network(endpoint, true), 5_000); + + await expect(client.getAccountResources("TNmoJ3Be59WFEq5dsW6eCkZjveiL3G8HVB")).rejects.toBeTruthy(); + + expect(received).toHaveLength(0); + }); + + it("refuses to follow a redirect on the raw transport path too", async () => { + const { received, endpoint } = await redirectPair(); + const client = new TronRpcClient(network(endpoint, true), 5_000); + + await expect(client.getAccount("TNmoJ3Be59WFEq5dsW6eCkZjveiL3G8HVB")).rejects.toBeTruthy(); + + expect(received).toHaveLength(0); + }); + + // The guard is deliberately conditional: with no credential there is nothing to leak, and a + // provider that legitimately redirects must keep working. Without this, a passing suite could + // just mean redirects are broken everywhere. + it("still follows a redirect when no credential is configured", async () => { + const { received, endpoint } = await redirectPair(); + const client = new TronRpcClient(network(endpoint, false), 5_000); + + await client.getAccountResources("TNmoJ3Be59WFEq5dsW6eCkZjveiL3G8HVB").catch(() => undefined); + + expect(received.length).toBeGreaterThan(0); + expect(JSON.stringify(received)).not.toContain(API_KEY); + }); +}); diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts index 54d05ab93..5e19bae51 100644 --- a/ts/src/adapters/outbound/chain/tron/tron.ts +++ b/ts/src/adapters/outbound/chain/tron/tron.ts @@ -3,7 +3,7 @@ * Broadcaster port plus TRON-specific reads, TRC10/TRC20, Stake 2.0, and contract operations. * (builtin TRON networks carry an HTTP fullHost; tronweb is HTTP-based.) */ -import { TronWeb, utils as tronUtils } from "tronweb"; +import { providers, TronWeb, utils as tronUtils } from "tronweb"; import type { Types } from "tronweb"; import { isLosslessNumber, parse as parseLosslessJson } from "lossless-json"; import type { @@ -11,6 +11,7 @@ import type { ActivePermissionView, BroadcastResult, PermissionGroupView, + NetworkDescriptor, SignedTx, TronTransactionArtifact, UnsignedTx, @@ -59,6 +60,14 @@ import { } from "./transaction-codec.js"; import { decodeOperations } from "../../../../domain/permission/index.js"; import { addressCodec } from "../../../../domain/family/index.js"; +import { + FetchHttpTransport, + HttpTransportError, + httpTransportFailure, + networkHttpConfig, + type HttpEndpointConfig, + type HttpTransport, +} from "../../http/index.js"; /** a valid base58 owner used as the caller for read-only (constant) contract calls. */ const TRON_READ_OWNER = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; @@ -73,13 +82,50 @@ export function hexToBase58(addr: unknown): string { export class TronRpcClient implements TronGateway, Broadcaster { #tw: InstanceType; - readonly #fullHost: string; readonly #timeoutMs: number; - constructor(fullHost: string, timeoutMs = 60_000) { + readonly #transport: HttpTransport; + constructor( + fullHostOrConfig: string | NetworkDescriptor | HttpEndpointConfig, + timeoutMs = 60_000, + transport?: HttpTransport, + ) { + let config: HttpEndpointConfig; + try { + config = + typeof fullHostOrConfig === "string" + ? { endpoint: fullHostOrConfig, timeoutMs, headers: {} } + : "endpoint" in fullHostOrConfig + ? fullHostOrConfig + : networkHttpConfig(fullHostOrConfig, timeoutMs); + } catch (error) { + const failure = + error instanceof HttpTransportError ? httpTransportFailure(error) : "invalid HTTP endpoint"; + throw new TransportError("rpc_error", `TRON RPC configuration failed: ${failure}`); + } // a dummy address keeps tronweb happy for read-only/builder use (no key → cannot sign) - this.#tw = new TronWeb({ fullHost }); - this.#fullHost = fullHost.replace(/\/+$/, ""); - this.#timeoutMs = timeoutMs; + const tronProvider = () => { + const provider = new providers.HttpProvider(config.endpoint, config.timeoutMs, "", "", { + ...config.headers, + }); + // tronweb talks through its own axios instance, which follows redirects and re-sends the + // headers to wherever it lands — so a credentialed request would hand the API key to + // whatever host the endpoint points at. Mirror FetchHttpTransport's `redirect: "error"`. + // Conditional on purpose: with nothing to leak, a provider that legitimately redirects + // must keep working. + if (Object.keys(config.headers).length > 0) { + // tronweb types `instance` as request-only; the axios defaults are there at runtime. + const axios = provider.instance as unknown as { defaults: { maxRedirects: number } }; + axios.defaults.maxRedirects = 0; + } + return provider; + }; + this.#tw = new TronWeb({ + fullNode: tronProvider(), + solidityNode: tronProvider(), + eventServer: tronProvider(), + }); + this.#timeoutMs = config.timeoutMs; + this.#transport = transport ?? new FetchHttpTransport(config); this.#tw.setAddress(TRON_READ_OWNER); } get tronweb(): InstanceType { @@ -431,6 +477,12 @@ export class TronRpcClient implements TronGateway, Broadcaster { } catch (e) { if (e instanceof ChainError || e instanceof UsageError || e instanceof TransportError) throw e; + if (e instanceof HttpTransportError) { + if (e.kind === "timeout") { + throw new ChainError("timeout", `TRON ${label} timed out`); + } + throw new TransportError("rpc_error", `TRON ${label} failed: ${httpTransportFailure(e)}`); + } throw new TransportError( "rpc_error", `TRON ${label} failed: ${redactErrorMessage((e as Error).message?.split("\n")[0] ?? "")}`, @@ -441,40 +493,28 @@ export class TronRpcClient implements TronGateway, Broadcaster { // ── account / query ────────────────────────────────────────────────────────── /** node POST returning the raw body, so the caller can parse it losslessly. */ async #post(path: string, body: unknown): Promise { - const response = await fetch(`${this.#fullHost}${path}`, { + return this.#transport.requestText({ method: "POST", + path, headers: { "content-type": "application/json" }, body: JSON.stringify(body), - signal: AbortSignal.timeout(this.#timeoutMs), }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return await response.text(); } async getAccount(address: string): Promise { return this.#wrap("getAccount", async () => { - const response = await fetch(`${this.#fullHost}/wallet/getaccount`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ address: this.#tw.address.toHex(address) }), - signal: AbortSignal.timeout(this.#timeoutMs), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return parseTronAccountResponse(await response.text()); + return parseTronAccountResponse( + await this.#post("/wallet/getaccount", { address: this.#tw.address.toHex(address) }), + ); }); } 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({ + return parseTronAccountResponse( + await this.#post("/wallet/getaccountbyid", { 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 { @@ -486,14 +526,9 @@ export class TronRpcClient implements TronGateway, Broadcaster { numberOrLatest === undefined ? undefined : this.#safeNumber(numberOrLatest, "block number"); return this.#wrap("getBlock", async () => { const endpoint = height === undefined ? "getnowblock" : "getblockbynum"; - const response = await fetch(`${this.#fullHost}/wallet/${endpoint}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(height === undefined ? {} : { num: height }), - signal: AbortSignal.timeout(this.#timeoutMs), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return parseTronBlockResponse(await response.text()); + return parseTronBlockResponse( + await this.#post(`/wallet/${endpoint}`, height === undefined ? {} : { num: height }), + ); }); } async getTransactionById(txid: string): Promise { @@ -1062,17 +1097,15 @@ export class TronRpcClient implements TronGateway, Broadcaster { async getWitnesses(limit: number): Promise { const capped = Math.min(Math.max(Math.trunc(limit), 1), 127); return this.#wrap("getNowWitnessList", async () => { - const response = await fetch(`${this.#fullHost}/wallet/getpaginatednowwitnesslist`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ offset: 0, limit: capped, visible: true }), - signal: AbortSignal.timeout(this.#timeoutMs), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record< - string, - unknown - >; + const raw = normalizeAccountValue( + parseLosslessJson( + await this.#post("/wallet/getpaginatednowwitnesslist", { + offset: 0, + limit: capped, + visible: true, + }), + ), + ) as Record; const witnesses = Array.isArray(raw.witnesses) ? raw.witnesses : []; return witnesses.map(normalizeWitness).filter((w): w is TronWitness => w !== null); }); @@ -1092,17 +1125,9 @@ export class TronRpcClient implements TronGateway, Broadcaster { */ async getWitness(address: string): Promise { return this.#wrap("listWitnesses", async () => { - const response = await fetch(`${this.#fullHost}/wallet/listwitnesses`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{}", - signal: AbortSignal.timeout(this.#timeoutMs), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record< - string, - unknown - >; + const raw = normalizeAccountValue( + parseLosslessJson(await this.#post("/wallet/listwitnesses", {})), + ) as Record; const witnesses = Array.isArray(raw.witnesses) ? raw.witnesses : []; // normalizeWitness yields base58; compare against the caller's ref in the same form. const wanted = hexToBase58(address) || address; @@ -1117,17 +1142,9 @@ export class TronRpcClient implements TronGateway, Broadcaster { } async getProposals(): Promise { return this.#wrap("listProposals", async () => { - const response = await fetch(`${this.#fullHost}/wallet/listproposals`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{}", - signal: AbortSignal.timeout(this.#timeoutMs), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record< - string, - unknown - >; + const raw = normalizeAccountValue( + parseLosslessJson(await this.#post("/wallet/listproposals", {})), + ) as Record; const proposals = Array.isArray(raw.proposals) ? raw.proposals : []; return proposals .map(normalizeProposal) @@ -1136,14 +1153,11 @@ export class TronRpcClient implements TronGateway, Broadcaster { } async getProposal(id: number): Promise { return this.#wrap("getProposalById", async () => { - const response = await fetch(`${this.#fullHost}/wallet/getproposalbyid`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ id }), - signal: AbortSignal.timeout(this.#timeoutMs), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return normalizeProposal(normalizeAccountValue(parseLosslessJson(await response.text()))); + return normalizeProposal( + normalizeAccountValue( + parseLosslessJson(await this.#post("/wallet/getproposalbyid", { id })), + ), + ); }); } async buildProposalCreate( @@ -1225,17 +1239,13 @@ export class TronRpcClient implements TronGateway, Broadcaster { } async getBrokerage(address: string): Promise { return this.#wrap("getBrokerage", async () => { - const response = await fetch(`${this.#fullHost}/wallet/getBrokerage`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ address: this.#tw.address.toHex(address) }), - signal: AbortSignal.timeout(this.#timeoutMs), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record< - string, - unknown - >; + const raw = normalizeAccountValue( + parseLosslessJson( + await this.#post("/wallet/getBrokerage", { + address: this.#tw.address.toHex(address), + }), + ), + ) as Record; if (raw.brokerage === undefined) throw new Error("brokerage not found"); const brokerage = Number(raw.brokerage); return Number.isFinite(brokerage) ? brokerage : 0; @@ -1243,17 +1253,11 @@ export class TronRpcClient implements TronGateway, Broadcaster { } async getReward(address: string): Promise { return this.#wrap("getReward", async () => { - const response = await fetch(`${this.#fullHost}/wallet/getReward`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ address: this.#tw.address.toHex(address) }), - signal: AbortSignal.timeout(this.#timeoutMs), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record< - string, - unknown - >; + const raw = normalizeAccountValue( + parseLosslessJson( + await this.#post("/wallet/getReward", { address: this.#tw.address.toHex(address) }), + ), + ) as Record; return quantityString(raw.reward); }); } diff --git a/ts/src/adapters/outbound/config/config.test.ts b/ts/src/adapters/outbound/config/config.test.ts index 5866a71cb..6a1603a08 100644 --- a/ts/src/adapters/outbound/config/config.test.ts +++ b/ts/src/adapters/outbound/config/config.test.ts @@ -369,3 +369,36 @@ describe("a hand-added network is validated at load", () => { expect(net).toMatchObject({ nativeSymbol: "ETH", httpEndpoint: "https://mine.example" }); }); }); + +// An RPC apiKey is a credential like the service ones, and it lives NESTED under a network — the +// permission gate has to look inside `networks`, not only at the top-level keys. +describe("ConfigLoader network API key", () => { + const withKey = [ + "networks:", + " tron:nile:", + " httpEndpoint: https://nile.trongrid.io", + " apiKeyHeader: TRON-PRO-API-KEY", + " apiKey: TESTTESTTEST", + "", + ].join("\n"); + + it("loads the pair from a private config file", () => { + const config = ConfigLoader.load(envWithConfig(withKey, 0o600)); + expect(config.networks["tron:nile"]).toMatchObject({ + apiKeyHeader: "TRON-PRO-API-KEY", + apiKey: "TESTTESTTEST", + }); + }); + + it.runIf(process.platform !== "win32")( + "rejects a network API key in a group/world-readable file", + () => { + expect(() => ConfigLoader.load(envWithConfig(withKey, 0o644))).toThrow(/mode 0600/); + }, + ); + + it("leaves a file without any credential unrestricted", () => { + const noKey = ["networks:", " tron:nile:", " apiKeyHeader: X-Api-Key", ""].join("\n"); + expect(() => ConfigLoader.load(envWithConfig(noKey, 0o644))).not.toThrow(); + }); +}); diff --git a/ts/src/adapters/outbound/config/index.ts b/ts/src/adapters/outbound/config/index.ts index 957069db2..b6fd06add 100644 --- a/ts/src/adapters/outbound/config/index.ts +++ b/ts/src/adapters/outbound/config/index.ts @@ -48,7 +48,10 @@ export class ConfigLoader { const raw = readConfigDocument(path); if ( (typeof raw.tronlinkSecretKey === "string" && raw.tronlinkSecretKey !== "") || - (typeof raw.gasfreeApiSecret === "string" && raw.gasfreeApiSecret !== "") + (typeof raw.gasfreeApiSecret === "string" && raw.gasfreeApiSecret !== "") || + // A network's RPC apiKey is a credential too, and it sits NESTED under `networks`; a gate + // that only inspected top-level keys would hand out a 644 file holding one. + holdsNetworkApiKey(raw.networks) ) { assertSecretConfigPermissions(path); } @@ -178,6 +181,15 @@ function readConfigDocument(path: string) { } } +/** true when any network in the document carries a non-empty `apiKey`. */ +function holdsNetworkApiKey(networks: unknown): boolean { + if (!networks || typeof networks !== "object") return false; + return Object.values(networks as Record).some((network) => { + const key = (network as { apiKey?: unknown } | null)?.apiKey; + return typeof key === "string" && key !== ""; + }); +} + function assertSecretConfigPermissions(path: string): void { if (process.platform === "win32") return; if (lstatSync(path).isSymbolicLink()) { diff --git a/ts/src/adapters/outbound/http/index.test.ts b/ts/src/adapters/outbound/http/index.test.ts new file mode 100644 index 000000000..f3f4485f5 --- /dev/null +++ b/ts/src/adapters/outbound/http/index.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import { FetchHttpTransport, networkHttpConfig } from "./index.js"; + +const network = (overrides: Partial = {}): NetworkDescriptor => + ({ + id: "evm:1", + family: "evm", + chainId: "1", + nativeSymbol: "ETH", + capabilities: [], + httpEndpoint: "https://rpc.example/v2/url-key", + ...overrides, + }) as NetworkDescriptor; + +describe("networkHttpConfig", () => { + it("turns a complete network API-key pair into the request header", () => { + expect( + networkHttpConfig( + network({ apiKeyHeader: "X-Provider-Key", apiKey: "header-secret" }), + 12_345, + ), + ).toEqual({ + endpoint: "https://rpc.example/v2/url-key", + timeoutMs: 12_345, + headers: { "X-Provider-Key": "header-secret" }, + }); + }); + + it.each([ + { + label: "header only", + credentials: { apiKeyHeader: "X-Provider-Key", apiKey: undefined }, + }, + { label: "key only", credentials: { apiKeyHeader: undefined, apiKey: "header-secret" } }, + ] as const)("emits no credential for an incomplete pair: $label", ({ credentials }) => { + expect(networkHttpConfig(network(credentials), 12_345).headers).toEqual({}); + }); +}); + +describe("FetchHttpTransport", () => { + it("sends the network credential and returns the response as raw text", async () => { + const seen: Array<{ url: string; init?: RequestInit }> = []; + const fetchFn = async (url: string | URL | Request, init?: RequestInit) => { + seen.push({ url: String(url), init }); + return new Response('{"amount":9007199254740993}', { status: 200 }); + }; + const transport = new FetchHttpTransport( + networkHttpConfig( + network({ apiKeyHeader: "X-Provider-Key", apiKey: "header-secret" }), + 12_345, + ), + fetchFn, + ); + + const text = await transport.requestText({ + method: "POST", + path: "/wallet/getaccount", + headers: { "content-type": "application/json" }, + body: "{}", + }); + + expect(text).toBe('{"amount":9007199254740993}'); + expect(seen).toHaveLength(1); + expect(seen[0]!.url).toBe("https://rpc.example/v2/url-key/wallet/getaccount"); + const headers = new Headers(seen[0]!.init?.headers); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("x-provider-key")).toBe("header-secret"); + }); + + it("lets the network credential override a protocol header case-insensitively", async () => { + let sent: Headers | undefined; + const transport = new FetchHttpTransport( + networkHttpConfig( + network({ apiKeyHeader: "X-Provider-Key", apiKey: "network-secret" }), + 12_345, + ), + async (_url, init) => { + sent = new Headers(init?.headers); + return new Response("{}", { status: 200 }); + }, + ); + + await transport.requestText({ + method: "POST", + headers: { "x-provider-key": "caller-value" }, + body: "{}", + }); + + expect(sent?.get("X-Provider-Key")).toBe("network-secret"); + }); + + it("rejects an absolute request URL before credentials can leave their endpoint", async () => { + const fetchFn = vi.fn(); + const transport = new FetchHttpTransport( + networkHttpConfig( + network({ apiKeyHeader: "X-Provider-Key", apiKey: "header-secret" }), + 12_345, + ), + fetchFn, + ); + + await expect( + transport.requestText({ method: "GET", path: "https://attacker.example/collect" }), + ).rejects.toMatchObject({ name: "HttpTransportError", kind: "invalid_request" }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("turns a non-success status into a safe typed error without exposing the response", async () => { + const transport = new FetchHttpTransport( + networkHttpConfig(network(), 12_345), + async () => new Response("server leaked header-secret", { status: 429 }), + ); + + const error = await transport + .requestText({ method: "POST", body: "request held header-secret" }) + .catch((caught: unknown) => caught); + expect(error).toMatchObject({ + name: "HttpTransportError", + kind: "http_status", + status: 429, + }); + expect(String(error)).not.toContain("header-secret"); + }); + + it("aborts at the configured deadline and reports a typed timeout", async () => { + const transport = new FetchHttpTransport( + networkHttpConfig(network(), 10), + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("secret network detail", "AbortError")), + ); + }), + ); + + await expect(transport.requestText({ method: "GET" })).rejects.toMatchObject({ + name: "HttpTransportError", + kind: "timeout", + }); + }); + + it("refuses to follow redirects whenever a network credential is attached", async () => { + let init: RequestInit | undefined; + const transport = new FetchHttpTransport( + networkHttpConfig( + network({ apiKeyHeader: "X-Provider-Key", apiKey: "header-secret" }), + 12_345, + ), + async (_url, requestInit) => { + init = requestInit; + return new Response("{}", { status: 200 }); + }, + ); + + await transport.requestText({ method: "GET" }); + expect(init?.redirect).toBe("error"); + }); + + it("rejects a missing endpoint before attempting I/O", async () => { + const fetchFn = vi.fn(); + const transport = new FetchHttpTransport( + { endpoint: "", timeoutMs: 12_345, headers: {} }, + fetchFn, + ); + + await expect(transport.requestText({ method: "POST", body: "{}" })).rejects.toMatchObject({ + name: "HttpTransportError", + kind: "invalid_endpoint", + }); + expect(fetchFn).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/adapters/outbound/http/index.ts b/ts/src/adapters/outbound/http/index.ts new file mode 100644 index 000000000..a3c4a204a --- /dev/null +++ b/ts/src/adapters/outbound/http/index.ts @@ -0,0 +1,115 @@ +import type { NetworkDescriptor } from "../../../domain/types/index.js"; + +export interface HttpEndpointConfig { + readonly endpoint: string; + readonly timeoutMs: number; + readonly headers: Readonly>; +} + +export interface HttpRequest { + readonly method: "GET" | "POST"; + readonly path?: string; + readonly body?: string; + readonly headers?: Readonly>; +} + +export interface HttpTransport { + requestText(request: HttpRequest): Promise; +} + +export type HttpTransportErrorKind = + "invalid_endpoint" | "invalid_request" | "timeout" | "network" | "http_status" | "redirect"; + +/** Safe internal transport failure. It deliberately carries no URL, body, header, or remote text. */ +export class HttpTransportError extends Error { + constructor( + public readonly kind: HttpTransportErrorKind, + public readonly status?: number, + ) { + super(status === undefined ? `HTTP transport ${kind}` : `HTTP transport ${kind}: ${status}`); + this.name = "HttpTransportError"; + } +} + +export function httpTransportFailure(error: HttpTransportError): string { + if (error.kind === "timeout") return "request timed out"; + if (error.kind === "http_status" && error.status !== undefined) return `HTTP ${error.status}`; + if (error.kind === "invalid_endpoint") return "invalid HTTP endpoint"; + if (error.kind === "invalid_request") return "invalid HTTP request"; + return "network request failed"; +} + +type FetchFunction = typeof globalThis.fetch; + +/** Resolve the HTTP mechanics shared by every adapter that uses a network's httpEndpoint. */ +export function networkHttpConfig( + network: NetworkDescriptor, + timeoutMs: number, +): HttpEndpointConfig { + assertHttpEndpoint(network.httpEndpoint ?? ""); + const headers = + network.apiKeyHeader && network.apiKey ? { [network.apiKeyHeader]: network.apiKey } : {}; + return { + endpoint: network.httpEndpoint ?? "", + timeoutMs, + headers, + }; +} + +function assertHttpEndpoint(endpoint: string): void { + try { + const url = new URL(endpoint); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new HttpTransportError("invalid_endpoint"); + } + } catch (error) { + if (error instanceof HttpTransportError) throw error; + throw new HttpTransportError("invalid_endpoint"); + } +} + +/** Native HTTP adapter. Protocol-specific parsing deliberately stays above this interface. */ +export class FetchHttpTransport implements HttpTransport { + constructor( + private readonly config: HttpEndpointConfig, + private readonly fetchFn: FetchFunction = globalThis.fetch, + ) {} + + async requestText(request: HttpRequest): Promise { + const headers = new Headers(request.headers); + for (const [name, value] of Object.entries(this.config.headers)) headers.set(name, value); + try { + const response = await this.fetchFn(requestUrl(this.config.endpoint, request.path), { + method: request.method, + headers, + body: request.body, + signal: AbortSignal.timeout(this.config.timeoutMs), + ...(Object.keys(this.config.headers).length === 0 ? {} : { redirect: "error" }), + }); + if (!response.ok) throw new HttpTransportError("http_status", response.status); + return await response.text(); + } catch (error) { + if (error instanceof HttpTransportError) throw error; + if ( + error instanceof Error && + (error.name === "AbortError" || error.name === "TimeoutError") + ) { + throw new HttpTransportError("timeout"); + } + throw new HttpTransportError("network"); + } + } +} + +function requestUrl(endpoint: string, path: string | undefined): string { + assertHttpEndpoint(endpoint); + const url = new URL(endpoint); + if (path === undefined) return endpoint; + if (/^[A-Za-z][A-Za-z\d+.-]*:/.test(path) || /^[/\\]{2}/.test(path)) { + throw new HttpTransportError("invalid_request"); + } + const [pathname, query = ""] = path.split("?", 2); + url.pathname = `${url.pathname.replace(/\/+$/, "")}/${pathname!.replace(/^\/+/, "")}`; + for (const [name, value] of new URLSearchParams(query)) url.searchParams.append(name, value); + return url.toString(); +} diff --git a/ts/src/adapters/outbound/http/wiring.test.ts b/ts/src/adapters/outbound/http/wiring.test.ts new file mode 100644 index 000000000..0bcd71e23 --- /dev/null +++ b/ts/src/adapters/outbound/http/wiring.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { evmFamily } from "../../../bootstrap/families/evm.js"; +import { tronFamily } from "../../../bootstrap/families/tron.js"; +import { TronGridHistoryReader } from "../chain/tron/history-reader.js"; +import type { EvmNetworkDescriptor, TronNetworkDescriptor } from "../../../domain/types/index.js"; + +let server: Server | undefined; + +afterEach(async () => { + if (!server) return; + await new Promise((resolve, reject) => + server!.close((error) => (error ? reject(error) : resolve())), + ); + server = undefined; +}); + +async function listen( + handler: (request: IncomingMessage, response: ServerResponse) => void, +): Promise { + server = createServer(handler); + await new Promise((resolve) => server!.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; +} + +describe("network API-key wire coverage", () => { + it("maps a missing HTTP endpoint to the existing RPC error before I/O", () => { + const evm = { + id: "evm:1", + family: "evm", + chainId: "1", + nativeSymbol: "ETH", + capabilities: [], + } satisfies EvmNetworkDescriptor; + const tron = { + id: "tron:nile", + family: "tron", + chainId: "nile", + nativeSymbol: "TRX", + capabilities: [], + } satisfies TronNetworkDescriptor; + + for (const create of [ + () => evmFamily.createGateway(evm, 1_000), + () => tronFamily.createGateway(tron, 1_000), + ]) { + let error: unknown; + try { + create(); + } catch (caught) { + error = caught; + } + expect(error).toMatchObject({ code: "rpc_error" }); + } + }); + + it("sends the configured header through the assembled EVM gateway", async () => { + let receivedKey: string | undefined; + const endpoint = await listen((request, response) => { + receivedKey = request.headers["x-provider-key"] as string | undefined; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x0" })); + }); + const network: EvmNetworkDescriptor = { + id: "evm:1", + family: "evm", + chainId: "1", + nativeSymbol: "ETH", + capabilities: [], + httpEndpoint: endpoint, + apiKeyHeader: "X-Provider-Key", + apiKey: "header-secret", + }; + + await evmFamily + .createGateway(network, 1_000) + .getNativeBalance("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + + expect(receivedKey).toBe("header-secret"); + }); + + it("sends the configured header through a direct TRON FullNode request", async () => { + let receivedKey: string | undefined; + const endpoint = await listen((request, response) => { + receivedKey = request.headers["tron-pro-api-key"] as string | undefined; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ balance: 42 })); + }); + const network: TronNetworkDescriptor = { + id: "tron:nile", + family: "tron", + chainId: "nile", + nativeSymbol: "TRX", + capabilities: [], + httpEndpoint: endpoint, + apiKeyHeader: "TRON-PRO-API-KEY", + apiKey: "tron-secret", + }; + + await tronFamily + .createGateway(network, 1_000) + .getNativeBalance("T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"); + + expect(receivedKey).toBe("tron-secret"); + }); + + it("sends the configured header through TronWeb's HTTP provider", async () => { + let receivedKey: string | undefined; + const endpoint = await listen((request, response) => { + receivedKey = request.headers["tron-pro-api-key"] as string | undefined; + response.setHeader("content-type", "application/json"); + response.end("{}"); + }); + const network: TronNetworkDescriptor = { + id: "tron:nile", + family: "tron", + chainId: "nile", + nativeSymbol: "TRX", + capabilities: [], + httpEndpoint: endpoint, + apiKeyHeader: "TRON-PRO-API-KEY", + apiKey: "tron-secret", + }; + + await tronFamily.createGateway(network, 1_000).getNodeInfo(); + + expect(receivedKey).toBe("tron-secret"); + }); + + it("sends the configured header through the TRON History request", async () => { + let receivedKey: string | undefined; + const endpoint = await listen((request, response) => { + receivedKey = request.headers["tron-pro-api-key"] as string | undefined; + response.setHeader("content-type", "application/json"); + response.end('{"data":[]}'); + }); + const network: TronNetworkDescriptor = { + id: "tron:nile", + family: "tron", + chainId: "nile", + nativeSymbol: "TRX", + capabilities: [], + httpEndpoint: endpoint, + apiKeyHeader: "TRON-PRO-API-KEY", + apiKey: "tron-secret", + }; + + await new TronGridHistoryReader(1_000).get(network, "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb", { + limit: 1, + }); + + expect(receivedKey).toBe("tron-secret"); + }); +}); diff --git a/ts/src/application/use-cases/config-service.test.ts b/ts/src/application/use-cases/config-service.test.ts index 0dd693957..5c38bc1b7 100644 --- a/ts/src/application/use-cases/config-service.test.ts +++ b/ts/src/application/use-cases/config-service.test.ts @@ -140,14 +140,14 @@ const registry = { // §2.4: `config networks` used to return only ids, so there was no way to confirm an endpoint // change had taken effect. describe("ConfigService networks view", () => { - it("maps each canonical id to its endpoint host", () => { + it("maps each canonical id to its configurable fields, endpoint trimmed to the host", () => { const { svc } = service(); expect(svc.execute({ key: "networks" }, twoNetworks, registry)).toMatchObject({ key: "networks", value: { - "tron:nile": "nile.trongrid.io", + "tron:nile": { httpEndpoint: "nile.trongrid.io" }, // host only — an endpoint may carry an API key in its path - "evm:11155111": "sepolia.example", + "evm:11155111": { httpEndpoint: "sepolia.example" }, }, }); }); @@ -271,3 +271,176 @@ describe("ConfigService reads a nested network key", () => { ); }); }); + +// §2.4 (revised): `config` shows what a user can SET. A network therefore renders as an object of +// its configurable fields — endpoint plus the API-key pair — not as a bare endpoint string, so a +// new configurable field appears everywhere at once instead of needing a display site per view. +const keyedNetworks = { + timeoutMs: 60_000, + waitTimeoutMs: 60_000, + aliases: { nile: "tron:nile", sepolia: "evm:11155111" }, + networks: { + "tron:nile": { + id: "tron:nile", + httpEndpoint: "https://nile.trongrid.io", + apiKeyHeader: "TRON-PRO-API-KEY", + apiKey: "topsecret", + }, + "evm:11155111": { id: "evm:11155111", httpEndpoint: "https://sepolia.example/abc123" }, + }, +} as unknown as Config; + +const keyedRegistry = { + resolve: (id: string) => { + const key = { nile: "tron:nile", sepolia: "evm:11155111" }[id] ?? id; + const net = (keyedNetworks.networks as Record)[key]; + if (!net) throw new Error(`unknown network: ${id}`); + return net; + }, +} as unknown as NetworkRegistry; + +describe("ConfigService network subtree read", () => { + it("reads one network as its configurable fields", () => { + const { svc } = service(); + expect(svc.execute({ key: "networks.tron:nile" }, keyedNetworks, keyedRegistry)).toEqual({ + key: "networks.tron:nile", + value: { + httpEndpoint: "https://nile.trongrid.io", + apiKeyHeader: "TRON-PRO-API-KEY", + apiKey: "********", + }, + }); + }); + + // Naming ONE network is as deliberate an act as naming its endpoint leaf, so it reveals the + // full URL; the breadth-first listings below stay trimmed to the host. + it("gives the full endpoint URL when a single network is named", () => { + const { svc } = service(); + expect( + svc.execute({ key: "networks.evm:11155111" }, keyedNetworks, keyedRegistry), + ).toMatchObject({ value: { httpEndpoint: "https://sepolia.example/abc123" } }); + }); + + it("resolves an alias to the canonical id, exactly as the leaf read does", () => { + const { svc } = service(); + expect(svc.execute({ key: "networks.sepolia" }, keyedNetworks, keyedRegistry)).toMatchObject({ + key: "networks.evm:11155111", + }); + }); + + it("omits fields the network has not configured", () => { + const { svc } = service(); + const value = ( + svc.execute({ key: "networks.evm:11155111" }, keyedNetworks, keyedRegistry) as { + value: Record; + } + ).value; + expect(Object.keys(value)).toEqual(["httpEndpoint"]); + }); + + it("rejects an unknown network", () => { + const { svc } = service(); + expect(() => svc.execute({ key: "networks.dogechain" }, keyedNetworks, keyedRegistry)).toThrow( + /dogechain/, + ); + }); +}); + +describe("ConfigService network listings keep endpoints host-only", () => { + it("nests each network under its id in the networks view", () => { + const { svc } = service(); + expect(svc.execute({ key: "networks" }, keyedNetworks, keyedRegistry)).toMatchObject({ + key: "networks", + value: { + "tron:nile": { + httpEndpoint: "nile.trongrid.io", + apiKeyHeader: "TRON-PRO-API-KEY", + apiKey: "********", + }, + "evm:11155111": { httpEndpoint: "sepolia.example" }, + }, + }); + }); + + it("nests them the same way in the whole-config view", () => { + const { svc } = service(); + expect(svc.execute({}, keyedNetworks, keyedRegistry)).toMatchObject({ + networks: { "tron:nile": { httpEndpoint: "nile.trongrid.io" } }, + }); + }); +}); + +// An RPC API key is a credential: it must never come back out of the config surface, at any depth. +describe("ConfigService apiKey is write-only", () => { + it("masks it in the leaf read", () => { + const { svc } = service(); + expect(svc.execute({ key: "networks.nile.apiKey" }, keyedNetworks, keyedRegistry)).toEqual({ + key: "networks.tron:nile.apiKey", + value: "********", + }); + }); + + it("masks it when the whole config is dumped", () => { + const { svc } = service(); + expect(JSON.stringify(svc.execute({}, keyedNetworks, keyedRegistry))).not.toContain( + "topsecret", + ); + }); +}); + +describe("ConfigService writes the API-key pair", () => { + it("writes apiKeyHeader under the canonical id", () => { + const { svc, update } = service(); + expect( + svc.execute( + { key: "networks.nile.apiKeyHeader", value: "TRON-PRO-API-KEY" }, + keyedNetworks, + keyedRegistry, + ), + ).toMatchObject({ key: "networks.tron:nile.apiKeyHeader", value: "TRON-PRO-API-KEY" }); + const document = update.mock.calls[0]![0]({}).document as Record; + expect(document.networks["tron:nile"]).toEqual({ apiKeyHeader: "TRON-PRO-API-KEY" }); + }); + + it("never echoes the apiKey it just wrote", () => { + const { svc, update } = service(); + expect( + svc.execute( + { key: "networks.nile.apiKey", value: "topsecret" }, + keyedNetworks, + keyedRegistry, + ), + ).toMatchObject({ key: "networks.tron:nile.apiKey", value: "********", input: "********" }); + const document = update.mock.calls[0]![0]({}).document as Record; + expect(document.networks["tron:nile"].apiKey).toBe("topsecret"); + }); + + // A header NAME travels into an HTTP request line; a newline in it would be header injection. + it("rejects a header name that is not an HTTP token", () => { + const { svc, update } = service(); + for (const bad of ["X-Key: injected", "X-Key\nHost: evil", "", "X Key"]) { + expect(() => + svc.execute( + { key: "networks.nile.apiKeyHeader", value: bad }, + keyedNetworks, + keyedRegistry, + ), + ).toThrow(); + } + expect(update).not.toHaveBeenCalled(); + }); + + it("rejects an apiKey with control characters", () => { + const { svc } = service(); + expect(() => + svc.execute({ key: "networks.nile.apiKey", value: "abc\ndef" }, keyedNetworks, keyedRegistry), + ).toThrow(); + }); + + it("still rejects a sub-key outside the configurable set", () => { + const { svc } = service(); + expect(() => + svc.execute({ key: "networks.nile.chainId", value: "9" }, keyedNetworks, keyedRegistry), + ).toThrow(/apiKeyHeader/); + }); +}); diff --git a/ts/src/application/use-cases/config-service.ts b/ts/src/application/use-cases/config-service.ts index 22ea03ba0..7e8adfb48 100644 --- a/ts/src/application/use-cases/config-service.ts +++ b/ts/src/application/use-cases/config-service.ts @@ -31,23 +31,38 @@ export type ConfigKey = (typeof CONFIG_KEYS)[number]; export type WritableConfigKey = (typeof WRITABLE_CONFIG_KEYS)[number]; export interface ConfigCommandInput { - /** a flat key, or the nested `networks..httpEndpoint` path (§2.4). */ + /** a flat key, or a nested `networks.[.]` path (§2.4). */ key?: string; value?: string; } -/** `networks..httpEndpoint` — the only nested key. Parsed, not string-matched, so a - * wrong sub-key says which one is supported instead of "read-only". */ -const NETWORK_ENDPOINT_KEY = /^networks\.(.+)\.([^.]+)$/; +/** + * What a user may configure ON a network — as opposed to what the network IS (family, chainId, + * feeModel, gasfree…), which `wallet-cli networks` and `chain node` report. + * + * `config` renders exactly this set, so a field added here surfaces in the whole-config view, the + * `networks` listing, the single-network read and `--output json` at once, with no display site + * to update per view. + */ +export const NETWORK_CONFIG_FIELDS = ["httpEndpoint", "apiKeyHeader", "apiKey"] as const; +export type NetworkConfigField = (typeof NETWORK_CONFIG_FIELDS)[number]; -interface NetworkEndpointKey { +/** `networks.` (the whole network) or `networks..` (one field). */ +const NETWORK_KEY = /^networks\.(.+)\.([^.]+)$/; + +interface NetworkKey { networkRef: string; - field: string; + /** absent → the caller addressed the network itself, not one of its fields. */ + field?: string; } -function parseNetworkKey(key: string): NetworkEndpointKey | null { - const match = NETWORK_ENDPOINT_KEY.exec(key); - return match ? { networkRef: match[1]!, field: match[2]! } : null; +/** A canonical id holds a colon, never a dot (`tron:nile`), so the last dot — when there is one — + * always separates the field. An alias containing a dot would be misread as `.`; the + * book is hand-written and no builtin does that. */ +function parseNetworkKey(key: string): NetworkKey | null { + const match = NETWORK_KEY.exec(key); + if (match) return { networkRef: match[1]!, field: match[2]! }; + return key.startsWith("networks.") ? { networkRef: key.slice("networks.".length) } : null; } export class ConfigService { @@ -63,10 +78,11 @@ export class ConfigService { defaultOutput: effective.defaultOutput, timeoutMs: effective.timeoutMs, waitTimeoutMs: effective.waitTimeoutMs, - // canonical id -> endpoint HOST. Ids alone gave no way to confirm a change took effect, - // and the full URL may carry an API key this listing has no business echoing. + // canonical id -> its configurable fields. A LISTING keeps the endpoint trimmed to its host: + // the full URL may carry an API key in its path, and this is output people paste whole. + // Naming one network (`config networks.`) is the deliberate act that reveals it. networks: Object.fromEntries( - Object.entries(effective.networks).map(([id, n]) => [id, endpointHost(n.httpEndpoint)]), + Object.entries(effective.networks).map(([id, n]) => [id, networkView(n, false)]), ), // Read-only, and the book's only visibility surface: there is no `config set aliases.*`, // so without this the only way to see what a short name resolves to is to open config.yaml. @@ -108,25 +124,31 @@ export class ConfigService { })); } - /** `networks..httpEndpoint` — the key's network ref is normalised to its canonical + /** `networks..` — the key's network ref is normalised to its canonical * id before writing, so config.yaml can never hold the same network under two names (§2.4). */ private setNetworkField( - { networkRef, field }: NetworkEndpointKey, + { networkRef, field }: NetworkKey, value: string, networks: NetworkRegistry, ): Record { - assertWritableNetworkField(field); + const configurable = assertConfigurableNetworkField(field); const id = networks.resolve(networkRef).id; - const key = `networks.${id}.httpEndpoint`; - const endpoint = httpsEndpoint(value, key); + const key = `networks.${id}.${configurable}`; + const normalized = normalizeNetworkValue(configurable, value, key); + // The key never travels back out, in the receipt or in the echoed input. + const secret = configurable === "apiKey"; return this.documents.update((current) => { const existing = (current as { networks?: Record> }).networks; return { document: { ...current, - networks: { ...existing, [id]: { ...existing?.[id], httpEndpoint: endpoint } }, + networks: { ...existing, [id]: { ...existing?.[id], [configurable]: normalized } }, + }, + result: { + key, + value: secret ? maskSecret(normalized) : normalized, + input: secret ? "********" : value, }, - result: { key, value: endpoint, input: value }, }; }); } @@ -176,26 +198,92 @@ function maskSecret(value: string | undefined): string | undefined { return value ? "********" : undefined; } -/** Reading the same key that `config set` writes — addressed by alias or canonical id alike, and - * answered with the effective value rather than only what config.yaml happens to hold. */ +/** + * One network's configurable fields, or a single field of it — addressed by alias or canonical id + * alike, and answered with the effective value rather than only what config.yaml happens to hold. + * + * Naming ONE network is as deliberate as naming its endpoint leaf, so both reveal the endpoint in + * full; only the breadth-first listings (`config`, `config networks`) trim it to the host. + */ function readNetworkField( - { networkRef, field }: NetworkEndpointKey, + { networkRef, field }: NetworkKey, effective: Config, networks: NetworkRegistry, ): Record { - assertWritableNetworkField(field); const id = networks.resolve(networkRef).id; - return { key: `networks.${id}.httpEndpoint`, value: effective.networks[id]?.httpEndpoint }; + const network = effective.networks[id]; + if (field === undefined) { + return { key: `networks.${id}`, value: network ? networkView(network, true) : {} }; + } + const configurable = assertConfigurableNetworkField(field); + const value = network?.[configurable]; + return { + key: `networks.${id}.${configurable}`, + value: configurable === "apiKey" ? maskSecret(value) : value, + }; +} + +/** the configurable sub-keys; named in the error so a typo says which ones are supported. */ +function assertConfigurableNetworkField(field: string | undefined): NetworkConfigField { + if (!(NETWORK_CONFIG_FIELDS as readonly string[]).includes(field ?? "")) { + throw new UsageError( + "invalid_value", + `only networks..{${NETWORK_CONFIG_FIELDS.join(" | ")}} is readable or writable; got networks..${field}`, + ); + } + return field as NetworkConfigField; +} + +/** the user-configurable half of a network; `full` reveals an endpoint URL that may carry a key. */ +function networkView( + network: { httpEndpoint?: string; apiKeyHeader?: string; apiKey?: string }, + full: boolean, +): Record { + const endpoint = network.httpEndpoint; + return omitUndefined({ + httpEndpoint: endpoint ? (full ? endpoint : endpointHost(endpoint)) : undefined, + apiKeyHeader: network.apiKeyHeader, + apiKey: maskSecret(network.apiKey), + }); +} + +/** an unset field is absent, not an empty line: the view says what IS configured. */ +function omitUndefined(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)); +} + +function normalizeNetworkValue(field: NetworkConfigField, value: string, key: string): string { + if (field === "httpEndpoint") return httpsEndpoint(value, key); + if (field === "apiKeyHeader") return headerName(value, key); + return credentialValue(value, key); +} + +/** + * An HTTP field name per RFC 9110 — the token production, which excludes whitespace, `:` and CR/LF. + * + * This value is written verbatim into a request's header list, so accepting a newline here would + * let config.yaml smuggle in a second header (or a request line): header injection sourced from a + * file the user edits by hand. + */ +function headerName(value: string, key: string): string { + const name = value.trim(); + if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/.test(name)) { + throw new UsageError( + "invalid_value", + `${key} must be an HTTP header name: 1 to 64 characters, no spaces, colons or control characters`, + ); + } + return name; } -/** the one writable sub-key; named in the error so a typo says which one is supported. */ -function assertWritableNetworkField(field: string): void { - if (field !== "httpEndpoint") { +function credentialValue(value: string, key: string): string { + if (value.length === 0 || value.length > 256 || /[\u0000-\u001f\u007f]/.test(value)) { throw new UsageError( "invalid_value", - `only networks..httpEndpoint is readable or writable; got networks..${field}`, + `${key} must be 1 to 256 characters without control characters`, ); } + return value; } function httpsEndpoint(value: string, key: string): string { diff --git a/ts/src/bootstrap/families/evm.test.ts b/ts/src/bootstrap/families/evm.test.ts index fc792cea5..ac8f192e5 100644 --- a/ts/src/bootstrap/families/evm.test.ts +++ b/ts/src/bootstrap/families/evm.test.ts @@ -80,7 +80,7 @@ describe("registerEvmChainCommands", () => { it("declares no evm-only flags on the signing commands", () => { const reg = registry(); - // A family flag that exists on one side only would show up in help tagged "(evm)". These two + // A family flag that exists on one side only would show up in help tagged "(evm only)". These two // commands take the same input everywhere; anything else is a regression. expect(reg.resolveChain(["message", "sign"])?.families.evm?.fields).toBeUndefined(); expect(reg.resolveChain(["typed-data", "sign"])?.families.evm?.fields).toBeUndefined(); diff --git a/ts/src/bootstrap/families/evm.ts b/ts/src/bootstrap/families/evm.ts index a21cfe270..a44cc1390 100644 --- a/ts/src/bootstrap/families/evm.ts +++ b/ts/src/bootstrap/families/evm.ts @@ -87,7 +87,7 @@ import type { FamilyPlugin } from "./types.js"; export const evmFamily: FamilyPlugin<"evm"> = { meta: FAMILIES.evm, signStrategy: evmSignStrategy, - createGateway: (network, timeoutMs) => new EvmRpcClient(network.httpEndpoint ?? "", timeoutMs), + createGateway: (network, timeoutMs) => new EvmRpcClient(network, timeoutMs), }; export interface EvmChainCommandDependencies { diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 34136f73d..3fbc076cd 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -164,7 +164,7 @@ import { export const tronFamily: FamilyPlugin<"tron"> = { meta: FAMILIES.tron, signStrategy: tronSignStrategy, - createGateway: (network, timeoutMs) => new TronRpcClient(network.httpEndpoint ?? "", timeoutMs), + createGateway: (network, timeoutMs) => new TronRpcClient(network, timeoutMs), }; export interface TronChainCommandDependencies { diff --git a/ts/src/bootstrap/migration-gate.test.ts b/ts/src/bootstrap/migration-gate.test.ts index 0456965fa..4134efccc 100644 --- a/ts/src/bootstrap/migration-gate.test.ts +++ b/ts/src/bootstrap/migration-gate.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { AtomicFileStore } from "../adapters/outbound/persistence/fs/index.js"; import { MigrationRunner, type MigrationStep } from "../adapters/outbound/persistence/migration.js"; import { runMigrationGate } from "./migration-gate.js"; -import { upgradeNotice } from "./runner.js"; +import { upgradeCancelledNotice, upgradeCompleteNotice, upgradeNotice } from "./runner.js"; import { CliError } from "../domain/errors/index.js"; function stalePasswordStep(path: string, needsPassword: boolean): MigrationStep { @@ -40,7 +40,7 @@ describe("runMigrationGate", () => { expect(JSON.parse(readFileSync(wallets, "utf8"))).toEqual({ version: 1, wallets: [] }); }); - it("migrates silently when no stale file needs a password", async () => { + it("migrates without asking for a password when no stale file needs one", async () => { const wallets = join(seededRoot(), "wallets.json"); const runner = new MigrationRunner(new AtomicFileStore()); const obtain = vi.fn(async () => "should-not-be-asked"); @@ -77,10 +77,8 @@ describe("runMigrationGate", () => { }); /** - * Consent. The gate rewrites the user's wallet file and needs their master password to do it, so - * in a terminal it must SAY so and take an answer first. Before this it did neither: the whole - * upgrade surfaced as a bare "Master password (hidden):" prompt with no explanation, no mention - * that a file was about to be rewritten, and no way to decline except Ctrl+C. + * Consent. The gate rewrites the user's wallet file, independently of whether it needs a password, + * so an interactive caller must always be able to explain the plan and take an answer first. */ describe("runMigrationGate consent", () => { it("asks before touching anything, and asks before asking for the password", async () => { @@ -103,51 +101,66 @@ describe("runMigrationGate consent", () => { expect(JSON.parse(readFileSync(wallets, "utf8")).version).toBe(2); }); - it("declining leaves the file untouched and never asks for the password", async () => { + it("exiting leaves the file untouched and never asks for the password", async () => { const wallets = join(seededRoot(), "wallets.json"); const runner = new MigrationRunner(new AtomicFileStore()); const password = vi.fn(async () => "hunter2"); - const error = await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { + const outcome = await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { confirm: async () => false, password, - }) - .then(() => null) - .catch((e: unknown) => e as CliError); + }); - expect(error?.code).toBe("migration_required"); - expect(error?.exitCode()).toBe(2); + expect(outcome).toEqual({ + status: "cancelled", + files: [{ path: wallets, from: 1, to: 2, backup: `${wallets}.v1.bak` }], + }); expect(password).not.toHaveBeenCalled(); expect(JSON.parse(readFileSync(wallets, "utf8"))).toEqual({ version: 1, wallets: [] }); }); - it("tells a user who declined how to proceed", async () => { + it("reports cancellation as an outcome rather than an error", async () => { const wallets = join(seededRoot(), "wallets.json"); const runner = new MigrationRunner(new AtomicFileStore()); - const error = await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { + const outcome = await runMigrationGate(runner, [stalePasswordStep(wallets, true)], { confirm: async () => false, password: async () => "hunter2", - }).catch((e: unknown) => e as CliError); + }); - expect(error?.message).toMatch(/declined/i); - expect(error?.message).toMatch(/--password-stdin/); + expect(outcome.status).toBe("cancelled"); }); - it("does not ask consent for a secretless upgrade — ADR-0008 keeps that silent", async () => { + it("asks consent for a secretless upgrade but never asks for a password", async () => { const wallets = join(seededRoot(), "wallets.json"); const runner = new MigrationRunner(new AtomicFileStore()); const confirm = vi.fn(async () => true); + const password = vi.fn(async () => "should-not-be-asked"); await runMigrationGate(runner, [stalePasswordStep(wallets, false)], { confirm, - password: async () => null, + password, }); - expect(confirm).not.toHaveBeenCalled(); + expect(confirm).toHaveBeenCalledOnce(); + expect(password).not.toHaveBeenCalled(); expect(JSON.parse(readFileSync(wallets, "utf8")).version).toBe(2); }); + it("lets a user exit a secretless upgrade without changing the file", async () => { + const wallets = join(seededRoot(), "wallets.json"); + + const outcome = await runMigrationGate( + new MigrationRunner(new AtomicFileStore()), + [stalePasswordStep(wallets, false)], + { confirm: async () => false, password: async () => null }, + ); + + expect(outcome.status).toBe("cancelled"); + expect(JSON.parse(readFileSync(wallets, "utf8"))).toEqual({ version: 1, wallets: [] }); + expect(() => readFileSync(`${wallets}.v1.bak`, "utf8")).toThrow(); + }); + it("asks nothing at all when every file is current", async () => { const dir = mkdtempSync(join(tmpdir(), "gate-")); const wallets = join(dir, "wallets.json"); @@ -181,6 +194,47 @@ describe("runMigrationGate consent", () => { expect(seen).toEqual([{ path: wallets, from: 1, to: 2, backup: `${wallets}.v1.bak` }]); }); + + it("reports the plan before applying it and returns the completed upgrades", async () => { + const wallets = join(seededRoot(), "wallets.json"); + const events: string[] = []; + + const outcome = await runMigrationGate( + new MigrationRunner(new AtomicFileStore()), + [stalePasswordStep(wallets, false)], + { + notice: (pending, needsPassword) => { + events.push(`notice:${pending[0]?.from}->${pending[0]?.to}:${needsPassword}`); + }, + password: async () => null, + applying: () => events.push("applying"), + }, + ); + + expect(events).toEqual(["notice:1->2:false", "applying"]); + expect(outcome).toEqual({ + status: "upgraded", + files: [{ path: wallets, from: 1, to: 2, backup: `${wallets}.v1.bak` }], + }); + }); + + it("reports nothing and returns no upgrades when every file is current", async () => { + const dir = mkdtempSync(join(tmpdir(), "gate-")); + const wallets = join(dir, "wallets.json"); + writeFileSync(wallets, JSON.stringify({ version: 2, wallets: [] })); + const notice = vi.fn(); + const applying = vi.fn(); + + const outcome = await runMigrationGate( + new MigrationRunner(new AtomicFileStore()), + [stalePasswordStep(wallets, false)], + { notice, applying, password: async () => null }, + ); + + expect(outcome).toEqual({ status: "current", files: [] }); + expect(notice).not.toHaveBeenCalled(); + expect(applying).not.toHaveBeenCalled(); + }); }); describe("the upgrade notice", () => { @@ -199,15 +253,14 @@ describe("the upgrade notice", () => { expect(notice()).toMatch(/v1\s*→\s*v2/); }); - it("explains that the upgrade is required before commands can run", () => { - expect(notice()).toMatch(/must be upgraded/); - expect(notice()).toMatch(/before any command can run/); + it("explains that the upgrade is required", () => { + expect(notice()).toMatch(/upgrade is required/); }); it("explains where the backup is kept", () => { expect(notice()).toContain("wallets.json.v1.bak"); - expect(notice()).toMatch(/never removed automatically/); - expect(notice()).toMatch(/runs once/); + expect(notice()).toMatch(/kept permanently/); + expect(notice()).toMatch(/runs only once/); }); it("links to the release details", () => { @@ -215,6 +268,59 @@ describe("the upgrade notice", () => { }); it("does not expose implementation details", () => { - expect(notice()).not.toMatch(/EVM address|master password|decrypt|seed|leaves this machine/i); + expect(notice()).not.toMatch(/EVM address|decrypt|seed|leaves this machine/i); + }); + + it("only mentions a master password when the migration needs one", () => { + expect(notice()).toMatch(/master password/i); + expect( + upgradeNotice( + [ + { + path: "/home/u/.wallet-cli/wallets.json", + from: 1, + to: 2, + backup: "/home/u/.wallet-cli/wallets.json.v1.bak", + }, + ], + false, + ).join("\n"), + ).not.toMatch(/master password/i); + }); +}); + +describe("the upgrade completion notice", () => { + const complete = upgradeCompleteNotice([ + { + path: "/home/u/.wallet-cli/wallets.json", + from: 1, + to: 2, + backup: "/home/u/.wallet-cli/wallets.json.v1.bak", + }, + ]).join("\n"); + + it("confirms success and concisely tells the user to run the command again", () => { + expect(complete).toMatch(/completed successfully/i); + expect(complete).toContain("🎉 Upgrade complete. Please run your command again."); + expect(complete).toContain("wallets.json.v1.bak"); + }); +}); + +describe("the upgrade cancellation notice", () => { + const cancelled = upgradeCancelledNotice([ + { + path: "/home/u/.wallet-cli/wallets.json", + from: 1, + to: 2, + backup: "/home/u/.wallet-cli/wallets.json.v1.bak", + }, + ]).join("\n"); + + it("describes an unsupported schema and the compatible-release option without calling it an error", () => { + expect(cancelled).toContain("Wallet data was not upgraded. No changes were made."); + expect(cancelled).toContain("wallets.json: schema v1 (requires v2)"); + expect(cancelled).toMatch(/compatible earlier release/i); + expect(cancelled).toContain("https://github.com/tronprotocol/wallet-cli/releases"); + expect(cancelled).not.toMatch(/error|migration_required|password-stdin/i); }); }); diff --git a/ts/src/bootstrap/migration-gate.ts b/ts/src/bootstrap/migration-gate.ts index 3cd3b3aa1..4af21f104 100644 --- a/ts/src/bootstrap/migration-gate.ts +++ b/ts/src/bootstrap/migration-gate.ts @@ -1,6 +1,6 @@ /** - * The startup migration gate (ADR-0008). Runs before any command dispatches, after the - * help/meta short-circuit so `--help` stays reachable on a stale or unmigratable keystore. + * The startup migration gate (ADR-0008). Runs on every invocation before help/meta handling, + * argument validation, or command dispatch. * * The gate is absolute: while a registered file lags this binary, no command runs. That is what * lets `ChainAddresses` stay total instead of degrading to a partial map everywhere. @@ -11,8 +11,9 @@ * bare "Master password (hidden):" prompt — no reason given, no mention that a file was about to * be rewritten, and no way to say no except Ctrl+C. * - * A secretless upgrade (ledger / watch only) stays silent, as ADR-0008 requires: there is nothing - * to decrypt, nothing to ask for, and no cost to weigh. + * Consent and authentication are independent. Every interactive migration asks before rewriting + * wallet state; a secretless upgrade (ledger / watch only) simply skips the password step after + * consent. Non-interactive secretless migrations remain automatic so CI can self-heal. */ import { UsageError } from "../domain/errors/index.js"; import { @@ -31,37 +32,47 @@ export interface PendingUpgrade { backup: string; } +export interface MigrationGateOutcome { + status: "current" | "upgraded" | "cancelled"; + files: PendingUpgrade[]; +} + export interface MigrationPrompt { + /** Report every pending upgrade before any prompt or write. */ + notice?(pending: PendingUpgrade[], needsPassword: boolean): void; /** - * Explain the pending upgrade and return the user's answer. Called ONLY when the upgrade needs - * the master password, and always before `password()`. + * Explain the pending upgrade and return the user's answer. Called for every stale plan when an + * interactive caller supplies it, and always before `password()`. * - * Non-interactive callers return true: there is no one to ask, and `password()` then produces - * the `migration_required` error on its own. + * Non-interactive callers return true: secretless plans then apply automatically, while + * password-bearing plans let `password()` produce migration_required when no source exists. */ confirm?(pending: PendingUpgrade[]): Promise; /** The master password, or null when none can be obtained (no TTY and no --password-stdin). */ password(): Promise; + /** Report the atomic backup + rewrite immediately before it starts. */ + applying?(pending: PendingUpgrade[]): void; } export async function runMigrationGate( runner: MigrationRunner, steps: MigrationStep[], prompt: MigrationPrompt, -): Promise { +): Promise { // No early exit for "nothing stale" is needed: planMigrations only aggregates needsPassword // over stale files, and apply() no-ops on an empty set. Mutation testing proved the guard dead. const plan = runner.plan(steps); + const pending = plan.stale.map(pendingUpgrade); + if (pending.length === 0) return { status: "current", files: [] }; + + prompt.notice?.(pending, plan.needsPassword); let password: string | undefined; + if (prompt.confirm && !(await prompt.confirm(pending))) { + return { status: "cancelled", files: pending }; + } + if (plan.needsPassword) { - if (prompt.confirm && !(await prompt.confirm(plan.stale.map(pendingUpgrade)))) { - throw new UsageError( - "migration_required", - "upgrade declined; this version cannot run against a wallet file from an older one. " + - "Re-run any command and answer yes, or pipe the master password with --password-stdin", - ); - } const supplied = await prompt.password(); if (supplied === null) { throw new UsageError( @@ -73,7 +84,9 @@ export async function runMigrationGate( password = supplied; } + prompt.applying?.(pending); runner.apply(plan.stale, password); + return { status: "upgraded", files: pending }; } function pendingUpgrade(file: StaleFile): PendingUpgrade { diff --git a/ts/src/bootstrap/migration-wiring.test.ts b/ts/src/bootstrap/migration-wiring.test.ts index 4ab87f85a..dcaeab6fe 100644 --- a/ts/src/bootstrap/migration-wiring.test.ts +++ b/ts/src/bootstrap/migration-wiring.test.ts @@ -13,14 +13,18 @@ async function runIn(walletsDoc: unknown, tokens: string[]) { const previous = process.env.WALLET_CLI_HOME; process.env.WALLET_CLI_HOME = root; const stdout: string[] = []; + const stderr: string[] = []; const outSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { stdout.push(String(chunk)); return true; }); - const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); try { const code = await main(["node", "wallet-cli", ...tokens]); - return { code, stdout: stdout.join(""), walletsPath }; + return { code, stdout: stdout.join(""), stderr: stderr.join(""), walletsPath }; } finally { outSpy.mockRestore(); errSpy.mockRestore(); @@ -54,8 +58,8 @@ const v1PrivateKeyDoc = { * Ledger is the case that matters most here: a real, signing-capable account that holds no local * secret. Such a user may never have set a master password at all — `import ledger` / `import * watch` do not ask for one, and a keystore file is never written — so a gate that demanded one - * would leave them with nothing to type and no way in. The upgrade must be silent, not merely - * quiet. + * would leave them with nothing to type and no way in. The upgrade must not ask for a password or + * consent; reporting its progress is safe. */ const v1LedgerDoc = { version: 1, @@ -69,7 +73,7 @@ const v1LedgerDoc = { ], }; -// watch and ledger hold no secret anywhere, so this keystore migrates with no prompt at all. +// watch and ledger hold no secret anywhere, so this keystore migrates with no prompt. const v1WatchDoc = { version: 1, activeAccount: "wlt_w", @@ -95,13 +99,28 @@ describe("the startup migration gate is wired into main()", () => { expect(code).toBe(2); }); - it("migrates a secret-free keystore silently and runs the command", async () => { - const { code, walletsPath } = await runIn(v1WatchDoc, ["-o", "json", "list"]); + it("shows and completes a secret-free upgrade without running the original command", async () => { + const { code, stdout, stderr, walletsPath } = await runIn(v1WatchDoc, ["-o", "json", "list"]); expect(code).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ + success: true, + command: "migration", + data: { upgraded: true, originalCommandExecuted: false }, + }); + expect(stderr).toContain("Schema: v1 → v2"); + expect(stderr).toContain("Backing up and upgrading wallet data"); expect(JSON.parse(readFileSync(walletsPath, "utf8")).version).toBe(2); }); + it("prints a human completion boundary in text mode", async () => { + const { code, stdout } = await runIn(v1WatchDoc, ["list"]); + + expect(code).toBe(0); + expect(stdout).toContain("Wallet data upgrade completed successfully"); + expect(stdout).toContain("Upgrade complete. Please run your command again"); + }); + it("preserves everything it was not asked to change", async () => { const { walletsPath } = await runIn(v1WatchDoc, ["-o", "json", "list"]); const doc = JSON.parse(readFileSync(walletsPath, "utf8")); @@ -117,10 +136,12 @@ describe("the startup migration gate is wired into main()", () => { expect(JSON.parse(readFileSync(`${walletsPath}.v1.bak`, "utf8"))).toEqual(v1WatchDoc); }); - it("migrates a Ledger-only keystore silently and runs the command", async () => { - const { code, walletsPath } = await runIn(v1LedgerDoc, ["-o", "json", "list"]); + it("migrates a Ledger-only keystore and stops before command dispatch", async () => { + const { code, stdout, walletsPath } = await runIn(v1LedgerDoc, ["-o", "json", "list"]); expect(code).toBe(0); + expect(JSON.parse(stdout).command).toBe("migration"); + expect(JSON.parse(stdout).data.originalCommandExecuted).toBe(false); expect(JSON.parse(readFileSync(walletsPath, "utf8")).version).toBe(2); }); @@ -137,9 +158,33 @@ describe("the startup migration gate is wired into main()", () => { expect(code).toBe(2); }); - it("leaves --help reachable on a stale keystore", async () => { - const { code } = await runIn(v1SeedDoc, ["--help"]); + it.each([ + ["help", ["-o", "json", "--help"]], + ["version", ["-o", "json", "--version"]], + ["JSON schema", ["-o", "json", "--json-schema"]], + ])("checks migration before %s", async (_surface, tokens) => { + const { code, stdout, walletsPath } = await runIn(v1SeedDoc, tokens); + + expect(code).toBe(2); + expect(JSON.parse(stdout).error.code).toBe("migration_required"); + expect(JSON.parse(readFileSync(walletsPath, "utf8")).version).toBe(1); + }); + + it("checks migration before bare-invocation help", async () => { + const { code, stderr, walletsPath } = await runIn(v1SeedDoc, []); + + expect(code).toBe(2); + expect(stderr).toContain("migration_required"); + expect(JSON.parse(readFileSync(walletsPath, "utf8")).version).toBe(1); + }); + + it("completes a secret-free migration instead of rendering requested help", async () => { + const { code, stdout, walletsPath } = await runIn(v1WatchDoc, ["--help"]); + expect(code).toBe(0); + expect(stdout).toContain("Upgrade complete. Please run your command again"); + expect(stdout).not.toContain("Usage:"); + expect(JSON.parse(readFileSync(walletsPath, "utf8")).version).toBe(2); }); }); @@ -240,8 +285,21 @@ describe("config addresses networks by nested key", () => { it("shows each network's endpoint host so a change can be confirmed", async () => { const { stdout } = await runIn(emptyKeystore, ["-o", "json", "config", "networks"]); expect(JSON.parse(stdout).data.value).toMatchObject({ - "tron:nile": "nile.trongrid.io", - "evm:11155111": "ethereum-sepolia-rpc.publicnode.com", + "tron:nile": { httpEndpoint: "nile.trongrid.io" }, + "evm:11155111": { httpEndpoint: "ethereum-sepolia-rpc.publicnode.com" }, + }); + }); + + // The endpoint may carry an API key in its path, so breadth-first listings trim it to the host; + // naming ONE network is the deliberate act that reveals the whole URL. + it("reveals the full endpoint URL only when a single network is named", async () => { + const listed = await runIn(emptyKeystore, ["-o", "json", "config", "networks"]); + expect(JSON.parse(listed.stdout).data.value["tron:nile"].httpEndpoint).toBe("nile.trongrid.io"); + + const named = await runIn(emptyKeystore, ["-o", "json", "config", "networks.nile"]); + expect(JSON.parse(named.stdout).data).toMatchObject({ + key: "networks.tron:nile", + value: { httpEndpoint: "https://nile.trongrid.io" }, }); }); diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index f80e7289a..b81f88548 100644 --- a/ts/src/bootstrap/runner.ts +++ b/ts/src/bootstrap/runner.ts @@ -6,11 +6,12 @@ import type { ExitCode, OutputMode } from "../domain/types/index.js"; import { normalizeError, UsageError } from "../domain/errors/index.js"; import { redactErrorMessage } from "../domain/errors/redact.js"; import { HelpService, hasMeta } from "../adapters/inbound/cli/help/index.js"; -import { buildCli } from "../adapters/inbound/cli/shell/index.js"; +import { assertNoTailFlags, buildCli } from "../adapters/inbound/cli/shell/index.js"; import { createOutputFormatter } from "../adapters/inbound/cli/output/index.js"; import { StreamManager } from "../adapters/inbound/cli/stream/index.js"; import { hasCommand, parseGlobals } from "./argv.js"; import { composeCliRuntime } from "./composition.js"; +import { basename } from "node:path"; export const VERSION = "4.12.0"; @@ -57,48 +58,98 @@ export async function main(argv: string[]): Promise { } try { - if (hasMeta(tokens) || !hasCommand(tokens)) { - const help = new HelpService(runtime.registry, runtime.streams, VERSION); - return help.handleMeta(hasMeta(tokens) ? tokens : ["--help"]); - } - - // A supplied global flag with an out-of-range/invalid value is a usage error — never a silent - // fall-back to the default. Reported before any command dispatch, so no RPC is attempted. - if (invalid.length > 0) { - const bad = invalid[0]!; - throw new UsageError( - "invalid_value", - `invalid ${bad.flag} value "${bad.value}": ${bad.reason}`, - ); - } - - // The migration gate runs after the meta short-circuit above (so `--help` stays reachable on a - // stale keystore) and before any command dispatches — ADR-0008. - await runMigrationGate( + // Every invocation runs the migration preflight before help, version, schema output, argument + // validation, or command dispatch. A current/absent wallet is a no-op; stale state is handled + // consistently regardless of which surface caused wallet-cli to start — ADR-0008. + const migration = await runMigrationGate( new MigrationRunner(runtime.store), migrationSteps(runtime.root, runtime.store), { - confirm: async (pending) => { - const { secrets, prompter } = runtime.deps; - // --password-stdin already stated the intent, and there is no one to ask anyway. - if (secrets.hasMasterPassword()) return true; - // No terminal: fall through so password() raises migration_required, unchanged. + notice: (pending, needsPassword) => { + for (const line of upgradeNotice(pending, needsPassword)) { + runtime.streams.diagnostic("info", line); + } + }, + confirm: async (_pending) => { + const { prompter } = runtime.deps; + // Non-interactive secretless plans migrate automatically; password-bearing plans fall + // through to password(), which raises migration_required if no stdin source exists. if (!prompter.isTTY()) return true; - for (const line of upgradeNotice(pending)) runtime.streams.diagnostic("info", line); - return prompter.confirm({ label: "Upgrade now?" }); + return prompter.select({ + label: "Wallet data upgrade", + choices: [ + { value: true, label: "Upgrade now" }, + { value: false, label: "Exit without upgrading" }, + ], + }); }, password: async () => { const { secrets, keystore, prompter } = runtime.deps; if (!secrets.hasMasterPassword() && !prompter.isTTY()) return null; + runtime.streams.diagnostic("info", "==> Verifying master password"); await secrets.primePassword({ mode: "verify", verify: (pw) => keystore.verifyPassword(pw), }); return secrets.masterPassword(); }, + applying: () => { + runtime.streams.diagnostic("info", "==> Backing up and upgrading wallet data"); + }, }, ); + // Startup changed durable wallet state. End this invocation at that boundary instead of + // silently dispatching the command the user typed before they saw the upgrade. + if (migration.status === "cancelled") { + const data = { + upgraded: false, + cancelled: true, + files: migration.files.map(({ path, from, to }) => ({ path, from, to })), + originalCommandExecuted: false, + }; + runtime.streams.result( + runtime.formatter.success("migration", undefined, data, () => + upgradeCancelledNotice(migration.files).join("\n"), + ), + ); + return 0; + } + + if (migration.status === "upgraded") { + const data = { + upgraded: true, + files: migration.files.map(({ path, from, to, backup }) => ({ path, from, to, backup })), + originalCommandExecuted: false, + }; + runtime.streams.result( + runtime.formatter.success("migration", undefined, data, () => + upgradeCompleteNotice(migration.files).join("\n"), + ), + ); + return 0; + } + + if (hasMeta(tokens) || !hasCommand(tokens)) { + const help = new HelpService(runtime.registry, runtime.streams, VERSION); + return help.handleMeta(hasMeta(tokens) ? tokens : ["--help"]); + } + + // A supplied global flag with an out-of-range/invalid value is a usage error — never a silent + // fall-back to the default. Reported before command dispatch, so no RPC is attempted. + if (invalid.length > 0) { + const bad = invalid[0]!; + throw new UsageError( + "invalid_value", + `invalid ${bad.flag} value "${bad.value}": ${bad.reason}`, + ); + } + + // yargs stores a grouped command's tail in argv.group/verb/args, so those names must stay in + // the per-command flag allowlist — which leaves a user-typed `--args` indistinguishable from + // plumbing once parsed. The raw tokens still tell them apart. + assertNoTailFlags(tokens); + const cli = buildCli({ registry: runtime.registry, globals, @@ -128,20 +179,46 @@ export async function main(argv: string[]): Promise { } /** Goes to stderr at `info`, so stdout stays reserved for command output. */ -export function upgradeNotice(pending: PendingUpgrade[]): string[] { +export function upgradeNotice(pending: PendingUpgrade[], needsPassword = true): string[] { return [ "", - "This wallet was created by an earlier version of wallet-cli and must be upgraded", - "before any command can run.", + "Updating wallet-cli wallet data...", + "==> An older wallet data format was detected; an upgrade is required", + "", + ...pending.flatMap((f) => [ + `==> ${f.path}`, + ` Schema: v${f.from} \u2192 v${f.to}`, + ` Backup: ${f.backup}`, + ]), "", - ...pending.map((f) => ` ${f.path} v${f.from} \u2192 v${f.to}`), + "==> The backup is kept permanently; this upgrade runs only once", + ...(needsPassword + ? ["==> Your master password is required to update derived account data"] + : []), + "Release details: https://github.com/tronprotocol/wallet-cli/releases", "", + ]; +} + +export function upgradeCompleteNotice(upgraded: PendingUpgrade[]): string[] { + return [ + "✓ Wallet data upgrade completed successfully.", + ...upgraded.map((file) => ` Backup saved: ${file.backup}`), + "", + "🎉 Upgrade complete. Please run your command again.", + ]; +} + +export function upgradeCancelledNotice(pending: PendingUpgrade[]): string[] { + return [ + "Wallet data was not upgraded. No changes were made.", + "", + "This version of wallet-cli does not support the existing wallet data format:", ...pending.map( - (f) => - `A copy of the current file is kept at\n ${f.backup}\nand is never removed automatically. The upgrade runs once.`, + (file) => ` ${basename(file.path)}: schema v${file.from} (requires v${file.to})`, ), "", - "Release details: https://github.com/tronprotocol/wallet-cli/releases", - "", + "To continue without upgrading, use a compatible earlier release:", + "https://github.com/tronprotocol/wallet-cli/releases", ]; } diff --git a/ts/src/domain/types/network.ts b/ts/src/domain/types/network.ts index 2eeddb3c1..e622499b7 100644 --- a/ts/src/domain/types/network.ts +++ b/ts/src/domain/types/network.ts @@ -30,6 +30,16 @@ interface NetworkBase { testnet?: boolean; feeModel?: FeeModel; capabilities: string[]; + /** + * Credential for a commercial RPC endpoint that authenticates by HEADER rather than by a key + * embedded in the URL (TronGrid's `TRON-PRO-API-KEY`, for one). Both halves are configured + * per network because the header NAME differs per provider. + * + * `apiKey` is a secret: it is masked wherever config is rendered, and its presence in + * config.yaml makes that file subject to the same 0600 check as the service credentials. + */ + apiKeyHeader?: string; + apiKey?: string; } /** TRON network. Reached via tronweb, which is HTTP-based — `httpEndpoint` is a FullNode HTTP REST diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index ee65137a2..f234f0465 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -959,12 +959,16 @@ describe("golden CLI — startup migration", () => { return path; } - it("migrates with the master password piped in, then runs the command", () => { + it("migrates with the master password piped in, then stops before the command", () => { const path = windBackToV1(); const r = run(["--output", "json", "list"], { password: DEFAULT_PW }); expect(r.status).toBe(0); + expect(r.json).toMatchObject({ + command: "migration", + data: { upgraded: true, originalCommandExecuted: false }, + }); const doc = JSON.parse(readFileSync(path, "utf8")); expect(doc.version).toBe(2); expect(doc.wallets[0].source.addresses["0"].evm).toMatch(/^0x[0-9a-fA-F]{40}$/); @@ -997,9 +1001,13 @@ describe("golden CLI — startup migration", () => { expect(JSON.parse(readFileSync(path, "utf8")).version).toBe(1); }); - it("runs --help on a stale keystore without demanding anything", () => { - windBackToV1(); - expect(run(["--help"], { password: null }).status).toBe(0); + it("checks migration before --help", () => { + const path = windBackToV1(); + const r = run(["--output", "json", "--help"], { password: null }); + + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("migration_required"); + expect(JSON.parse(readFileSync(path, "utf8")).version).toBe(1); }); }); @@ -1064,3 +1072,49 @@ describe("golden CLI — networks table", () => { expect(out).not.toContain("https://"); }); }); + +// yargs keeps a grouped command's tail in argv.group/verb/args, which made those names work as +// undocumented flags: `chain --verb node` really dispatched chain.node and `use --args main` +// really bound the positional. The dangerous one was `contract call --args `, which bound +// into a slot the command has not got — the call then ran with no argument and answered 0, a +// wrong result indistinguishable from a real one. +describe("golden CLI — yargs tail keys are not user-facing flags", () => { + it("rejects --args where it used to act as a positional", () => { + seedWallet("main"); + const r = run(["--output", "json", "use", "--args", "main"]); + + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_option"); + expect(r.json.error.message).toContain("--args"); + }); + + it("rejects --verb where it used to act as a subcommand alias", () => { + const r = run(["--output", "json", "chain", "--verb", "node"]); + + // exit 2 before any RPC: the check runs on the raw tokens, ahead of dispatch. + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_option"); + }); + + it("rejects --group and --source as well", () => { + for (const flag of ["--group", "--source"]) { + const r = run(["--output", "json", "list", flag, "x"]); + expect(r.json.error.code).toBe("invalid_option"); + } + }); + + it("still accepts the real positional form it was shadowing", () => { + seedWallet("main"); + const r = run(["--output", "json", "use", "main"]); + + expect(r.status).toBe(0); + expect(r.json.data.label).toBe("main"); + }); + + it("leaves grouped-command dispatch working", () => { + const r = run(["--output", "json", "config", "defaultNetwork"]); + + expect(r.status).toBe(0); + expect(r.json.data.key).toBe("defaultNetwork"); + }); +}); From 5389b0096e3c5a1f19091e90a182bb971ee952fb Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 27 Aug 2026 15:24:11 +0800 Subject: [PATCH 22/23] docs: repoint the ts architecture reference at what still exists The source-of-truth doc it named was deleted in 7a33dcd5, leaving the one instruction that tells a reader where the boundary rules live pointing at nothing. Split the reference across what actually holds each part now: the table below it for the boundaries, .dependency-cruiser.cjs for enforcement, machine-interface.md for the JSON contract, and docs/adr/ for the reasoning. Claude-Session: https://claude.ai/code/session_01JSxAgrttq54UacDxykm7gy --- CLAUDE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c579946e7..39fd2d57d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,10 @@ npm run depcruise # dependency-cruiser — enforces the architecture rules ### Architecture (hexagonal / ports & adapters) -Dependencies point inward. The source of truth is -`ts/docs/typescript-wallet-cli-architecture-source-of-truth.md` — read it before changing -boundaries, ports, command routing, or the JSON contract. `depcruise` enforces these rules in CI. +Dependencies point inward, and the table below is the rule — read it before changing boundaries, +ports, or command routing. `depcruise` enforces it in CI (`ts/.dependency-cruiser.cjs`). +`ts/docs/machine-interface.md` is the source of truth for the JSON contract (envelope, exit codes, +stdout/stderr discipline), and `ts/docs/adr/` records the decisions behind the boundaries. | Area (`ts/src/…`) | Role | May depend on | Must NOT depend on | |---|---|---|---| From f723d8fc5fc9e36b0362e9bb59ddb0b058ac9e20 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 27 Aug 2026 15:29:41 +0800 Subject: [PATCH 23/23] =?UTF-8?q?docs:=20drop=20the=20ADR=20reference=20?= =?UTF-8?q?=E2=80=94=20ts/docs/adr=20is=20gitignored?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5389b009 pointed at ts/docs/adr/ after verifying it existed on disk, which is the wrong test: ts/.gitignore:9 ignores it, so it holds zero tracked files and is not there for anyone who clones. That replaced one dead reference with another. What remains is what the repo actually carries: the table, the dependency-cruiser config that enforces it, and machine-interface.md. Claude-Session: https://claude.ai/code/session_01JSxAgrttq54UacDxykm7gy --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 39fd2d57d..3b0b874b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ npm run depcruise # dependency-cruiser — enforces the architecture rules Dependencies point inward, and the table below is the rule — read it before changing boundaries, ports, or command routing. `depcruise` enforces it in CI (`ts/.dependency-cruiser.cjs`). `ts/docs/machine-interface.md` is the source of truth for the JSON contract (envelope, exit codes, -stdout/stderr discipline), and `ts/docs/adr/` records the decisions behind the boundaries. +stdout/stderr discipline). | Area (`ts/src/…`) | Role | May depend on | Must NOT depend on | |---|---|---|---|