From 11c98aa0df9534a811001a35e73eac1a4852e963 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 24 Aug 2026 10:03:33 +0800 Subject: [PATCH 1/2] 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 2/2] 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); + }); +});