diff --git a/CLAUDE.md b/CLAUDE.md index 87baeb635..7e14b7b6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,9 +108,6 @@ cd java ./qa-repl/cli-boundary.sh build/libs/wallet-cli.jar # entry point takes only --version/--help ``` -See `java/qa-repl/README.md`. The older `qa/` harness only ever drove the standard -CLI, which was removed in v4.13.0. - ## Architecture This is a **TRON blockchain CLI wallet** built on the [Trident SDK](https://github.com/tronprotocol/trident). It communicates with TRON nodes via gRPC. diff --git a/java/build.gradle b/java/build.gradle index 6044f3a81..ccc72d8cd 100644 --- a/java/build.gradle +++ b/java/build.gradle @@ -19,7 +19,7 @@ plugins { } group 'Tron' -version '4.13.0' +version '4.13.1' apply plugin: 'java' apply plugin: 'com.google.protobuf' diff --git a/java/src/main/java/org/tron/common/utils/Utils.java b/java/src/main/java/org/tron/common/utils/Utils.java index 968542ff4..27233d309 100644 --- a/java/src/main/java/org/tron/common/utils/Utils.java +++ b/java/src/main/java/org/tron/common/utils/Utils.java @@ -136,7 +136,7 @@ public class Utils { public static final int MIN_LENGTH = 2; public static final int MAX_LENGTH = 14; - public static final String VERSION = " v4.13.0"; + public static final String VERSION = " v4.13.1"; public static final String TRANSFER_METHOD_ID = "a9059cbb"; private static SecureRandom random = new SecureRandom(); diff --git a/ts/docs/commands/backup.md b/ts/docs/commands/backup.md index 93c667915..2177aa063 100644 --- a/ts/docs/commands/backup.md +++ b/ts/docs/commands/backup.md @@ -15,9 +15,18 @@ With an account, `backup` writes that account's secret material and metadata to Two formats: -- **Native** (default) — the wallet's own backup JSON. A seed account exports its recovery phrase, so the whole seed moves with it. +- **Native** — the wallet's own backup JSON. A seed account exports its recovery phrase, so the whole seed moves with it. - **`--keystore`** — a standard Web3 keystore JSON, importable by TronLink and others, encrypted with **your master password**. A keystore holds a **single private key**: an HD account exports only its current derived key, and that key arrives elsewhere as a standalone account with nothing derivable from it. Use the native format to move a seed. +In a fully interactive terminal, omitting `--keystore` opens a format selector before the password prompt. Commands using `--password-stdin`, and other non-interactive invocations, keep native as the default so scripts never stop for this choice. + +The native export may warn that some stored accounts need a separate `--keystore` export. Follow +that warning before deleting anything; see [Recover addresses after +`legacy_derivation`](../troubleshooting/legacy-derivation-recovery.md). + +The warning means this version's default mnemonic import and derive flow will not recreate those +TRON addresses. The recovery phrase can still derive their keys at the listed legacy paths. + **A keystore also holds one key per *family*.** A seed account derives a different key for TRON (coin type 195) and for EVM (coin type 60), and a keystore can carry only one of them, so `--network` selects which — falling back to `config.defaultNetwork` when omitted. The receipt names the family that was written, and the export log records it. A private-key account has a single key and ignores the selection; the native backup covers every family at once, so it needs no choice and reports none. **Files land in the current working directory** by default — `./-.json`, or `./-.keystore.json` with `--keystore`. `--out` overrides the path. @@ -40,7 +49,7 @@ The positional account is the exception: it means different things in the two fo | Option | Description | |---|---| | `` | Account to export, by accountId, label, or address. Required unless `--records`; **with** `--records` it filters the log instead, like `--account` | -| `--keystore` | Export as a standard Web3 keystore instead of the native format | +| `--keystore` | Export as a standard Web3 keystore instead of the native format. Omit in a fully interactive terminal to choose | | `--out ` | Output file path; mode 0600, never overwritten (default: the current directory, see above) | | `--password-stdin` | Master password from stdin (fd 0) | | `--network ` | With `--keystore`, which family's key to export (`tron:3448148188` → the TRON key, `eip155:1` → the EVM key). No node is contacted | @@ -140,7 +149,7 @@ Both forms are local and contact no node, but `backup` has an optional network d | `index` | number \| null | HD derivation index; `null` for private-key accounts | | `active` | boolean | Whether it is the active account | | `addresses` | object | One entry per family the account can produce: `tron` and/or `evm` | -| `derivationPath` | object \| null | Per-family BIP44 path for `seed` accounts; `null` for `privateKey` | +| `derivationPath` | object \| null | The verified BIP44 path behind each address. A seed backup unlocks the seed and reports every family's actual path, including the pre-4.13.1 TRON path for a stranded account; a private-key account reports `null` | | `family` | string | With `--keystore`, which family's key was written; absent for a native backup, which covers every family | | `seedId` | string | Owning seed wallet id (`seed` accounts only) | | `secretType` | string | Kind of exported secret — `mnemonic`, or `privateKey` with `--keystore` | diff --git a/ts/docs/commands/create.md b/ts/docs/commands/create.md index 9a69a4b77..0ebcb0c79 100644 --- a/ts/docs/commands/create.md +++ b/ts/docs/commands/create.md @@ -70,7 +70,7 @@ printf '%s' "$PW" | wallet-cli create --label main --password-stdin -o json | `index` | number | HD derivation index (0 for the first account) | | `active` | boolean | Whether it became the active account | | `addresses` | object | One address per family the account can produce: `tron` (base58) and `evm` (`0x`, EIP-55 checksummed) | -| `derivationPath` | object | The BIP44 path each address came from: `{"tron":"m/44'/195'/'/0/0","evm":"m/44'/60'/0'/0/"}` | +| `derivationPath` | object | The BIP44 path each address came from — `m/44'/'/0'/0/` per family. `create` only ever makes index 0: `{"tron":"m/44'/195'/0'/0/0","evm":"m/44'/60'/0'/0/0"}` | | `seedId` | string | Owning seed wallet id | ## Exit status diff --git a/ts/docs/commands/current.md b/ts/docs/commands/current.md index 9826c0d38..23a692f56 100644 --- a/ts/docs/commands/current.md +++ b/ts/docs/commands/current.md @@ -64,7 +64,7 @@ wallet-cli current -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"current","data":{"accountId":"wlt_z259a1hq.0","label":"main","type":"seed","index":0,"active":true,"addresses":{"tron":"TE9kPMtaMjfZN95CuPRsCHUQGWwx9EcJW8","evm":"0x7B28FE10FBccE88c3967ff0Fd64f1ffB46b46C9C"},"seedId":"wlt_z259a1hq","derivationPath":{"tron":"m/44'/195'/0'/0/0","evm":"m/44'/60'/0'/0/0"}},"meta":{"durationMs":14,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"current","data":{"accountId":"wlt_z259a1hq.0","label":"main","type":"seed","index":0,"active":true,"addresses":{"tron":"TE9kPMtaMjfZN95CuPRsCHUQGWwx9EcJW8","evm":"0x7B28FE10FBccE88c3967ff0Fd64f1ffB46b46C9C"},"seedId":"wlt_z259a1hq","derivationPath":null},"meta":{"durationMs":14,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} ``` With no active account yet, it fails with `missing_wallet_address` (exit 1): @@ -89,7 +89,7 @@ error [missing_wallet_address]: no active account; import one first | `index` | number \| null | HD derivation index; `null` for non-HD accounts | | `active` | boolean | `true` for the active account; `false` when `--account` selected a different one | | `addresses` | object | One entry per family the account can produce: `tron` (base58) and/or `evm` (`0x`, EIP-55 checksummed) | -| `derivationPath` | object \| null | The BIP32 path behind each address: every family for a `seed` account, the single chosen path for a `ledger` account; `null` for `privateKey` and `watch`, which were never derived | +| `derivationPath` | null | Always `null`; `current` does not unlock the seed or provide derivation information | | `seedId` | string | Owning seed wallet id (`seed` accounts only) | | `family` | string | Chain family this account is bound to — single-family accounts (`watch`, `ledger`) only | | `receiveAddress` | string | Present in JSON only when `--qr` was requested; address selected by `--network` | diff --git a/ts/docs/commands/delete.md b/ts/docs/commands/delete.md index 901d6c862..a4a2544d7 100644 --- a/ts/docs/commands/delete.md +++ b/ts/docs/commands/delete.md @@ -22,7 +22,7 @@ Plus [global options](index.md). ## Notes -Deleting an HD wallet cascades from the seed root — all derived accounts go with it. On-chain assets are untouched; re-import the mnemonic to regain access. Back up first. Metadata-only — no master password needed. +Deleting an HD wallet cascades from the seed root — all derived accounts go with it. On-chain assets are untouched. Run [`backup`](backup.md) first and follow any warning it reports. Metadata-only — no master password needed. ## Examples diff --git a/ts/docs/commands/derive.md b/ts/docs/commands/derive.md index 980c6256a..0236c0693 100644 --- a/ts/docs/commands/derive.md +++ b/ts/docs/commands/derive.md @@ -1,18 +1,19 @@ # wallet-cli derive -Derive the next HD account from a seed wallet (by --seed-id). +Derive the next HD account from a seed wallet. ## Synopsis ``` -wallet-cli derive --seed-id [--index ] [--label ] [options] +wallet-cli derive [--seed-id ] [--account ] [--index ] [--label ] [options] ``` ## Options | Option | Description | |---|---| -| `--seed-id ` | seed id of the HD wallet to derive from — the HD group header in `list` [required] | +| `--seed-id ` | seed id of the HD wallet to derive from. Takes precedence over `--account` | +| `--account ` | account ID, label, or address belonging to the HD wallet. Defaults to the active account | | `--index ` | explicit HD account index; omit to use the next free index. An index that already exists is not re-derived — the existing account is made active and `status` comes back `"existing"` | | `--label ` | label for the new account, 1-64 chars; omit to auto-generate | | `--password-stdin` | read the master password from stdin (fd 0) | @@ -21,32 +22,46 @@ Plus [global options](index.md). ## Notes -Private-key and Ledger accounts have no seed and cannot derive. See [Accounts & HD](../concepts/accounts-and-hd.md). +You can select the wallet through any of its HD accounts; it does not have to be index 0. When both selectors are present, `--seed-id` takes precedence. Without either selector, `derive` uses the active account. + +Private-key, Ledger, and watch-only accounts have no seed and cannot derive. Select an HD account or pass `--seed-id`. See [Accounts & HD](../concepts/accounts-and-hd.md). + +When creating a new index, `derive` refuses a wallet containing an unsupported stored TRON derivation with `legacy_derivation`. A stored address that does not match the seed fails with `derivation_mismatch`. + +If `--index` selects an existing slot, no new key is derived. A verified account is made active and returns `status: "existing"`; an address that does not match the seed fails with `derivation_mismatch` before the active account changes. Reselecting a legacy slot succeeds but warns that default mnemonic recovery will not recreate its TRON address. The phrase can still derive the key at the reported path; follow [Recover addresses after `legacy_derivation`](../troubleshooting/legacy-derivation-recovery.md). ## Examples In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. ```bash -printf '%s' "$PW" | wallet-cli derive --seed-id wlt_y8cz6xda --password-stdin +printf '%s' "$PW" | wallet-cli derive --password-stdin +``` + +```bash +printf '%s' "$PW" | wallet-cli derive --account main-1 --password-stdin +``` + +```bash +printf '%s' "$PW" | wallet-cli derive --seed-id wlt_vy5n6qhh --password-stdin ``` ```console ✅ Derived sub-account "main-1" - Account ID wlt_y8cz6xda.1 + Account ID wlt_vy5n6qhh.1 Index 1 - TRON address TWCa1W6BkcXZnRGxeZZw9jh8eNgULDVGzj - EVM address 0x2395227A93465175c6D6EAF2B9d37c2cC0BaB60c + TRON address TKpmAZmDcGhJBugwAhbJ1ubWeTM4VZgRbK + EVM address 0x7Fee0863cB70a3C7c937A292220dD0C52E2526e0 Active yes - Note shares master mnemonic; no separate backup needed + Note shares the wallet's recovery phrase ``` ```bash -printf '%s' "$PW" | wallet-cli derive --seed-id wlt_y8cz6xda --password-stdin -o json +printf '%s' "$PW" | wallet-cli derive --seed-id wlt_vy5n6qhh --password-stdin -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"derive","data":{"status":"created","accountId":"wlt_y8cz6xda.1","label":"main-1","type":"seed","index":1,"active":true,"addresses":{"tron":"TWCa1W6BkcXZnRGxeZZw9jh8eNgULDVGzj","evm":"0x2395227A93465175c6D6EAF2B9d37c2cC0BaB60c"},"seedId":"wlt_y8cz6xda","derivationPath":{"tron":"m/44'/195'/1'/0/0","evm":"m/44'/60'/0'/0/1"}},"meta":{"durationMs":1013,"warnings":[]}} +{"schema":"wallet-cli.result.v1","success":true,"command":"derive","data":{"status":"created","accountId":"wlt_vy5n6qhh.1","label":"main-1","type":"seed","index":1,"active":true,"addresses":{"tron":"TKpmAZmDcGhJBugwAhbJ1ubWeTM4VZgRbK","evm":"0x7Fee0863cB70a3C7c937A292220dD0C52E2526e0"},"seedId":"wlt_vy5n6qhh","derivationPath":{"tron":"m/44'/195'/0'/0/1","evm":"m/44'/60'/0'/0/1"}},"meta":{"durationMs":980,"warnings":[]}} ``` ## Output @@ -62,12 +77,12 @@ printf '%s' "$PW" | wallet-cli derive --seed-id wlt_y8cz6xda --password-stdin -o | `index` | number | HD derivation index | | `active` | boolean | Always `true` (the new account is made active) | | `addresses` | object | One address per family the account can produce: `tron` (base58) and `evm` (`0x`, EIP-55 checksummed) | -| `derivationPath` | object | The BIP44 path each address came from: `{"tron":"m/44'/195'/'/0/0","evm":"m/44'/60'/0'/0/"}` | +| `derivationPath` | object | The verified BIP44 path each address came from. A newly created account reports `m/44'/'/0'/0/` for both families; `--index` naming an existing account reports that account's actual current or legacy path | | `seedId` | string | Owning seed wallet id | ## Exit status -`0` success · `1` execution failure · `2` usage error. See [machine-interface](../machine-interface.md). +`0` success · `1` execution failure, including `legacy_derivation` and `derivation_mismatch` (see Notes) · `2` usage error. See [machine-interface](../machine-interface.md). ## See also diff --git a/ts/docs/commands/import/ledger.md b/ts/docs/commands/import/ledger.md index 682ae1b5a..5b47472f5 100644 --- a/ts/docs/commands/import/ledger.md +++ b/ts/docs/commands/import/ledger.md @@ -14,7 +14,7 @@ wallet-cli import ledger --app [--index | --path | - | Option | Description | |---|---| | `--app ` | **Required.** Ledger app to open on the device; this is what selects the chain family and the derivation scheme | -| `--index ` | Account index under wallet-cli's family path template. Mutually exclusive with `--path` / `--address` | +| `--index ` | Account index under **Ledger Live's** template (`m/44'/'/'/0/0`), not the software one `derive` uses. Mutually exclusive with `--path` / `--address` | | `--path ` | Explicit derivation path, e.g. `m/44'/195'/0'/0/0` (TRON) or `m/44'/60'/0'/0/0` (Ethereum) | | `--address ` | Known address to locate by bounded scan | | `--scan-limit ` | Indexes to scan with `--address` (default 20) | @@ -28,7 +28,9 @@ Creates a watch-only entry; no secret is stored. Requires the device unlocked wi When all three locators are omitted, an attached TTY opens a paged account selector (five derived addresses at a time). In non-interactive use there is no selector and the command falls back to index 0; pass `--index`, `--path`, or `--address` explicitly in scripts. -For Ethereum, `--index ` uses wallet-cli's MetaMask-style path `m/44'/60'/0'/0/`. Ledger Live commonly uses `m/44'/60'/'/0/0`; use an explicit `--path` when importing an account created under that scheme. +`--index ` follows **Ledger Live's** template on both chains — `m/44'/195'/'/0/0` for TRON and `m/44'/60'/'/0/0` for Ethereum. The interactive picker walks the same template and prints each path beside its address. + +Software accounts instead use `m/44'/'/0'/0/`. The two templates agree only at index 0. Use `--path` to register any other device derivation scheme; `--address` and `--scan-limit` search only the Ledger Live template. `--app` is what makes a Ledger account **single-family**: the TRON app registers a `tron` account and the Ethereum app an `evm` one, and the resulting account has only that one address. Import the same device twice, once per app, to hold both. See [Ledger guide](../../guide/ledger.md). @@ -59,7 +61,7 @@ wallet-cli import ledger --app tron --index 0 --label cold -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"import.ledger","data":{"status":"created","accountId":"wlt_7h2k9d3m","label":"cold","type":"ledger","index":null,"active":true,"addresses":{"tron":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ"},"family":"tron","path":"m/44'/195'/0'/0/0"},"meta":{"durationMs":812,"warnings":[]}} +{"schema":"wallet-cli.result.v1","success":true,"command":"import.ledger","data":{"status":"created","accountId":"wlt_7h2k9d3m","label":"cold","type":"ledger","index":null,"active":true,"addresses":{"tron":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ"},"family":"tron","path":"m/44'/195'/0'/0/0","derivationPath":{"tron":"m/44'/195'/0'/0/0"}},"meta":{"durationMs":812,"warnings":[]}} ``` ## Output @@ -77,6 +79,7 @@ wallet-cli import ledger --app tron --index 0 --label cold -o json | `addresses` | object | The single address, keyed by its family — `{"tron":"T…"}` for the TRON app, `{"evm":"0x…"}` for the Ethereum app | | `family` | string | Chain family selected by `--app` — `tron` or `evm` | | `path` | string | Derivation path on the device | +| `derivationPath` | object | The same verified device path, keyed by its chain family for the common account descriptor shape | ## Exit status diff --git a/ts/docs/commands/import/watch.md b/ts/docs/commands/import/watch.md index 72b08a996..bffd2b2f2 100644 --- a/ts/docs/commands/import/watch.md +++ b/ts/docs/commands/import/watch.md @@ -46,7 +46,7 @@ wallet-cli import watch --address TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ --label col ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"import.watch","data":{"status":"created","accountId":"wlt_jsyq8fxe","label":"cold","type":"watch","index":null,"active":false,"addresses":{"tron":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ"},"family":"tron"},"meta":{"durationMs":36,"warnings":[]}} +{"schema":"wallet-cli.result.v1","success":true,"command":"import.watch","data":{"status":"created","accountId":"wlt_jsyq8fxe","label":"cold","type":"watch","index":null,"active":false,"addresses":{"tron":"TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ"},"family":"tron","derivationPath":null},"meta":{"durationMs":36,"warnings":[]}} ``` ## Output @@ -63,6 +63,7 @@ wallet-cli import watch --address TMSgJxtPw29AFEHMXsjGo4kWV7UwbCToHJ --label col | `active` | boolean | Whether this account is already the current active account. Registering a watch-only account does not select it; use [`use`](../use.md) explicitly | | `addresses` | object | The single address, keyed by its family — `{"tron":"T…"}` or `{"evm":"0x…"}` | | `family` | string | Chain family detected from the address — `tron` or `evm` | +| `derivationPath` | null | Always `null`; a watch-only address was not derived by this wallet | ## Exit status diff --git a/ts/docs/commands/list.md b/ts/docs/commands/list.md index 43f538b52..cb38edc97 100644 --- a/ts/docs/commands/list.md +++ b/ts/docs/commands/list.md @@ -28,8 +28,9 @@ wallet-cli list --network tron:3448148188 ```console warning: 1 account(s) have no tron address and are not shown; use --network to switch, or --output json to see every family -HD wlt_z259a1hq -└─ [0] main TE9kPMtaMjfZN95CuPRsCHUQGWwx9EcJW8 +HD wlt_hmzp5y4b +├─ [0] main TSKyFs16wArLhZ8jQnxAbcP7zQevYoXtQe +└─ [1] main-1 TKiSrWgCCYjMPJsVNLFKQ2kSpzhhXzAv4G watch-only └─ watch-test THdUXD3mZqT5aMnPQMtBSJX9ANGjaeUwQK @@ -43,11 +44,12 @@ wallet-cli list --network eip155:11155111 ```console warning: 1 account(s) have no evm address and are not shown; use --network to switch, or --output json to see every family -HD wlt_z259a1hq -└─ [0] main 0x7B28FE10FBccE88c3967ff0Fd64f1ffB46b46C9C +HD wlt_hmzp5y4b +├─ [0] main 0xE30cd287565D36d7BA9f1405DbF4Ea56690B5BF1 +└─ [1] main-1 0x7733DA595d51e8603A3Bb2450eAC456B0D077B71 watch-only -└─ watch_evm 0xe4aAd11792F7E74f1B5cbce65f9a1E207c952961 (active) +└─ watch_evm 0xe4aAd11792F7E74f1B5cbce65f9a1E207c952961 (active) ``` HD accounts are grouped by seed and carry an `[index]`; non-HD entries (private key / watch-only / Ledger) are grouped by type and have no `[index]`. @@ -57,7 +59,7 @@ wallet-cli list -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"list","data":[{"accountId":"wlt_z259a1hq.0","label":"main","type":"seed","index":0,"active":false,"addresses":{"tron":"TE9kPMtaMjfZN95CuPRsCHUQGWwx9EcJW8","evm":"0x7B28FE10FBccE88c3967ff0Fd64f1ffB46b46C9C"},"seedId":"wlt_z259a1hq","derivationPath":{"tron":"m/44'/195'/0'/0/0","evm":"m/44'/60'/0'/0/0"}},{"accountId":"wlt_whxjk6na","label":"watch-test","type":"watch","index":null,"active":false,"addresses":{"tron":"THdUXD3mZqT5aMnPQMtBSJX9ANGjaeUwQK"},"family":"tron","derivationPath":null},{"accountId":"wlt_n5v4r992","label":"watch_evm","type":"watch","index":null,"active":true,"addresses":{"evm":"0xe4aAd11792F7E74f1B5cbce65f9a1E207c952961"},"family":"evm","derivationPath":null}],"meta":{"durationMs":15,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"list","data":[{"accountId":"wlt_hmzp5y4b.0","label":"main","type":"seed","index":0,"active":false,"addresses":{"tron":"TSKyFs16wArLhZ8jQnxAbcP7zQevYoXtQe","evm":"0xE30cd287565D36d7BA9f1405DbF4Ea56690B5BF1"},"seedId":"wlt_hmzp5y4b","derivationPath":null},{"accountId":"wlt_hmzp5y4b.1","label":"main-1","type":"seed","index":1,"active":false,"addresses":{"tron":"TKiSrWgCCYjMPJsVNLFKQ2kSpzhhXzAv4G","evm":"0x7733DA595d51e8603A3Bb2450eAC456B0D077B71"},"seedId":"wlt_hmzp5y4b","derivationPath":null},{"accountId":"wlt_m8ecsy79","label":"watch-test","type":"watch","index":null,"active":false,"addresses":{"tron":"THdUXD3mZqT5aMnPQMtBSJX9ANGjaeUwQK"},"family":"tron","derivationPath":null},{"accountId":"wlt_sypwt6rc","label":"watch_evm","type":"watch","index":null,"active":true,"addresses":{"evm":"0xe4aAd11792F7E74f1B5cbce65f9a1E207c952961"},"family":"evm","derivationPath":null}],"meta":{"durationMs":18,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} ``` ## Output @@ -72,7 +74,7 @@ wallet-cli list -o json | `index` | number \| null | HD derivation index within the seed; `null` for non-HD accounts | | `active` | boolean | Whether this is the account commands default to | | `addresses` | object | One entry per family the account can produce: `tron` (base58) and/or `evm` (`0x`, EIP-55 checksummed) | -| `derivationPath` | object \| null | The BIP32 path behind each address: every family for a `seed` account (`{"tron":"m/44'/195'/0'/0/0","evm":"m/44'/60'/0'/0/0"}`), the single chosen path for a `ledger` account; `null` for `privateKey` and `watch`, which were never derived | +| `derivationPath` | null | Always `null`; listing does not unlock seeds or provide derivation information. Use `derive` or `backup` when a verified path is required | | `seedId` | string | Owning seed wallet id (`seed` accounts only) | | `family` | string | Chain family this account is bound to — present only on single-family accounts (`watch`, `ledger`) | diff --git a/ts/docs/commands/rename.md b/ts/docs/commands/rename.md index 76635226a..2a2e25525 100644 --- a/ts/docs/commands/rename.md +++ b/ts/docs/commands/rename.md @@ -41,7 +41,7 @@ wallet-cli rename main-1 --label hot-hd -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"rename","data":{"previousLabel":"main-1","accountId":"wlt_0y2z0gvr.1","label":"hot-hd","type":"seed","index":1,"active":true,"addresses":{"tron":"TRzaAZWRvPCcmqNETTWvmMLDi6cKwM3gbR","evm":"0x94f2e5cbb4BcA39A3F6c252217a0F30A0D23660b"},"seedId":"wlt_0y2z0gvr","derivationPath":{"tron":"m/44'/195'/1'/0/0","evm":"m/44'/60'/0'/0/1"}},"meta":{"durationMs":14,"warnings":[]}} +{"schema":"wallet-cli.result.v1","success":true,"command":"rename","data":{"previousLabel":"main-1","accountId":"wlt_vy5n6qhh.1","label":"hot-hd","type":"seed","index":1,"active":true,"addresses":{"tron":"TKpmAZmDcGhJBugwAhbJ1ubWeTM4VZgRbK","evm":"0x7Fee0863cB70a3C7c937A292220dD0C52E2526e0"},"seedId":"wlt_vy5n6qhh","derivationPath":null},"meta":{"durationMs":31,"warnings":[]}} ``` ## Output @@ -57,7 +57,7 @@ wallet-cli rename main-1 --label hot-hd -o json | `index` | number \| null | HD derivation index; `null` for non-HD accounts | | `active` | boolean | Whether it is the active account | | `addresses` | object | One entry per family the account can produce: `tron` (base58) and/or `evm` (`0x`, EIP-55 checksummed) | -| `derivationPath` | object \| null | Per-family BIP44 path for derived accounts; `null` for `watch` / `privateKey`, which were never derived | +| `derivationPath` | null | Always `null`; `rename` changes only the label and does not unlock the seed or provide derivation information | | `seedId` | string | Owning seed wallet id (`seed` accounts only) | | `family` | string | Chain family this account is bound to — single-family accounts (`watch`, `ledger`) only | diff --git a/ts/docs/commands/use.md b/ts/docs/commands/use.md index 12a58b644..d1c44f7fb 100644 --- a/ts/docs/commands/use.md +++ b/ts/docs/commands/use.md @@ -24,18 +24,18 @@ wallet-cli use main-1 ```console ✅ Active account: main-1 - TRON address TRs9HgTuY3dT3yDasdFdP9WQHqL37891Ax - EVM address 0xf3ec542047Fe61E0b753a7EBca95B27a672F9cbe + TRON address TKpmAZmDcGhJBugwAhbJ1ubWeTM4VZgRbK + EVM address 0x7Fee0863cB70a3C7c937A292220dD0C52E2526e0 ``` -You can also select by accountId or address: `wallet-cli use wlt_758891fa.1` / `wallet-cli use TRs9Hg…`. +You can also select by accountId or address: `wallet-cli use wlt_vy5n6qhh.1` / `wallet-cli use TKpmAZ…`. ```bash wallet-cli use main-1 -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"use","data":{"previous":"wlt_758891fa.0","accountId":"wlt_758891fa.1","label":"main-1","type":"seed","index":1,"active":true,"addresses":{"tron":"TRs9HgTuY3dT3yDasdFdP9WQHqL37891Ax","evm":"0xf3ec542047Fe61E0b753a7EBca95B27a672F9cbe"},"seedId":"wlt_758891fa","derivationPath":{"tron":"m/44'/195'/1'/0/0","evm":"m/44'/60'/0'/0/1"}},"meta":{"durationMs":14,"warnings":[]}} +{"schema":"wallet-cli.result.v1","success":true,"command":"use","data":{"previous":"wlt_vy5n6qhh.0","accountId":"wlt_vy5n6qhh.1","label":"main-1","type":"seed","index":1,"active":true,"addresses":{"tron":"TKpmAZmDcGhJBugwAhbJ1ubWeTM4VZgRbK","evm":"0x7Fee0863cB70a3C7c937A292220dD0C52E2526e0"},"seedId":"wlt_vy5n6qhh","derivationPath":null},"meta":{"durationMs":25,"warnings":[]}} ``` ## Output @@ -51,7 +51,7 @@ wallet-cli use main-1 -o json | `index` | number \| null | HD derivation index; `null` for non-HD accounts | | `active` | boolean | Always `true` (just made active) | | `addresses` | object | One entry per family the account can produce: `tron` (base58) and/or `evm` (`0x`, EIP-55 checksummed) | -| `derivationPath` | object \| null | Per-family BIP44 path for derived accounts; `null` for `watch` / `privateKey`, which were never derived | +| `derivationPath` | null | Always `null`; `use` selects an account but does not unlock the seed or provide derivation information | | `seedId` | string | Owning seed wallet id (`seed` accounts only) | | `family` | string | Chain family this account is bound to — single-family accounts (`watch`, `ledger`) only | diff --git a/ts/docs/concepts/accounts-and-hd.md b/ts/docs/concepts/accounts-and-hd.md index 1df5811dd..61d6a81f8 100644 --- a/ts/docs/concepts/accounts-and-hd.md +++ b/ts/docs/concepts/accounts-and-hd.md @@ -12,24 +12,28 @@ wlt_z259a1hq.0 ← accountId = seedId.index (one account, one address per f wlt_z259a1hq.1 ``` -`create` makes a new seed plus account #0; `derive --seed-id wlt_…` adds the next account (or an explicit `--index`) from the same mnemonic. Restoring the mnemonic elsewhere re-derives the same addresses — which is why the mnemonic is the real backup and the master password is only local protection. Note that `create` does not print the mnemonic; run [`backup`](../commands/backup.md) to export it to an offline file. +`create` makes a new seed plus account #0; `derive` adds the next account (or an explicit `--index`) from the active HD wallet. Use `--account` to select it through any of its accounts, or `--seed-id` to name the seed directly. Restoring the mnemonic elsewhere re-derives the same addresses — which is why the mnemonic is the real backup and the master password is only local protection. Note that `create` does not print the mnemonic; run [`backup`](../commands/backup.md) to export it to an offline file. ## One account, one address per chain family A key is not tied to a chain, so **an account holds one address per [family](networks.md)** — a TRON base58 address and an EVM `0x` address — derived from the same seed at different BIP44 coin types: ``` -m/44'/195'/'/0/0 TRON +m/44'/195'/0'/0/ TRON m/44'/60'/0'/0/ EVM ``` -Both are real, independent addresses: they hold separate balances, and funding one does nothing for the other. `list -o json` and `current -o json` report them together, under `addresses` keyed by family, with `derivationPath` naming the template each came from: +Only the coin type differs: software accounts increment `address_index` for both families. Ledger +accounts are separate: their default paths increment the account level for both TRON and EVM, and +the exact device path is stored with the account. + +Both are real, independent addresses: they hold separate balances, and funding one does nothing for the other. `list -o json` and `current -o json` report them together under `addresses`, keyed by family. Those password-free account commands deliberately return `derivationPath: null`; `derive` and `backup`, which open the seed and can verify the cached addresses, report the actual paths: ```json {"accountId":"wlt_z259a1hq.0","label":"main","type":"seed","index":0, "addresses":{"tron":"TE9kPMtaMjfZN95CuPRsCHUQGWwx9EcJW8","evm":"0x7B28FE10FBccE88c3967ff0Fd64f1ffB46b46C9C"}, "seedId":"wlt_z259a1hq", - "derivationPath":{"tron":"m/44'/195'/0'/0/0","evm":"m/44'/60'/0'/0/0"}} + "derivationPath":null} ``` Which one a command acts as follows the **selected network**, not a setting on the account: `--network nile` uses the TRON address, `--network sepolia` the EVM one. Text listings show one family at a time and say how many accounts they left out; JSON always carries every family. @@ -57,7 +61,7 @@ Labels are unique, 1–64 chars, renameable (`rename`) — the stable handle is ## Lifecycle - `backup ` exports secret + metadata to a file created with mode **0600** and never overwritten (in the current working directory by default). Treat the file as the secret it contains — and mind where you run it, since the CLI does not check whether that directory is shared or version-controlled. The native format carries the seed or key itself, so it covers every family at once; `backup --keystore` carries a **single private key**, so `--network` selects which family's key it holds. -- `delete` removes accounts; **deleting an HD wallet cascades from the seed root** — all derived accounts of that seed go with it. The on-chain assets are untouched: re-import the mnemonic to regain access. +- `delete` removes accounts; **deleting an HD wallet cascades from the seed root** — all derived accounts of that seed go with it. Back up first and follow any warning it reports. - Losing the master password is unrecoverable locally; the escape hatch is always the mnemonic → `import mnemonic`. ## See also diff --git a/ts/docs/guide/ledger.md b/ts/docs/guide/ledger.md index 6fe0c0b9b..f5b8ed31e 100644 --- a/ts/docs/guide/ledger.md +++ b/ts/docs/guide/ledger.md @@ -18,13 +18,13 @@ Locally this creates a **watch-only** entry — no secret is stored; signing hap | Flag | Use when | |---|---| -| `--index ` | You know the account index under wallet-cli's family path template | +| `--index ` | You know the account index under Ledger Live's template | | `--path ` | You need an explicit derivation path, e.g. `m/44'/195'/0'/0/0` (TRON) or `m/44'/60'/0'/0/0` (Ethereum) | | `--address ` | You know the address; wallet-cli scans indexes to find it (`--scan-limit`, default 20) | **`--app` fixes the account to one chain family.** Unlike a software account — which holds a TRON *and* an EVM address from the same seed — a Ledger account has exactly the one address its app derives, and only works on networks of that family. Selecting it elsewhere fails with `family_mismatch`. Import the same device twice, once per app, to cover both. -With no locator, a TTY presents a paged account selector; a non-interactive invocation falls back to index 0. For Ethereum, wallet-cli's `--index ` template is `m/44'/60'/0'/0/` (MetaMask style), while Ledger Live commonly uses `m/44'/60'/'/0/0`. Use `--path` to register the exact Ledger Live account instead of assuming the indexes are interchangeable. +With no locator, a TTY presents a paged account selector; a non-interactive invocation falls back to index 0. `--index `, the selector, and `--address` scanning use **Ledger Live's** template: `m/44'/195'/'/0/0` for TRON and `m/44'/60'/'/0/0` for Ethereum. Software accounts use `m/44'/'/0'/0/` instead. Register any other device derivation scheme with `--path`. Confirm with `wallet-cli list` — the account appears alongside your software accounts and works with `use`, `--account`, and every query command. `list` shows one family at a time, so a TRON-app account is invisible under `--network sepolia` and vice versa; `-o json` shows every account regardless. diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index 25e220535..02d7c635f 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -244,6 +244,8 @@ Common codes at exit **1** (execution — runtime failure): | `auth_failed` | Wrong master password (decryption failed) | | `signing_rejected` / `transaction_rejected` | Signing or broadcast rejected (device or chain) | | `watch_only_no_signer` | The account is watch-only and cannot sign | +| `legacy_derivation` | A seed account uses a TRON derivation path this version no longer produces. Raised when signing that TRON address or deriving a new account in the same wallet. Follow [Recover addresses after `legacy_derivation`](troubleshooting/legacy-derivation-recovery.md) | +| `derivation_mismatch` | The account's stored address matches no derivation path of its seed — `wallets.json` and the encrypted vault disagree, which an edited wallet file or a wallet pointing at the wrong vault would cause | | `invalid_mnemonic` / `invalid_private_key` | Storage validation rejected a malformed mnemonic or private key; interactive import normally catches it at the prompt and asks again | | `token_metadata_unavailable` | Required token metadata could not be read from the selected network. This one crosses exit codes: most sites raise it at exit `1`, but `tx send` on TRON raises it at exit **2** when a contract answers no `decimals()` and the address book has no entry either — there, the call itself has to change | | `wrong_device_seed` | Connected Ledger does not match the registered account | diff --git a/ts/docs/troubleshooting.md b/ts/docs/troubleshooting.md index d3ead7cfa..4071c4ac1 100644 --- a/ts/docs/troubleshooting.md +++ b/ts/docs/troubleshooting.md @@ -38,6 +38,11 @@ A nonce that is *ahead* of the account's next one is only a `meta.warnings` entr `create` (and other password-setting commands) rejected the master password. It must be **at least 8 characters** and include an **uppercase letter, a lowercase letter, a digit, and a special character** (`!@#$%^&*()-_=+[]{};:,.?`). The error message names the specific rule you missed. +## `legacy_derivation` (exit 1) + +A seed account uses an older TRON derivation path. Follow [Recover addresses after +`legacy_derivation`](troubleshooting/legacy-derivation-recovery.md). + ## `tty_required` / `auth_required` (exit 2 / exit 1) A credential, secret, or signing-device approval was needed but none was available. diff --git a/ts/docs/troubleshooting/legacy-derivation-recovery.md b/ts/docs/troubleshooting/legacy-derivation-recovery.md new file mode 100644 index 000000000..9ed9a919f --- /dev/null +++ b/ts/docs/troubleshooting/legacy-derivation-recovery.md @@ -0,0 +1,133 @@ +# Recover addresses after `legacy_derivation` + +You may see an error like this: + +```text +account "main-1" was derived at m/44'/195'/1'/0/0, a TRON path this version no longer produces, so it cannot be signed here. Follow the complete recovery procedure before deleting anything: + https://github.com/tronprotocol/wallet-cli/blob/wallet-cli-4.13.1/ts/docs/troubleshooting/legacy-derivation-recovery.md +``` + +## What happened + +Before v4.13.1, mnemonic wallets derived TRON sub-accounts with a legacy path. v4.13.1 +corrected that path. TRON accounts previously derived at index `1` or later must be re-imported as +standalone private-key accounts to keep using their existing addresses. Index `0` is unchanged. + +The recovery phrase still derives every legacy key when used with its original path. The problem +is that v4.13.1's default import and derive flow uses the corrected path and therefore does not +automatically recreate those TRON addresses. + +Ethereum derivation did not change. After preserving any legacy TRON accounts, remove and +re-import the mnemonic wallet, then derive the same indexes again. This restores every original +Ethereum address and creates new TRON addresses with the corrected derivation. + +This recovery reorganizes local keys; it does not move on-chain funds. + +## How to recover the addresses + +The examples below start with this wallet: + +```bash +wallet-cli list --network tron:728126428 +``` + +```text +HD wlt_abc123 +├─ [0] main TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6 +├─ [1] main-1 TCjow1qG4ZvDNj5ZRCF2RSuS2kMCGKK1JJ (active) +└─ [2] main-2 TKUwMQUhHTADfAtfG4gAHwXFGa3PYDieMJ +``` + +Save the account list and recovery phrase before changing anything: + +```bash +wallet-cli list -o json > ./accounts-before.json +wallet-cli backup main --out ./main-mnemonic.json +``` + +Choose **Native wallet backup** when prompted. Keep both files private and out of source control. +The mnemonic backup warning identifies every TRON account that needs the next step. + +### 1. Preserve the legacy TRON addresses + +Export every affected TRON account as a keystore: + +```bash +wallet-cli backup main-1 --keystore --network tron:728126428 \ + --out ./main-1-tron.keystore.json +wallet-cli backup main-2 --keystore --network tron:728126428 \ + --out ./main-2-tron.keystore.json +``` + +Import the keystores with new labels: + +```bash +wallet-cli import keystore ./main-1-tron.keystore.json --label main-1-legacy-tron +wallet-cli import keystore ./main-2-tron.keystore.json --label main-2-legacy-tron +``` + +For each import, enter the wallet master password, then enter it again as the keystore password. +Confirm that the imported TRON addresses match the original list: + +```bash +wallet-cli list --network tron:728126428 +``` + +```text +HD wlt_abc123 +├─ [0] main TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6 +├─ [1] main-1 TCjow1qG4ZvDNj5ZRCF2RSuS2kMCGKK1JJ +└─ [2] main-2 TKUwMQUhHTADfAtfG4gAHwXFGa3PYDieMJ + +private key +├─ main-1-legacy-tron TCjow1qG4ZvDNj5ZRCF2RSuS2kMCGKK1JJ +└─ main-2-legacy-tron TKUwMQUhHTADfAtfG4gAHwXFGa3PYDieMJ (active) +``` + +Do not remove the mnemonic wallet until every TRON address you need appears under `private key`. + +> If you are certain you do not need the old TRON addresses, skip this step and continue below. + +### 2. Rebuild the mnemonic wallet + +Delete the original mnemonic wallet. This removes its entire HD group but leaves the standalone +TRON accounts imported above: + +```bash +wallet-cli delete main --yes +``` + +Re-import the recovery phrase from `main-mnemonic.json`: + +```bash +wallet-cli import mnemonic --label main +``` + +The import recreates the root account at index `0` with the label `main`. + +### 3. Restore every Ethereum index + +Recreate each previous index from the new root account: + +```bash +wallet-cli derive --account main --index 1 --label main-1 +wallet-cli derive --account main --index 2 --label main-2 +``` + +`import mnemonic` restores only index `0`; every previous index must be derived explicitly. + +Check the result: + +```bash +wallet-cli list --network eip155:1 +wallet-cli list --network tron:728126428 +``` + +Under the new HD group: + +- Every Ethereum address must match the same index in `accounts-before.json`. +- Index `0` has the same TRON address as before. +- Index `1` and later have new TRON addresses using the corrected derivation. + +The old TRON addresses and their funds remain available under the imported `private key` accounts. +Move the mnemonic and keystore files to secure storage after verification. diff --git a/ts/package-lock.json b/ts/package-lock.json index b10b1c0f3..6931b2149 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "4.13.0", + "version": "4.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tron-walletcli/wallet-cli", - "version": "4.13.0", + "version": "4.13.1", "license": "LGPL-3.0-or-later", "dependencies": { "@ledgerhq/hw-app-eth": "^7.8.15", diff --git a/ts/package.json b/ts/package.json index d7a4b63a9..5b7ed1dd7 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "4.13.0", + "version": "4.13.1", "description": "Agent-first TypeScript CLI wallet for TRON — deterministic commands, JSON output, and discoverable schemas", "type": "module", "bin": { 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 19bdcffa1..e7836432f 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -73,6 +73,23 @@ describe("text formatters", () => { expect(missing.sort()).toEqual([]); }); + + it.each(["created", "existing"])( + "derive %s receipt does not promise that no separate recovery action is needed", + (status) => { + const out = TextFormatters.walletDerive({ + status, + accountId: "wlt_seed.1", + label: "main-1", + index: 1, + active: true, + addresses: { tron: "TLegacy", evm: "0x1234" }, + }) as string; + + expect(out).toContain("shares the wallet's recovery phrase"); + expect(out).not.toContain("no separate backup needed"); + }, + ); }); describe("permissionShow formatter", () => { diff --git a/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts index 1736691c5..069900050 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts @@ -138,6 +138,37 @@ describe("backup --keystore", () => { fileMode: "0600", }); expect(KeystoreV3.decrypt(f.writes[0]!.payload, VALID_PASSWORD)).toHaveLength(32); + expect(f.spySelect).not.toHaveBeenCalled(); + }); + + it("asks a fully interactive user which backup format to write", async () => { + const f = fixture({ tty: true }); + const accountId = await seedWallet(f); + + await buildCli(f.shellOpts).parseAsync(["backup", accountId]); + + expect(f.spySelect).toHaveBeenCalledWith({ + label: "Backup format", + choices: [ + { + value: false, + label: "Native wallet backup (recovery phrase for the whole HD wallet)", + }, + { value: true, label: "Web3 keystore (single TRON private key)" }, + ], + }); + expect(f.envelope().data).toMatchObject({ format: "native", secretType: "mnemonic" }); + }); + + it("writes a keystore when the interactive format selector chooses it", async () => { + const f = fixture({ tty: true }); + const accountId = await seedWallet(f); + f.spySelect.mockResolvedValueOnce(true as never); + + await buildCli(f.shellOpts).parseAsync(["backup", accountId]); + + expect(f.envelope().data).toMatchObject({ format: "keystore", family: "tron" }); + expect(KeystoreV3.decrypt(f.writes[0]!.payload, VALID_PASSWORD)).toHaveLength(32); }); it("still verifies the master password", async () => { diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index f461c6c78..19dcdde3c 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -49,7 +49,7 @@ export const walletImportLedgerFields = z.object({ // parsed default makes `index` always present, and the locator rule below counts PRESENCE — // `--path` alone would then read as two locators. The default stays in the description. .describe( - "account index under the app's default path; omit with no --path/--address to use index 0; mutually exclusive with --path and --address", + "Ledger Live account index (m/44'/'/'/0/0); defaults to 0 when no locator is given; mutually exclusive with --path and --address", ), path: z .string() @@ -533,20 +533,22 @@ export function registerWalletCommands( } satisfies CommandDefinition); // ── derive ──────────────────────────────────────────────────────────────── - // Wallet-level op: --seed-id picks the HD wallet directly by its seed id. No --account/active. const addAccountFields = z.object({ seedId: z .string() .min(1) - .describe( - "seed id (wlt_…) of the HD wallet to derive from — shown as the HD group header in `list`", - ), + .optional() + .describe("seed id (wlt_…) of the HD wallet to derive from; takes precedence over --account"), + account: accountRef( + "accountId, label, or address belonging to the HD wallet; defaults to the active account", + { optional: true }, + ), index: z.coerce .number() .int() .nonnegative() .optional() - .describe("explicit HD account index, in account index; omit to use the next free index"), + .describe("address index in m/44'/'/0'/0/; omit to use the next free index"), label: Schemas.label() .optional() .describe( @@ -558,19 +560,21 @@ export function registerWalletCommands( network: "none", wallet: "none", auth: "required", - summary: "Derive the next HD account from a seed wallet (by --seed-id)", + summary: "Derive the next HD account from a seed wallet", // Minus the `--path` sentence — that flag is not implemented: a hand-picked derivation path // would yield an account that exists on ONE family, and every seed account here is derived // for all of them at once. - description: - "Derive the next HD account from a seed wallet (by --seed-id). Each family uses\n" + - "its own BIP44 template, so one derive yields an address per family.", + description: "Derive one address per family using m/44'/'/0'/0/.", fields: addAccountFields, input: addAccountFields, - examples: [{ cmd: "wallet-cli derive --seed-id wlt_ab12cd34" }], + examples: [ + { cmd: "wallet-cli derive" }, + { cmd: "wallet-cli derive --account main" }, + { cmd: "wallet-cli derive --seed-id wlt_ab12cd34" }, + ], formatText: TextFormatters.walletDerive, - run: async (_ctx, _net, input) => { - return wallets.derive(input.seedId, input.index, input.label); + run: async (ctx, _net, input) => { + return wallets.derive(input, (message) => ctx.warn(message)); }, } satisfies CommandDefinition); @@ -631,9 +635,9 @@ export function registerWalletCommands( ), keystore: z .boolean() - .default(false) + .optional() .describe( - "export as a standard Web3 keystore JSON (importable by TronLink and others, encrypted with your master password) instead of the native format", + "export as a standard Web3 keystore JSON instead of the native format; omit in an interactive terminal to choose", ), out: z .string() @@ -746,6 +750,27 @@ export function registerWalletCommands( } const account = input.account!; // guaranteed by backupInput's refine wallets.assertExportable(account); + const family = (network ?? ctx.networkRegistry.resolveDefault()).family; + // Keep pipes deterministic: only a fully interactive invocation asks which format to use. + // --password-stdin is the caller opting into the scriptable path, where omission retains the + // long-standing native default. + const keystore = + input.keystore ?? + (ctx.prompt.isTTY() && !ctx.secrets.has("password") + ? await ctx.prompt.select({ + label: "Backup format", + choices: [ + { + value: false, + label: "Native wallet backup (recovery phrase for the whole HD wallet)", + }, + { + value: true, + label: `Web3 keystore (single ${family.toUpperCase()} private key)`, + }, + ], + }) + : false); await ctx.secrets.primePassword({ mode: "verify", verify: (pw) => wallets.verifyPassword(pw), @@ -754,14 +779,9 @@ export function registerWalletCommands( // 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"), - (network ?? ctx.networkRegistry.resolveDefault()).family, - ) - : wallets.backup(account, input.out); + return keystore + ? wallets.backupKeystore(account, input.out, ctx.secrets.read("password"), family) + : wallets.backup(account, input.out, (m) => ctx.warn(m)); }, } satisfies CommandDefinition); diff --git a/ts/src/adapters/inbound/cli/render/wallet.ts b/ts/src/adapters/inbound/cli/render/wallet.ts index 861d2ff55..032722600 100644 --- a/ts/src/adapters/inbound/cli/render/wallet.ts +++ b/ts/src/adapters/inbound/cli/render/wallet.ts @@ -55,7 +55,7 @@ export const WalletFormatters = { ["Index", d.index === null || d.index === undefined ? "" : formatInt(d.index)], ...addressPairs(d), ["Active", d.active === true ? "yes" : ""], - ["Note", "shares master mnemonic; no separate backup needed"], + ["Note", "shares the wallet's recovery phrase"], ]); }) satisfies TextFormatter, walletDelete: ((data) => { diff --git a/ts/src/adapters/outbound/keystore/index.ts b/ts/src/adapters/outbound/keystore/index.ts index df255a614..fc2ccee2a 100644 --- a/ts/src/adapters/outbound/keystore/index.ts +++ b/ts/src/adapters/outbound/keystore/index.ts @@ -20,6 +20,11 @@ import type { WalletsFile, } from "../../../domain/types/index.js"; import { CryptoEnvelope } from "../persistence/crypto/index.js"; +import { + derivationMismatchError, + legacyDerivationError, + resolveDerivation, +} from "../../../domain/wallet/derivation-match.js"; import { Derivation } from "../../../domain/derivation/index.js"; import { familyOf, canonicalAddress, CHAIN_FAMILIES } from "../../../domain/family/index.js"; import { SOURCE_KINDS, sourceFamily } from "../../../domain/sources/index.js"; @@ -250,9 +255,19 @@ export class Keystore { const key = String(next); let created = false; if (!wallet.source.addresses[key]) { + // Guarding here, not above: `--index ` is a documented no-op that re-activates + // the slot, so refusing it would answer a request that asked for no new key with a + // message saying none can be derived. Only an actual derivation can mix templates. const seed = this.#decryptSeedFromVault(wallet.source.vaultId); + this.#assertDerivable(file, wallet, seed); wallet.source.addresses[key] = deriveSeedAddresses(seed, next); created = true; + } else { + // An existing slot is still a no-op for derivation, including a historical one, but its + // cached addresses must be verified before this method persists the selection change. + // Otherwise WalletService can report derivation_mismatch only after activeAccount changed. + const seed = this.#decryptSeedFromVault(wallet.source.vaultId); + this.#assertAccountMatches(wallet, next, seed); } const ref = accountRefOf(wallet, next); file.activeAccount = ref; @@ -261,6 +276,54 @@ export class Keystore { }); } + /** + * Refuse to derive a new key into a wallet whose existing accounts this build cannot account + * for. A wallet must never mix templates: once a legacy and a current account sit in one + * addresses map, nothing on disk tells them apart — both are `{ tron, evm }` under a numeric + * key — so every later reader is left guessing. + * + * The two failures are kept apart, as they are everywhere else: `legacy` means the account is + * stranded on the old path and has a rescue; no match at all means wallets.json and the vault + * disagree, which is a different problem with a different fix. + */ + #assertDerivable(file: WalletsFile, wallet: Wallet, seed: Bytes): void { + if (wallet.source.type !== "seed") return; + for (const existing of accountIndices(wallet.source)) { + if (existing === 0) continue; // identical under both templates; never legacy + const cached = wallet.source.addresses[String(existing)]?.tron; + if (!cached) continue; + const resolved = resolveDerivation(seed, "tron", existing, cached); + const ref = `${wallet.id}.${existing}`; + if (!resolved) { + throw derivationMismatchError( + "tron", + ref, + `so no further accounts can be derived from ${wallet.id}`, + ); + } + if (resolved.scheme === "legacy") { + throw legacyDerivationError(ref, resolved.path, "derive", { + account: file.labels[ref], + wallet: file.labels[accountRefOf(wallet, 0)], + }); + } + } + } + + /** Verify one existing slot without rejecting a known historical template. */ + #assertAccountMatches(wallet: Wallet, index: number, seed: Bytes): void { + if (wallet.source.type !== "seed") return; + const ref = accountRefOf(wallet, index); + const addresses = wallet.source.addresses[String(index)]; + if (!addresses) return; + for (const family of CHAIN_FAMILIES) { + const cached = addresses[family]; + if (!resolveDerivation(seed, family, index, cached)) { + throw derivationMismatchError(family, ref); + } + } + } + // ── selection / lookup ───────────────────────────────────────────────────── /** index is meaningful only for seed wallets; privateKey/ledger report -1. */ resolveAccount(refOrLabel: string, family?: ChainFamily): { wallet: Wallet; index: number } { @@ -760,14 +823,22 @@ export class Keystore { /** * The BIP44 path behind each of an account's addresses. - * - seed: computed per family from the index — the templates differ, which is exactly - * what a caller cannot otherwise see. + * - seed: computed per family from the index when every path is unambiguous. If any family has + * a historical candidate (`Derivation.legacyPaths`, today TRON at index >= 1), the whole value + * is null: a partial object would misleadingly make this generic, password-free descriptor + * look like a derivation query. Commands that open the seed replace null with verified paths. + * Asking `legacyPaths` keeps that rule in the one place that owns it, so a future historical + * template cannot be added there and forgotten here. * - 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 (CHAIN_FAMILIES.some((family) => Derivation.legacyPaths(family, index).length > 0)) + return null; + return Object.fromEntries( + CHAIN_FAMILIES.map((family) => [family, Derivation.path(family, 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 0efb126e3..2732e7d8c 100644 --- a/ts/src/adapters/outbound/keystore/keystore.test.ts +++ b/ts/src/adapters/outbound/keystore/keystore.test.ts @@ -849,18 +849,27 @@ describe("wallets.json schema version", () => { }); describe("descriptor carries each family's derivation path", () => { - // 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. - it("gives a seed account one path per family", () => { + // A generic descriptor does not present a partial path object: if one family's actual template + // cannot be determined without the seed, derivation information is unavailable as a whole. + it("reports no derivation information when one family's path is ambiguous", () => { 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", + expect(account2.derivationPath).toBeNull(); + }); + + // Index 0 is the same path under both templates, so it is knowable and must still be reported. + it("still reports both paths for index 0, where the templates agree", () => { + const root = mkdtempSync(join(tmpdir(), "ks-")); + const ks = new Keystore(root, new AtomicFileStore(), () => "masterpw123A"); + ks.import({ secret: MNEMONIC, type: "seed", label: "main" }); + + expect(ks.list()[0]!.derivationPath).toEqual({ + tron: "m/44'/195'/0'/0/0", + evm: "m/44'/60'/0'/0/0", }); }); @@ -887,3 +896,139 @@ describe("descriptor carries each family's derivation path", () => { expect(ks.list()[0]!.derivationPath).toEqual({ tron: "m/44'/195'/5'/0/0" }); }); }); + +describe("derive on a wallet holding a pre-correction account", () => { + const TRON_LEGACY_1 = "TCjow1qG4ZvDNj5ZRCF2RSuS2kMCGKK1JJ"; // m/44'/195'/1'/0/0 + + function keystoreWithLegacyAccount() { + const root = mkdtempSync(join(tmpdir(), "ks-legacy-")); + const store = new AtomicFileStore(); + const ks = new Keystore(root, store, () => "masterpw123A"); + ks.import({ secret: MNEMONIC, type: "seed", label: "main" }); + const path = join(root, "wallets.json"); + const file = store.readJson(path)!; + const source = file.wallets[0]!.source as Extract< + WalletsFile["wallets"][0]["source"], + { type: "seed" } + >; + source.addresses["1"] = { + tron: TRON_LEGACY_1, + evm: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + }; + store.writeJsonAll([{ path, value: file }]); + return new Keystore(root, store, () => "masterpw123A"); + } + + // A wallet must never hold both templates: nothing on disk distinguishes them afterwards, so + // a mixed wallet is permanently ambiguous to every later reader. + it("refuses, rather than adding a second template to the same wallet", () => { + const ks = keystoreWithLegacyAccount(); + expect(() => ks.addAccount(ks.list()[0]!.seedId!)).toThrowError( + expect.objectContaining({ code: "legacy_derivation" }), + ); + }); + + it("identifies the wallet and stranded account by label", () => { + const ks = keystoreWithLegacyAccount(); + const seedId = ks.list()[0]!.seedId!; + ks.rename(`${seedId}.1`, "legacy account"); + + let message = ""; + try { + ks.addAccount(seedId); + } catch (error) { + message = (error as Error).message; + } + + expect(message).toContain('wallet "main" holds account "legacy account"'); + expect(message).toContain("complete recovery procedure before deleting anything"); + expect(message).toContain("docs/troubleshooting/legacy-derivation-recovery.md"); + expect(message).not.toContain(`${seedId}.1`); + }); + + // `--index ` is documented as a no-op that just re-activates the slot, and a refusal + // there is wrong twice over: nothing new is being derived, and the message would tell the user + // "no further accounts can be derived" about a request that asked for none. The guard belongs + // to the branch that actually creates a key. + it("still re-activates an existing index rather than refusing", () => { + const ks = keystoreWithLegacyAccount(); + const seedId = ks.list()[0]!.seedId!; + + const result = ks.addAccount(seedId, 0); + + expect(result).toEqual({ accountId: `${seedId}.0`, created: false }); + }); + + // Including the stranded slot itself: activating an account is not signing it, and the signer + // is where the refusal belongs. `use wlt.1` already reaches it by another route. + it("re-activates the stranded slot itself without refusing", () => { + const ks = keystoreWithLegacyAccount(); + const seedId = ks.list()[0]!.seedId!; + + expect(ks.addAccount(seedId, 1)).toEqual({ accountId: `${seedId}.1`, created: false }); + }); + + // A cached address no template explains means wallets.json and the vault disagree. The signer + // and `backup --keystore` both refuse that state; deriving a new key into the same file while + // saying nothing would leave the caller to meet it later, on a signature. + it("refuses with derivation_mismatch when a slot matches no template at all", () => { + const root = mkdtempSync(join(tmpdir(), "ks-mismatch-")); + const store = new AtomicFileStore(); + const ks = new Keystore(root, store, () => "masterpw123A"); + ks.import({ secret: MNEMONIC, type: "seed", label: "main" }); + const path = join(root, "wallets.json"); + const file = store.readJson(path)!; + const source = file.wallets[0]!.source as Extract< + WalletsFile["wallets"][0]["source"], + { type: "seed" } + >; + // The index-0 address parked at index 1: a real TRON address, but one no template produces + // for index 1 from this seed. + source.addresses["1"] = { + tron: "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6", + evm: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", // real for index 1; only tron is checked + }; + store.writeJsonAll([{ path, value: file }]); + const fresh = new Keystore(root, store, () => "masterpw123A"); + + expect(() => fresh.addAccount(fresh.list()[0]!.seedId!)).toThrowError( + expect.objectContaining({ code: "derivation_mismatch" }), + ); + }); + + it("does not change the active account when an existing slot fails verification", () => { + const root = mkdtempSync(join(tmpdir(), "ks-mismatch-existing-")); + const store = new AtomicFileStore(); + const ks = new Keystore(root, store, () => "masterpw123A"); + const rootRef = ks.import({ secret: MNEMONIC, type: "seed", label: "main" }).accountId; + const path = join(root, "wallets.json"); + const file = store.readJson(path)!; + const source = file.wallets[0]!.source as Extract< + WalletsFile["wallets"][0]["source"], + { type: "seed" } + >; + source.addresses["1"] = { + tron: "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6", + evm: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + }; + store.writeJsonAll([{ path, value: file }]); + const fresh = new Keystore(root, store, () => "masterpw123A"); + + expect(() => fresh.addAccount(rootRef.split(".")[0]!, 1)).toThrowError( + expect.objectContaining({ code: "derivation_mismatch" }), + ); + expect(fresh.activeAccount()).toBe(rootRef); + }); + + // Most 4.13.0 users never ran derive. Index 0 is the same path under both templates, so their + // wallets are unaffected and must stay usable — blocking them would be a regression for a + // problem they do not have. + it("allows a wallet that only ever held index 0", () => { + const root = mkdtempSync(join(tmpdir(), "ks-clean-")); + const ks = new Keystore(root, new AtomicFileStore(), () => "masterpw123A"); + ks.import({ secret: MNEMONIC, type: "seed", label: "main" }); + + const result = ks.addAccount(ks.list()[0]!.seedId!); + expect(result.created).toBe(true); + }); +}); diff --git a/ts/src/application/services/ledger-account.test.ts b/ts/src/application/services/ledger-account.test.ts index e3c1f9193..1e4f2a213 100644 --- a/ts/src/application/services/ledger-account.test.ts +++ b/ts/src/application/services/ledger-account.test.ts @@ -62,4 +62,16 @@ describe("resolveLedgerPath", () => { expect(err.message).toContain("--index"); expect(err.message).toContain("--path"); }); + + it("offers the concise recovery choices without wallet-specific guidance", async () => { + const err = await resolveLedgerPath(fakeLedger(), "evm", { + address: "0xnope", + scanLimit: 3, + }).catch((e) => e); + expect(err.message).toContain("Ledger Live"); + expect(err.message).toContain("--scan-limit"); + expect(err.message).toContain("--index"); + expect(err.message).toContain("--path"); + expect(err.message).not.toMatch(/MetaMask|MEW|MyCrypto/); + }); }); diff --git a/ts/src/application/services/ledger-account.ts b/ts/src/application/services/ledger-account.ts index 0b997ed96..9b8e2591b 100644 --- a/ts/src/application/services/ledger-account.ts +++ b/ts/src/application/services/ledger-account.ts @@ -28,7 +28,7 @@ export async function resolveLedgerPath( family: ChainFamily, locator: LedgerLocator, ): Promise { - if (locator.index !== undefined) return Derivation.path(family, locator.index); + if (locator.index !== undefined) return Derivation.ledgerPath(family, locator.index); if (locator.path !== undefined) { // Two different failures, told apart. A malformed path is a bad VALUE — reporting it as // "coin_type ? does not match --app tron" describes a mismatch the user never had, and sends @@ -55,17 +55,18 @@ export async function resolveLedgerPath( if (locator.address !== undefined) { const limit = locator.scanLimit ?? DEFAULT_SCAN_LIMIT; for (let index = 0; index < limit; index++) { - const path = Derivation.path(family, index); + const path = Derivation.ledgerPath(family, index); if ((await ledger.getAddress(family, path, { display: false })) === locator.address) return path; } throw new WalletError( "ledger_address_not_found", - `address not found in the first ${limit} accounts; widen with --scan-limit , ` + - `or specify it directly with --index / --path `, + `address not found among the first ${limit} Ledger Live accounts; increase ` + + `--scan-limit, select a known account with --index, or register its exact derivation ` + + `path with --path`, ); } - return Derivation.path(family, 0); + return Derivation.ledgerPath(family, 0); } /** Derive Ledger accounts lazily in pages and let the inbound prompt port select one. */ @@ -80,15 +81,18 @@ export async function selectLedgerPath( const loadPage = async () => { const end = nextIndex + pageSize; for (; nextIndex < end; nextIndex++) { - const path = Derivation.path(family, nextIndex); + const path = Derivation.ledgerPath(family, nextIndex); const address = await ledger.getAddress(family, path, { display: false }); - choices.push({ value: path, label: `[${nextIndex}] ${address}` }); + // The path, not just the index: the list is Ledger Live's template, and a user whose + // account lives on another scheme has no way to tell from an address alone that the + // account they are looking for is not in this list at all. + choices.push({ value: path, label: `[${nextIndex}] ${address} ${path}` }); } return choices; }; await loadPage(); return prompt.select({ - label: `Select ${family} account`, + label: `Select ${family} account (Ledger Live template; use --path for another scheme)`, choices: [...choices], loadMore: loadPage, }); diff --git a/ts/src/application/services/signer/index.ts b/ts/src/application/services/signer/index.ts index b61a940f5..08dcb05bc 100644 --- a/ts/src/application/services/signer/index.ts +++ b/ts/src/application/services/signer/index.ts @@ -9,13 +9,20 @@ import type { LedgerDevice } from "../../ports/ledger-device.js"; import { walletAddress } from "../../../domain/wallet/index.js"; import { LedgerSigner } from "./ledger.js"; import { SoftwareSigner } from "./software.js"; -import { Derivation } from "../../../domain/derivation/index.js"; +import { + derivationMismatchError, + legacyDerivationError, + resolveDerivation, +} from "../../../domain/wallet/derivation-match.js"; import { UsageError, WalletError } from "../../../domain/errors/index.js"; import { FAMILIES } from "../../../domain/family/index.js"; export class SignerResolver { constructor( - private readonly keystore: Pick, + private readonly keystore: Pick< + AccountStore, + "resolveAccount" | "describe" | "decryptSeed" | "decryptKey" + >, private readonly ledger: LedgerDevice, private readonly signStrategies: Record, ) {} @@ -78,9 +85,19 @@ export class SignerResolver { } case "seed": { const { vaultId } = wallet.source; - const loadKey = () => - Derivation.derive(this.keystore.decryptSeed(vaultId), Derivation.path(family, index)) - .privateKey; + const ref = `${wallet.id}.${index}`; + const label = this.keystore.describe(ref).label; + const loadKey = () => { + // Inside the loader, not in assertCanSign: this needs the seed, and --dry-run and + // --build-only reach assertCanSign without a master password. Neither produces a + // signature, and both read the cached (correct) address, so both must keep working. + const seed = this.keystore.decryptSeed(vaultId); + const resolved = resolveDerivation(seed, family, index, address); + if (!resolved) throw derivationMismatchError(family, ref); + if (resolved.scheme === "legacy") + throw legacyDerivationError(ref, resolved.path, "sign", { account: label }); + return resolved.keyPair.privateKey; + }; return new SoftwareSigner(loadKey, address, this.signStrategies[family]); } case "ledger": diff --git a/ts/src/application/services/signer/legacy-derivation.test.ts b/ts/src/application/services/signer/legacy-derivation.test.ts new file mode 100644 index 000000000..573fb3963 --- /dev/null +++ b/ts/src/application/services/signer/legacy-derivation.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi } from "vitest"; + +// Cheap KDF for keystore encryption in this suite. Production untouched. +vi.mock( + "@noble/hashes/scrypt.js", + async () => + import("../../../adapters/outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), +); +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SignerResolver } from "./index.js"; +import { tronSignStrategy } from "../../../adapters/outbound/chain/tron/signing-strategy.js"; +import { Keystore } from "../../../adapters/outbound/keystore/index.js"; +import { AtomicFileStore } from "../../../adapters/outbound/persistence/fs/index.js"; +import type { LedgerDevice } from "../../ports/ledger-device.js"; +import type { WalletsFile } from "../../../domain/types/index.js"; + +const MNEMONIC = "test test test test test test test test test test test junk"; +const PW = "masterpw123A"; +const TRON_LEGACY_1 = "TCjow1qG4ZvDNj5ZRCF2RSuS2kMCGKK1JJ"; // m/44'/195'/1'/0/0 +const TRON_INDEX_0 = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; // m/44'/195'/0'/0/0 + +/** A keystore holding one seed whose account 1 still carries the pre-correction address — + * what a wallets.json written by 4.13.0 looks like after one `derive`. The address is written + * straight into the file because addAccount now derives the corrected path (and, after Task 5, + * refuses this wallet outright), so no API can still produce this shape. */ +function keystoreWithLegacyAccount(): Keystore { + const root = mkdtempSync(join(tmpdir(), "legacy-")); + const store = new AtomicFileStore(); + const ks = new Keystore(root, store, () => PW); + ks.import({ secret: MNEMONIC, type: "seed", label: "main" }); + + const path = join(root, "wallets.json"); + const file = store.readJson(path)!; + const source = file.wallets[0]!.source as Extract< + WalletsFile["wallets"][0]["source"], + { type: "seed" } + >; + source.addresses["1"] = { + tron: TRON_LEGACY_1, + evm: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + }; + store.writeJsonAll([{ path, value: file }]); + return new Keystore(root, store, () => PW); +} + +/** A keystore whose index-1 address matches neither the current nor the legacy template — the + * index-0 address borrowed into that slot, which no derivation at index 1 produces under either + * scheme. This is the same trick Task 2's `derivation-match.test.ts` uses for its own no-match + * case, so both suites agree on what "mismatch" means: `wallets.json` and the vault disagree, + * which is a data-integrity fault distinct from `legacy_derivation` (a template match, just an + * old one) and must not be reported as the same thing. */ +function keystoreWithMismatchedAccount(): Keystore { + const root = mkdtempSync(join(tmpdir(), "mismatch-")); + const store = new AtomicFileStore(); + const ks = new Keystore(root, store, () => PW); + ks.import({ secret: MNEMONIC, type: "seed", label: "main" }); + + const path = join(root, "wallets.json"); + const file = store.readJson(path)!; + const source = file.wallets[0]!.source as Extract< + WalletsFile["wallets"][0]["source"], + { type: "seed" } + >; + source.addresses["1"] = { + tron: TRON_INDEX_0, + evm: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + }; + store.writeJsonAll([{ path, value: file }]); + return new Keystore(root, store, () => PW); +} + +// Constructor order is (keystore, ledger, signStrategies) — see SignerResolver's declaration. +function resolverFor(ks: Keystore): SignerResolver { + return new SignerResolver(ks, {} as LedgerDevice, { tron: tronSignStrategy } as never); +} + +function seedId(ks: Keystore): string { + return ks.list()[0]!.seedId!; +} + +describe("signing an account left on the old TRON template", () => { + // Resolving must stay free of the keystore: `tx send --dry-run` and `--build-only` resolve a + // signer and never sign, and they take no master password. Throwing here would break them. + it("resolves the signer without touching the seed", () => { + const ks = keystoreWithLegacyAccount(); + const signer = resolverFor(ks).resolve(`${seedId(ks)}.1`, "tron"); + + expect(signer.address).toBe(TRON_LEGACY_1); + }); + + it("throws legacy_derivation once the key is actually loaded", async () => { + const ks = keystoreWithLegacyAccount(); + const signer = resolverFor(ks).resolve(`${seedId(ks)}.1`, "tron"); + + await expect(signer.signMessage("hello", {} as never)).rejects.toMatchObject({ + code: "legacy_derivation", + }); + }); + + // The message identifies the real path and sends the user to the complete, ordered recovery. + it("names the legacy path and the recovery guide", async () => { + const ks = keystoreWithLegacyAccount(); + const signer = resolverFor(ks).resolve(`${seedId(ks)}.1`, "tron"); + + const err = (await signer.signMessage("hello", {} as never).catch((e) => e)) as Error; + expect(err.message).toContain("m/44'/195'/1'/0/0"); + expect(err.message).toContain("complete recovery procedure before deleting anything"); + expect(err.message).toContain("docs/troubleshooting/legacy-derivation-recovery.md"); + expect(err.message).not.toMatch(/wallet-cli (backup|import|delete)/); + expect(err.message).not.toMatch(/recovery phrase|unique to wallet-cli|no other wallet/i); + }); + + it("uses the account label even when signing was requested by address", async () => { + const ks = keystoreWithLegacyAccount(); + const ref = `${seedId(ks)}.1`; + ks.rename(ref, "legacy account"); + const signer = resolverFor(ks).resolve(TRON_LEGACY_1, "tron"); + + const err = (await signer.signMessage("hello", {} as never).catch((e) => e)) as Error; + expect(err.message).toContain('account "legacy account"'); + expect(err.message).not.toContain(TRON_LEGACY_1); + }); + + // An unaffected account must not be dragged into the rescue flow: index 0 is the same path + // under both templates, which is most users who never ran derive. + it("signs an index-0 account normally, both templates agreeing there", async () => { + const ks = keystoreWithLegacyAccount(); + const signer = resolverFor(ks).resolve(`${seedId(ks)}.0`, "tron"); + + await expect(signer.signMessage("hello", {} as never)).resolves.toBeTypeOf("string"); + }); + + // A different failure from legacy_derivation: no template matches at all, so the seed cannot + // produce this address under any scheme. This is wallets.json and the vault disagreeing — a + // hand-edited file or a wallet pointing at the wrong vault — not an account this CLI once + // derived and now derives differently, so it must not be collapsed into the legacy code. + it("throws derivation_mismatch when no template produces the stored address", async () => { + const ks = keystoreWithMismatchedAccount(); + const signer = resolverFor(ks).resolve(`${seedId(ks)}.1`, "tron"); + + await expect(signer.signMessage("hello", {} as never)).rejects.toMatchObject({ + code: "derivation_mismatch", + }); + }); +}); 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 31f91193c..5efb1a1db 100644 --- a/ts/src/application/use-cases/wallet-service.keystore.test.ts +++ b/ts/src/application/use-cases/wallet-service.keystore.test.ts @@ -15,6 +15,7 @@ import { AtomicFileStore } from "../../adapters/outbound/persistence/fs/index.js import type { BackupRecord } from "../ports/backup-records.js"; import type { BackupFormat } from "../ports/backup-writer.js"; import { Derivation } from "../../domain/derivation/index.js"; +import type { WalletsFile } from "../../domain/types/index.js"; import { KeystoreV3 } from "../../domain/keystore/index.js"; import { tronHexAddress } from "../../domain/address/index.js"; import { derivePrivAddresses } from "../../domain/wallet/index.js"; @@ -65,15 +66,12 @@ function fakeRecords(seed: BackupRecord[] = []) { } function harness() { - const keystore = new Keystore( - mkdtempSync(join(tmpdir(), "wsk-")), - new AtomicFileStore(), - () => PW, - ); + const root = mkdtempSync(join(tmpdir(), "wsk-")); + const keystore = new Keystore(root, new AtomicFileStore(), () => PW); const writer = fakeWriter(); const store = fakeRecords(); const service = new WalletService(keystore, {} as any, writer, store, () => NOW); - return { keystore, writer, store, service }; + return { root, keystore, writer, store, service }; } describe("WalletService.backupKeystore", () => { @@ -153,6 +151,109 @@ describe("WalletService.backupKeystore", () => { }); }); +describe("WalletService derivation-path disclosure", () => { + it("withholds derivationPath from list/current/use/rename as a command-level policy", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + const seedId = accountId.split(".")[0]!; + h.keystore.addAccount(seedId, 1); + + expect(h.service.list().every((account) => account.derivationPath === null)).toBe(true); + expect(h.service.current(accountId).derivationPath).toBeNull(); + expect(h.service.use(`${seedId}.1`).derivationPath).toBeNull(); + expect(h.service.rename(`${seedId}.1`, "renamed").derivationPath).toBeNull(); + }); + + it("reports both verified paths from derive", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + const result = h.service.derive({ seedId: accountId.split(".")[0]!, index: 1 }); + + expect(result.derivationPath).toEqual({ + tron: "m/44'/195'/0'/0/1", + evm: "m/44'/60'/0'/0/1", + }); + }); +}); + +describe("WalletService derive selection", () => { + it("uses the active HD account and keeps working after a derived child becomes active", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + + expect(h.service.derive({}).accountId).toBe(`${accountId.split(".")[0]}.1`); + expect(h.service.derive({}).accountId).toBe(`${accountId.split(".")[0]}.2`); + }); + + it("accepts any HD child through --account", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + const seedId = accountId.split(".")[0]!; + h.keystore.addAccount(seedId, 1); + + expect(h.service.derive({ account: `${seedId}.1`, index: 2 }).accountId).toBe(`${seedId}.2`); + }); + + it("gives --seed-id precedence without resolving --account", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + const seedId = accountId.split(".")[0]!; + + expect( + h.service.derive({ seedId, account: "account-that-does-not-exist", index: 1 }).accountId, + ).toBe(`${seedId}.1`); + }); + + it("rejects a selected non-HD account with actionable guidance", () => { + const h = harness(); + const { accountId } = h.keystore.import({ + secret: RAW_KEY, + type: "privateKey", + label: "hot", + }); + + expect(() => h.service.derive({ account: accountId })).toThrowError( + /account is not HD; select an account belonging to an HD wallet or pass --seed-id/, + ); + }); + + it("does not tell an invalid --seed-id caller to pass the same flag again", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: RAW_KEY, type: "privateKey" }); + + expect(() => h.service.derive({ seedId: accountId })).toThrowError( + /wallet is not HD; --seed-id must name an HD seed wallet/, + ); + }); + + it("warns only when reselecting an existing legacy slot", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + const seedId = accountId.split(".")[0]!; + const warnings: string[] = []; + + h.service.derive({ seedId, index: 1 }, (message) => warnings.push(message)); + h.service.derive({ seedId, index: 1 }, (message) => warnings.push(message)); + expect(warnings).toEqual([]); // created-current and existing-current both need no warning + + const store = new AtomicFileStore(); + const path = join(h.root, "wallets.json"); + const file = store.readJson(path)!; + const source = file.wallets[0]!.source as Extract< + WalletsFile["wallets"][0]["source"], + { type: "seed" } + >; + source.addresses["1"]!.tron = "TCjow1qG4ZvDNj5ZRCF2RSuS2kMCGKK1JJ"; + store.writeJsonAll([{ path, value: file }]); + + h.service.derive({ seedId, index: 1 }, (message) => warnings.push(message)); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("legacy TRON path m/44'/195'/1'/0/0"); + expect(warnings[0]).toContain("recovery phrase can still derive this key"); + expect(warnings[0]).toContain("default mnemonic recovery will not recreate"); + }); +}); + describe("WalletService export audit log", () => { let h: ReturnType; beforeEach(() => { @@ -523,3 +624,122 @@ describe("WalletService.backupKeystore — what the audit log records", () => { expect(records[0]).toMatchObject({ family: "evm" }); }); }); + +describe("backup --keystore rescues an account on the old TRON path", () => { + const TRON_LEGACY_1 = "TCjow1qG4ZvDNj5ZRCF2RSuS2kMCGKK1JJ"; // m/44'/195'/1'/0/0 + const TRON_INDEX_0 = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; + + /** harness() plus a hand-written pre-correction account 1 — the shape no API can produce any + * more, because addAccount derives the corrected path and (Task 5) refuses this wallet. */ + function legacyHarness() { + const h = harness(); + h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + const store = new AtomicFileStore(); + const path = join(h.root, "wallets.json"); + const file = store.readJson(path)!; + const source = file.wallets[0]!.source as Extract< + WalletsFile["wallets"][0]["source"], + { type: "seed" } + >; + source.addresses["1"] = { + tron: TRON_LEGACY_1, + evm: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + }; + store.writeJsonAll([{ path, value: file }]); + return { ...h, seedId: h.keystore.list()[0]!.seedId! }; + } + + // This is the ONE place the binary may use a template it no longer produces, and its only + // purpose is to let the key leave. Exporting the current template's key would hand the user an + // empty address — while the refusal message told them to run exactly this command. + it("exports the key that owns the stored address, not the current template's", () => { + const h = legacyHarness(); + + const result = h.service.backupKeystore(`${h.seedId}.1`, undefined, PW, "tron"); + + const file = h.writer.writes[0]!.payload as { address: string }; + expect(file.address).toBe(tronHexAddress(TRON_LEGACY_1)); + expect(result.derivationPath).toEqual({ + tron: "m/44'/195'/1'/0/0", + evm: "m/44'/60'/0'/0/1", + }); + }); + + it("still exports the current template's key for an unaffected account", () => { + const h = legacyHarness(); + + h.service.backupKeystore(`${h.seedId}.0`, undefined, PW, "tron"); + + const file = h.writer.writes[0]!.payload as { address: string }; + expect(file.address).toBe(tronHexAddress(TRON_INDEX_0)); + }); +}); + +describe("native backup warns about accounts the default recovery flow will not recreate", () => { + const TRON_LEGACY_1 = "TCjow1qG4ZvDNj5ZRCF2RSuS2kMCGKK1JJ"; // m/44'/195'/1'/0/0 + + function legacyHarness() { + const h = harness(); + h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + const store = new AtomicFileStore(); + const path = join(h.root, "wallets.json"); + const file = store.readJson(path)!; + const source = file.wallets[0]!.source as Extract< + WalletsFile["wallets"][0]["source"], + { type: "seed" } + >; + source.addresses["1"] = { + tron: TRON_LEGACY_1, + evm: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + }; + store.writeJsonAll([{ path, value: file }]); + return { ...h, seedId: h.keystore.list()[0]!.seedId! }; + } + + it("names the account and path without claiming the phrase cannot derive its key", () => { + const h = legacyHarness(); + const warnings: string[] = []; + + h.service.backup(`${h.seedId}.0`, undefined, (m) => warnings.push(m)); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(`${h.seedId}.1`); + expect(warnings[0]).toContain("m/44'/195'/1'/0/0"); + expect(warnings[0]).toContain("default mnemonic recovery will NOT recreate"); + expect(warnings[0]).toContain("recovery phrase can still derive that key"); + expect(warnings[0]).not.toContain("recovery phrase does NOT back up"); + expect(warnings[0]).toMatch(/--keystore/); + }); + + // The warning is about the WALLET, not the account named on the command line: one mnemonic + // backs up every account of its seed, so backing up index 1 leaves the same gap. + it("warns no matter which account of the wallet was named", () => { + const h = legacyHarness(); + const warnings: string[] = []; + + h.service.backup(`${h.seedId}.1`, undefined, (m) => warnings.push(m)); + + expect(warnings).toHaveLength(1); + }); + + it("stays quiet for a wallet whose accounts are all on the current template", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: MNEMONIC, type: "seed", label: "main" }); + h.keystore.addAccount(accountId.split(".")[0]!, 1); + const warnings: string[] = []; + + h.service.backup(accountId, undefined, (m) => warnings.push(m)); + + expect(warnings).toEqual([]); + }); + + it("stays quiet for a private-key account, which has no derivation at all", () => { + const h = harness(); + const { accountId } = h.keystore.import({ secret: RAW_KEY, type: "privateKey" }); + const warnings: string[] = []; + + h.service.backup(accountId, undefined, (m) => warnings.push(m)); + + expect(warnings).toEqual([]); + }); +}); diff --git a/ts/src/application/use-cases/wallet-service.ts b/ts/src/application/use-cases/wallet-service.ts index b499f5cc7..c6fc34431 100644 --- a/ts/src/application/use-cases/wallet-service.ts +++ b/ts/src/application/use-cases/wallet-service.ts @@ -1,5 +1,12 @@ import { bytesToHex } from "@noble/hashes/utils.js"; import { Derivation } from "../../domain/derivation/index.js"; +import { + derivationMismatchError, + LEGACY_DERIVATION_RECOVERY_GUIDE, + legacyAccounts, + resolveDerivation, +} from "../../domain/wallet/derivation-match.js"; +import { walletAddress } from "../../domain/wallet/index.js"; import { CHAIN_FAMILIES, canonicalAddress, @@ -13,7 +20,7 @@ import { evmAddressFromPublicKey, tronHexAddress, } from "../../domain/address/index.js"; -import type { Bytes } from "../../domain/types/index.js"; +import type { AccountDescriptor, Bytes } from "../../domain/types/index.js"; import { ExecutionError, UsageError, WalletError } from "../../domain/errors/index.js"; import type { BackupWriter } from "../ports/backup-writer.js"; import type { BackupRecord, BackupRecordStore } from "../ports/backup-records.js"; @@ -23,6 +30,19 @@ import type { WalletRepository } from "../ports/wallet-repository.js"; const mutationStatus = (created: boolean): "created" | "existing" => created ? "created" : "existing"; +/** + * Password-free account commands deliberately do not make derivation claims. Their interface is + * account selection/identity, not key derivation, and an old TRON account cannot be distinguished + * from a corrected one without opening its seed. Keep that policy at the use-case seam so + * list/current/use/rename cannot drift apart. + */ +const withoutDerivationPath = ( + descriptor: T, +): Omit & { derivationPath: null } => ({ + ...descriptor, + derivationPath: null, +}); + const notExportable = (type: string) => new WalletError("not_exportable", `${type} accounts hold no exportable secret`); @@ -42,6 +62,15 @@ export interface BackupRecordQuery { account?: string; } +export interface DeriveRequest { + /** Explicit seed selection takes precedence over account and active-account selection. */ + seedId?: string; + /** Any account belonging to an HD wallet identifies that wallet. */ + account?: string; + index?: number; + label?: string; +} + export class WalletService { constructor( private readonly wallets: WalletRepository, @@ -84,7 +113,7 @@ export class WalletService { } list() { - return this.wallets.list(); + return this.wallets.list().map(withoutDerivationPath); } /** ids of wallets skipped by list() because this build does not know their source kind. */ @@ -94,55 +123,91 @@ export class WalletService { use(account: string) { const result = this.wallets.setActive(account); - return { previous: result.previous, ...this.wallets.describe(result.accountId) }; + return withoutDerivationPath({ + previous: result.previous, + ...this.wallets.describe(result.accountId), + }); } current(requestedAccount?: string) { const account = requestedAccount ?? this.wallets.activeAccount(); if (!account) throw new WalletError("missing_wallet_address", "no active account; import one first"); - return this.wallets.describe(account); + return withoutDerivationPath(this.wallets.describe(account)); } rename(account: string, label: string) { const result = this.wallets.rename(account, label); - return { previousLabel: result.previousLabel, ...this.wallets.describe(result.accountId) }; + return withoutDerivationPath({ + previousLabel: result.previousLabel, + ...this.wallets.describe(result.accountId), + }); } changePassword(oldPassword: string, newPassword: string) { return this.wallets.changePassword(oldPassword, newPassword); } - derive(seedId: string, index?: number, label?: string) { - // --seed-id is strictly the seed id (wlt_…) — the HD group header in `list`. No labels, no - // sub-account refs: labels/refs point at an account, and the seed (not an account) is the root. - const id = seedId.trim(); - if (!/^wlt_[^.]+$/.test(id)) { - throw new UsageError( - "invalid_value", - `--seed-id takes a seed id (wlt_…), not '${seedId}'; copy it from the HD group header in \`list\``, - ); - } - const wallet = this.wallets.resolveWallet(id); + derive(request: DeriveRequest, warn?: (message: string) => void) { + const wallet = (() => { + // Do not resolve --account when --seed-id is present: precedence means even a stale account + // override must not prevent an explicitly named seed wallet from being used. + if (request.seedId !== undefined) { + const id = request.seedId.trim(); + if (!/^wlt_[^.]+$/.test(id)) { + throw new UsageError( + "invalid_value", + `--seed-id takes a seed id (wlt_…), not '${request.seedId}'; copy it from the HD group header in \`list\``, + ); + } + return this.wallets.resolveWallet(id); + } + + const account = request.account ?? this.wallets.activeAccount(); + if (!account) { + throw new WalletError( + "missing_wallet_address", + "no active account; select an HD account with --account or pass --seed-id", + ); + } + return this.wallets.resolveAccount(account).wallet; + })(); + if (wallet.source.type !== "seed") { - // Its own code, for the same reason `account_not_found` has one: "that reference is not a - // seed wallet" has an obvious next step (`list`, and read the HD group headers), and an - // agent can only take it if the code says so rather than the English. throw new UsageError( "seed_not_found", - `${wallet.source.type} wallet is not HD; derive needs a seed wallet`, + request.seedId !== undefined + ? `${wallet.source.type} wallet is not HD; --seed-id must name an HD seed wallet` + : `${wallet.source.type} account is not HD; select an account belonging to an HD wallet or pass --seed-id `, ); } const baseLabel = this.wallets.describe(`${wallet.id}.0`).label; // the wallet's name (index-0 label) - const result = this.wallets.addAccount(wallet.id, index); - if (label) { - this.wallets.rename(result.accountId, label); + const result = this.wallets.addAccount(wallet.id, request.index); + if (request.label) { + this.wallets.rename(result.accountId, request.label); } else if (result.created) { // auto-name new accounts - so they read as siblings under the same seed. const newIndex = Number(result.accountId.split(".")[1]); this.wallets.rename(result.accountId, `${baseLabel ?? "hd"}-${newIndex}`); } - return { status: mutationStatus(result.created), ...this.wallets.describe(result.accountId) }; + const descriptor = this.#describeWithVerifiedDerivation(result.accountId); + const tronPath = descriptor.derivationPath?.tron; + if ( + !result.created && + descriptor.index !== null && + tronPath !== undefined && + Derivation.legacyPaths("tron", descriptor.index).includes(tronPath) + ) { + warn?.( + `existing account ${JSON.stringify(descriptor.label ?? descriptor.accountId)} uses legacy TRON path ${tronPath}. ` + + `The wallet's recovery phrase can still derive this key at that path, but this version's default mnemonic recovery will not recreate its TRON address. ` + + `Follow ${LEGACY_DERIVATION_RECOVERY_GUIDE}`, + ); + } + return { + status: mutationStatus(result.created), + ...descriptor, + }; } describe(account: string) { @@ -167,10 +232,12 @@ export class WalletService { if (type !== "seed" && type !== "privateKey") throw notExportable(type); } - backup(account: string, requestedPath?: string) { - const descriptor = this.wallets.describe(account); + /** `warn` names accounts that this version's default mnemonic recovery will not rediscover. */ + backup(account: string, requestedPath?: string, warn?: (message: string) => void) { const { wallet } = this.wallets.resolveAccount(account); const source = wallet.source; + const seed = source.type === "seed" ? this.wallets.decryptSeed(source.vaultId) : undefined; + const descriptor = this.#describeWithVerifiedDerivation(account, seed); const metadata = { accountId: descriptor.accountId, type: source.type, @@ -185,6 +252,26 @@ export class WalletService { passphraseSet = revealed.passphraseSet; secretType = "mnemonic"; payload = { ...metadata, secretType, passphraseSet, mnemonic: revealed.mnemonic }; + // The whole wallet, not the account named on the command line: one mnemonic is every + // account of its seed, so the gap is the same whichever one was asked for. + const stranded = legacyAccounts(seed!, source.addresses); + if (stranded.length > 0 && warn) { + warn( + `this version's default mnemonic recovery will NOT recreate ${stranded + .map((a) => `${wallet.id}.${a.index} (${a.path})`) + .join(", ")} — ` + + `${stranded.length === 1 ? "that account was" : "those accounts were"} derived at a TRON path this version no longer produces, ` + + `but the recovery phrase can still derive ${stranded.length === 1 ? "that key" : "those keys"} at the listed ${stranded.length === 1 ? "path" : "paths"}. ` + + `Export ${stranded.length === 1 ? "it" : "each"} separately before deleting anything:\n` + + stranded + .map( + (a) => + ` wallet-cli backup ${wallet.id}.${a.index} --keystore --network tron:728126428 --password-stdin`, + ) + .join("\n") + + `\nFollow ${LEGACY_DERIVATION_RECOVERY_GUIDE}`, + ); + } } else if (source.type === "privateKey") { secretType = "privateKey"; payload = { @@ -220,8 +307,11 @@ export class WalletService { masterPassword: string, family: ChainFamily, ) { - const descriptor = this.wallets.describe(account); - const privateKey = this.#exportablePrivateKey(account, family); + const { wallet } = this.wallets.resolveAccount(account); + const seed = + wallet.source.type === "seed" ? this.wallets.decryptSeed(wallet.source.vaultId) : undefined; + const descriptor = this.#describeWithVerifiedDerivation(account, seed); + const privateKey = this.#exportablePrivateKey(account, family, seed); const file = this.backups.write( descriptor.accountId, requestedPath, @@ -298,16 +388,50 @@ export class WalletService { return this.wallets.isInitialized(); } - /** 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, family: ChainFamily): Bytes { + /** + * A descriptor for a command that has opened the seed and can therefore state the path behind + * every cached address. This is intentionally separate from the password-free account views: + * a missing match is corruption/vault disagreement, never permission to print the current + * template as though it were an observed fact. + */ + #describeWithVerifiedDerivation(account: string, knownSeed?: Bytes): AccountDescriptor { + const descriptor = this.wallets.describe(account); + const { wallet, index } = this.wallets.resolveAccount(account); + if (wallet.source.type !== "seed") return descriptor; + + const seed = knownSeed ?? this.wallets.decryptSeed(wallet.source.vaultId); + const derivationPath: Record = {}; + for (const family of CHAIN_FAMILIES) { + const address = walletAddress(wallet, family, index); + if (!address) continue; + const resolved = resolveDerivation(seed, family, index, address); + if (!resolved) throw derivationMismatchError(family, descriptor.accountId); + derivationPath[family] = resolved.path; + } + return { ...descriptor, derivationPath }; + } + + /** + * The private key a backup may hand out. + * + * For a seed account the template is resolved against the stored address rather than assumed: + * an account derived before the TRON path correction still owns its old address, and exporting + * the current template's key would give the user an empty account — while the refusal message + * tells them to run exactly this command. This is deliberately the only place that will use a + * template the CLI no longer produces, and only so the key can leave. + */ + #exportablePrivateKey(account: string, family: ChainFamily, knownSeed?: Bytes): 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(family, index)).privateKey; + const address = walletAddress(wallet, family, index); + if (!address) throw new UsageError("family_mismatch", `account has no ${family} address`); + const seed = knownSeed ?? this.wallets.decryptSeed(source.vaultId); + const resolved = resolveDerivation(seed, family, index, address); + if (!resolved) throw derivationMismatchError(family, account); + return resolved.keyPair.privateKey; } throw notExportable(source.type); } diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index 09748b34d..994b18117 100644 --- a/ts/src/bootstrap/runner.ts +++ b/ts/src/bootstrap/runner.ts @@ -13,7 +13,7 @@ import { hasCommand, parseGlobals } from "./argv.js"; import { composeCliRuntime } from "./composition.js"; import { basename } from "node:path"; -export const VERSION = "4.13.0"; +export const VERSION = "4.13.1"; /** * Report a failure raised while the composition root was still being built — an unreadable, diff --git a/ts/src/domain/derivation/derivation.test.ts b/ts/src/domain/derivation/derivation.test.ts index fccacb4d7..1378445a4 100644 --- a/ts/src/domain/derivation/derivation.test.ts +++ b/ts/src/domain/derivation/derivation.test.ts @@ -35,16 +35,56 @@ describe("AddressCodec.validate", () => { }); }); -// 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", () => { +// The software template follows the wallets a user can actually restore into: TronLink, +// agent-wallet and the Java wallet-cli on TRON, MetaMask/Rabby/Trezor on EVM. Both increment +// address_index, so both families share one shape. +describe("Derivation.path is the software template for every family", () => { + it("increments address_index on TRON", () => { expect(Derivation.path("tron", 0)).toBe("m/44'/195'/0'/0/0"); - expect(Derivation.path("tron", 2)).toBe("m/44'/195'/2'/0/0"); + expect(Derivation.path("tron", 2)).toBe("m/44'/195'/0'/0/2"); }); - it("puts the EVM account number at the address_index level", () => { + it("increments address_index on EVM", () => { expect(Derivation.path("evm", 0)).toBe("m/44'/60'/0'/0/0"); expect(Derivation.path("evm", 2)).toBe("m/44'/60'/0'/0/2"); }); }); + +// Ledger Live hangs the account number at the account level on BOTH chains, and that is what a +// device user expects: an address this template does not produce is one Ledger Live will not +// display, however real it is on the device. +describe("Derivation.ledgerPath follows Ledger Live on every family", () => { + it("puts TRON at the account level", () => { + expect(Derivation.ledgerPath("tron", 0)).toBe("m/44'/195'/0'/0/0"); + expect(Derivation.ledgerPath("tron", 3)).toBe("m/44'/195'/3'/0/0"); + }); + + it("puts EVM at the account level", () => { + expect(Derivation.ledgerPath("evm", 0)).toBe("m/44'/60'/0'/0/0"); + expect(Derivation.ledgerPath("evm", 3)).toBe("m/44'/60'/3'/0/0"); + }); + + // The Ledger template and the software one now differ on BOTH families, so `--index 1` means a + // different path depending on where the account comes from. That is deliberate and the reason + // `import ledger --path` exists. + it("differs from the software template on both families", () => { + expect(Derivation.ledgerPath("tron", 1)).not.toBe(Derivation.path("tron", 1)); + expect(Derivation.ledgerPath("evm", 1)).not.toBe(Derivation.path("evm", 1)); + }); +}); + +// The old TRON template is the only historical one: EVM never differed, and index 0 is the same +// path under both, so neither yields a candidate. +describe("Derivation.legacyPaths lists the templates this CLI used to produce", () => { + it("offers the old account-level TRON path for index >= 1", () => { + expect(Derivation.legacyPaths("tron", 1)).toEqual(["m/44'/195'/1'/0/0"]); + }); + + it("offers nothing at index 0, where the templates agree", () => { + expect(Derivation.legacyPaths("tron", 0)).toEqual([]); + }); + + it("offers nothing on EVM, which never changed", () => { + expect(Derivation.legacyPaths("evm", 2)).toEqual([]); + }); +}); diff --git a/ts/src/domain/derivation/index.ts b/ts/src/domain/derivation/index.ts index 0ab7bb49d..687a13a4e 100644 --- a/ts/src/domain/derivation/index.ts +++ b/ts/src/domain/derivation/index.ts @@ -38,12 +38,33 @@ export class Derivation { return entropyToMnemonic(entropy, wordlist); } - /** the family's own BIP44 template with `account` slotted into the level it uses. */ - static path(family: ChainFamily, account: number): string { - const { coinType, indexAt } = FAMILIES[family]; - return indexAt === "account" - ? `m/44'/${coinType}'/${account}'/0/0` - : `m/44'/${coinType}'/0'/0/${account}`; + /** + * The software derivation template: `m/44'/'/0'/0/` for every family. + * + * Both ecosystems increment address_index — TronLink, agent-wallet and the Java wallet-cli on + * TRON, MetaMask/Rabby/Trezor on EVM — so an account derived here follows their default restore + * flow. Before this was corrected, TRON hung the number at the account level, outside those + * default scans; `legacyPaths` still names it so the key remains identifiable by its exact path. + */ + static path(family: ChainFamily, index: number): string { + return `m/44'/${FAMILIES[family].coinType}'/0'/0/${index}`; + } + + /** Ledger Live's account-level template. `--path` reaches any other device scheme explicitly. */ + static ledgerPath(family: ChainFamily, index: number): string { + return `m/44'/${FAMILIES[family].coinType}'/${index}'/0/0`; + } + + /** + * Software templates this CLI produced in the past but no longer does, newest first. + * + * Only TRON has one, and only from index 1: index 0 is the same path under both templates, and + * EVM's template never changed. An empty list means every account of this family and index can + * only have come from the current template. + */ + static legacyPaths(family: ChainFamily, index: number): string[] { + if (family !== "tron" || index === 0) return []; + return [`m/44'/195'/${index}'/0/0`]; } /** Derive a keypair from a 64-byte seed at the given BIP44 path. publicKey is uncompressed (65B). */ diff --git a/ts/src/domain/errors/codes.ts b/ts/src/domain/errors/codes.ts index 7fd474dea..d481d47bc 100644 --- a/ts/src/domain/errors/codes.ts +++ b/ts/src/domain/errors/codes.ts @@ -48,6 +48,16 @@ export const ERROR_CODES = { not_exportable: { exit: 1, retry: "never", meaning: "the account holds no exportable secret (watch-only or Ledger)" }, no_software_wallet: { exit: 1, retry: "never", meaning: "the operation needs a locally stored key and none exists" }, watch_only_no_signer: { exit: 1, retry: "never", meaning: "the selected account can be watched but cannot sign" }, + legacy_derivation: { + exit: 1, + retry: "never", + meaning: "the account was derived at a TRON path this version no longer produces", + }, + derivation_mismatch: { + exit: 1, + retry: "never", + meaning: "the account's stored address matches no derivation path of its seed", + }, // ── secrets, keystore, local files ──────────────────────────────────────── auth_required: { exit: 1, retry: "never", meaning: "the master password is needed and was not available" }, diff --git a/ts/src/domain/family/index.ts b/ts/src/domain/family/index.ts index 1aac07aa8..36895c872 100644 --- a/ts/src/domain/family/index.ts +++ b/ts/src/domain/family/index.ts @@ -23,9 +23,6 @@ export interface FamilyMeta { // 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. */ - indexAt: "account" | "addressIndex"; codec: AddressCodec; // address derive/validate ledger?: { app: string }; // present = hardware app wired; value = the Ledger app name } @@ -36,7 +33,6 @@ export const FAMILIES: { [F in ChainFamily]: FamilyMeta & { family: F } } = { nativeUnit: "sun", nativeDecimals: 6, coinType: 195, - indexAt: "account", // m/44'/195'/'/0/0 codec: new TronAddress(), ledger: { app: "tron" }, }, @@ -45,7 +41,6 @@ export const FAMILIES: { [F in ChainFamily]: FamilyMeta & { family: F } } = { 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" }, }, diff --git a/ts/src/domain/migration/wallets-v2.test.ts b/ts/src/domain/migration/wallets-v2.test.ts index 60be33ef3..12f790fc5 100644 --- a/ts/src/domain/migration/wallets-v2.test.ts +++ b/ts/src/domain/migration/wallets-v2.test.ts @@ -1,7 +1,8 @@ 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 { evmAddressFromPublicKey } from "../address/index.js"; +import { addressCodec } from "../family/index.js"; import type { ChainAddresses } from "../types/index.js"; const seedWallet = { id: "wlt_s", source: { type: "seed", vaultId: "v1", addresses: {} } }; @@ -134,7 +135,17 @@ describe("migrateWalletsToV2 — the seed path", () => { source: { type: "seed", vaultId: "v1", - addresses: Object.fromEntries(indices.map((i) => [i, { tron: `T-stale-${i}` }])), + addresses: Object.fromEntries( + indices.map((i) => { + const index = Number(i); + const path = + Derivation.legacyPaths("tron", index)[0] ?? Derivation.path("tron", index); + const tron = addressCodec("tron").fromPublicKey( + Derivation.derive(seed, path).publicKey, + ); + return [i, { tron }]; + }), + ), }, }, ], @@ -143,6 +154,12 @@ describe("migrateWalletsToV2 — the seed path", () => { const addressesOf = (out: { wallets: Array<{ source: unknown }> }) => (out.wallets[0]!.source as { addresses: Record }).addresses; + // Verified constants for the test mnemonic: the two TRON templates disagree from index 1 on. + const LEGACY_TRON_1 = "TCjow1qG4ZvDNj5ZRCF2RSuS2kMCGKK1JJ"; // m/44'/195'/1'/0/0 + const CURRENT_TRON_1 = "TPjjvMwjPoDC32V2dGDYTkLH4E5LAtBZ6C"; // m/44'/195'/0'/0/1 + const CURRENT_TRON_0 = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; // m/44'/195'/0'/0/0 + const EVM_1 = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; // m/44'/60'/0'/0/1 + it("derives each known index's EVM address at m/44'/60'/0'/0/N", () => { const addresses = addressesOf(migrateWalletsToV2(docWithIndices(["0", "2"]), secrets)); @@ -153,17 +170,71 @@ describe("migrateWalletsToV2 — the seed path", () => { } }); - // 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)); + // An unexplained cached address may be corruption, a wallet paired with the wrong vault, or a + // historical template this build does not know. Migration cannot safely choose among those + // cases, so it leaves the v1 document for recovery instead of silently changing its identity. + it("refuses a cached TRON address that no known template explains", () => { + const mismatched = { + version: 1, + wallets: [ + { + id: "wlt_s", + source: { type: "seed", vaultId: "v1", addresses: { "0": { tron: "T-stale-0" } } }, + }, + ], + }; - expect(addresses["0"]!.tron).toBe( - new TronAddress().fromPublicKey(Derivation.derive(seed, "m/44'/195'/0'/0/0").publicKey), + expect(() => migrateWalletsToV2(mismatched, secrets)).toThrowError( + expect.objectContaining({ + code: "derivation_mismatch", + message: expect.stringContaining("wlt_s.0"), + }), ); }); + // The v1→v2 migration is the first code a 4.12.0 user meets, and 4.12.0 shipped `derive` on + // the OLD TRON template. Re-deriving index >= 1 would replace the funded legacy address with + // an address of a different key — after which every protection on this branch reads the + // account as "current" and signs with the wrong key. The cached value is the only record of + // which key owns the account, so it survives. + it("keeps a cached TRON address that a legacy template explains", () => { + const doc = { + version: 1, + wallets: [ + { + id: "wlt_s", + source: { + type: "seed", + vaultId: "v1", + addresses: { "0": { tron: CURRENT_TRON_0 }, "1": { tron: LEGACY_TRON_1 } }, + }, + }, + ], + }; + + const addresses = addressesOf(migrateWalletsToV2(doc, secrets)); + + expect(addresses["1"]!.tron).toBe(LEGACY_TRON_1); + expect(addresses["1"]!.evm).toBe(EVM_1); + expect(addresses["0"]!.tron).toBe(CURRENT_TRON_0); + }); + + // A wallet derived by this build caches the current address, which the current template + // explains — nothing to preserve, and the migration behaves exactly as before. + it("re-derives a cached TRON address the current template explains", () => { + const doc = { + version: 1, + wallets: [ + { + id: "wlt_s", + source: { type: "seed", vaultId: "v1", addresses: { "1": { tron: CURRENT_TRON_1 } } }, + }, + ], + }; + + expect(addressesOf(migrateWalletsToV2(doc, secrets))["1"]!.tron).toBe(CURRENT_TRON_1); + }); + it("decrypts each vault only once, however many indices it has", () => { let calls = 0; migrateWalletsToV2(docWithIndices(["0", "1", "2", "3"]), { diff --git a/ts/src/domain/migration/wallets-v2.ts b/ts/src/domain/migration/wallets-v2.ts index e74561362..653262995 100644 --- a/ts/src/domain/migration/wallets-v2.ts +++ b/ts/src/domain/migration/wallets-v2.ts @@ -9,9 +9,13 @@ * * - 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. + * + * A cached TRON address is evidence, not staleness: a legacy match is kept, while a value no + * known template explains stops the migration instead of silently replacing the account. */ -import type { Bytes, WalletsFile } from "../types/index.js"; +import type { Bytes, ChainAddresses, WalletsFile } from "../types/index.js"; import { derivePrivAddresses, deriveSeedAddresses } from "../wallet/index.js"; +import { derivationMismatchError, resolveDerivation } from "../wallet/derivation-match.js"; import { SOURCE_KINDS } from "../sources/index.js"; import type { Source } from "../types/wallet.js"; @@ -40,9 +44,28 @@ export function migrateWalletsToV2(doc: WalletsFileV1, secrets: MigrationSecrets 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 cached = source.addresses as Record | undefined>; const addresses = Object.fromEntries( - indices.map((index) => [index, deriveSeedAddresses(seed, Number(index))]), + Object.keys(cached).map((index) => { + const derived = deriveSeedAddresses(seed, Number(index)); + // The one value re-derivation must not overwrite: a cached TRON address that a LEGACY + // template explains is not stale — it is the only record of which key owns the account. + // v1 shipped with the old TRON template, so a 4.12.0 user who ran `derive` reaches this + // migration holding exactly such a value; replacing it would hand the account to a + // different key and hide it from every legacy-derivation guard downstream. + const cachedTron = cached[index]?.tron; + const resolved = + cachedTron === undefined + ? undefined + : resolveDerivation(seed, "tron", Number(index), cachedTron); + if (cachedTron !== undefined && resolved === undefined) { + throw derivationMismatchError("tron", `${wallet.id}.${index}`); + } + return [ + index, + resolved?.scheme === "legacy" ? { ...derived, tron: cachedTron } : derived, + ]; + }), ); return { ...wallet, source: { ...source, addresses } }; } diff --git a/ts/src/domain/types/wallet.ts b/ts/src/domain/types/wallet.ts index 99103f73d..3f52ee25e 100644 --- a/ts/src/domain/types/wallet.ts +++ b/ts/src/domain/types/wallet.ts @@ -48,14 +48,15 @@ export interface AccountDescriptor { addresses: Partial; family?: ChainFamily; path?: string; - /** 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. */ + /** HD only: the seed id (wallet id, `wlt_…`) this account was derived from — accepted by + * `derive --seed-id`. Combined with `index`, it identifies the owning seed and 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, so without this a user cannot tell which. + * The verified BIP32 path behind each of this account's addresses, one entry per family. + * `null` means the command does not provide derivation information: list/current/use/rename + * deliberately return null because they do not unlock the seed; watch and private-key accounts + * also return null because they were never derived. Commands that hold a seed (derive/backup) + * resolve each cached address against its actual current or historical template before reporting. */ derivationPath?: Record | null; } diff --git a/ts/src/domain/wallet/derivation-match.test.ts b/ts/src/domain/wallet/derivation-match.test.ts new file mode 100644 index 000000000..e60a2c31c --- /dev/null +++ b/ts/src/domain/wallet/derivation-match.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from "vitest"; +import { + derivationMismatchError, + legacyAccounts, + legacyDerivationError, + resolveDerivation, +} from "./derivation-match.js"; +import { Derivation } from "../derivation/index.js"; +import { addressCodec } from "../family/index.js"; + +const MNEMONIC = "test test test test test test test test test test test junk"; +const seed = Derivation.mnemonicToSeed(MNEMONIC); + +const TRON_CURRENT_1 = "TPjjvMwjPoDC32V2dGDYTkLH4E5LAtBZ6C"; // m/44'/195'/0'/0/1 +const TRON_LEGACY_1 = "TCjow1qG4ZvDNj5ZRCF2RSuS2kMCGKK1JJ"; // m/44'/195'/1'/0/0 +const TRON_INDEX_0 = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; // identical under both +const EVM_INDEX_1 = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + +describe("resolveDerivation", () => { + it("reports the current template when the cached address came from it", () => { + const r = resolveDerivation(seed, "tron", 1, TRON_CURRENT_1)!; + expect(r.scheme).toBe("current"); + expect(r.path).toBe("m/44'/195'/0'/0/1"); + }); + + it("reports the legacy template when the cached address came from that instead", () => { + const r = resolveDerivation(seed, "tron", 1, TRON_LEGACY_1)!; + expect(r.scheme).toBe("legacy"); + expect(r.path).toBe("m/44'/195'/1'/0/0"); + }); + + // The returned key is the point of the whole function: backup exports it, the signer loads it. + // Deriving the right path but handing back the wrong key would be silent and expensive. + it("returns the key that actually produces the cached address", () => { + const r = resolveDerivation(seed, "tron", 1, TRON_LEGACY_1)!; + const derived = addressCodec("tron").fromPublicKey(r.keyPair.publicKey); + expect(derived).toBe(TRON_LEGACY_1); + }); + + // Index 0 is the same path under both templates, so it must never be reported as legacy — + // that would send an unaffected user through the rescue flow for nothing. + it("calls index 0 current, where the two templates agree", () => { + const r = resolveDerivation(seed, "tron", 0, TRON_INDEX_0)!; + expect(r.scheme).toBe("current"); + }); + + it("calls EVM current at any index, its template never having changed", () => { + const r = resolveDerivation(seed, "evm", 1, EVM_INDEX_1)!; + expect(r.scheme).toBe("current"); + expect(r.path).toBe("m/44'/60'/0'/0/1"); + }); + + // An address matching no template means the file and the vault disagree — a hand-edited + // wallets.json, or the wrong vault. Callers must be able to tell that from "it is legacy". + it("returns undefined when no template reproduces the cached address", () => { + expect(resolveDerivation(seed, "tron", 1, TRON_INDEX_0)).toBeUndefined(); + }); + + // EVM is compared case-insensitively via the codec: a wallets.json written before addresses + // were canonicalised holds an all-lowercase address, and it still names the same account. + it("matches an EVM address stored in a non-canonical spelling", () => { + const r = resolveDerivation(seed, "evm", 1, EVM_INDEX_1.toLowerCase())!; + expect(r.scheme).toBe("current"); + }); +}); + +// A normal mnemonic import follows the current template and does not recreate the old stored TRON +// address automatically. `legacyAccounts` identifies the accounts a native backup must warn about. +describe("legacyAccounts", () => { + const addressesWith = (tronAt1: string) => ({ + "0": { tron: TRON_INDEX_0, evm: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }, + "1": { tron: tronAt1, evm: EVM_INDEX_1 }, + }); + + it("names the index and the path of an account on the old template", () => { + expect(legacyAccounts(seed, addressesWith(TRON_LEGACY_1))).toEqual([ + { index: 1, path: "m/44'/195'/1'/0/0" }, + ]); + }); + + it("finds nothing when every account is on the current template", () => { + expect(legacyAccounts(seed, addressesWith(TRON_CURRENT_1))).toEqual([]); + }); + + // Index 0 is the same path under both templates, so it can never be stranded — and reporting it + // would send every untouched user through a rescue they do not need. + it("never reports index 0", () => { + expect(legacyAccounts(seed, { "0": { tron: TRON_INDEX_0 } })).toEqual([]); + }); + + // A cached address no template explains is a file/vault disagreement, not a stranded account: + // a different problem with a different fix, so it must not be reported as legacy. + it("ignores an address no template explains", () => { + expect(legacyAccounts(seed, { "1": { tron: TRON_INDEX_0 } })).toEqual([]); + }); + + it("ignores an account with no TRON address at all", () => { + expect(legacyAccounts(seed, { "1": { evm: EVM_INDEX_1 } })).toEqual([]); + }); +}); + +// Signing and `derive` refuse for different reasons but point to the same complete procedure. +describe("legacyDerivationError", () => { + const REF = "wlt_abc123.1"; + const PATH = "m/44'/195'/1'/0/0"; + + it("points to the complete procedure instead of inlining a partial deletion flow", () => { + const m = legacyDerivationError(REF, PATH, "sign").message; + expect(m).toContain("complete recovery procedure before deleting anything"); + expect(m).toContain("docs/troubleshooting/legacy-derivation-recovery.md"); + expect(m).not.toMatch(/wallet-cli (backup|import|delete)/); + }); + + it("avoids absolute recovery and wallet-compatibility claims", () => { + expect(legacyDerivationError(REF, PATH, "sign").message).not.toMatch( + /recovery phrase|unique to wallet-cli|no other wallet/i, + ); + }); + + it("names the real path and links the versioned recovery guide", () => { + const m = legacyDerivationError(REF, PATH, "derive").message; + expect(m).toContain(PATH); + expect(m).toContain("blob/wallet-cli-4.13.1/ts/docs/troubleshooting"); + }); + + it("uses the code the contract documents", () => { + expect(legacyDerivationError(REF, PATH, "sign").code).toBe("legacy_derivation"); + }); + + // The two refusals differ only in why: one key cannot be produced, one wallet must not mix + // templates. The derive lead names the wallet, since the blocked wallet is what the user asked + // about and the stranded account may not be the one they named. + it("leads with signing for a sign refusal and with the wallet for a derive refusal", () => { + expect(legacyDerivationError(REF, PATH, "sign").message).toMatch(/cannot be signed here/); + const derive = legacyDerivationError(REF, PATH, "derive").message; + expect(derive).toMatch(/^wallet wlt_abc123 holds account wlt_abc123\.1/); + expect(derive).toMatch(/no further accounts can be derived/); + }); + + it("uses labels in prose without leaking the internal account ref", () => { + const m = legacyDerivationError(REF, PATH, "derive", { + account: "main's second", + wallet: "main wallet", + }).message; + expect(m).toContain('wallet "main wallet" holds account "main\'s second"'); + expect(m).not.toContain(REF); + }); +}); + +describe("derivationMismatchError", () => { + // A different problem from a stranded account, and one with no rescue: no key of this seed owns + // that address, so there is nothing to export. It must never read as a legacy refusal. + it("says the file and the vault disagree, and offers no rescue", () => { + const m = derivationMismatchError("tron", "wlt_abc123.1"); + expect(m.code).toBe("derivation_mismatch"); + expect(m.message).toContain("wallets.json and the vault disagree"); + expect(m.message).not.toContain("--keystore"); + }); + + it("appends the caller's consequence when it has one", () => { + expect( + derivationMismatchError("tron", "wlt_abc123.1", "so no further accounts").message, + ).toMatch(/disagree, so no further accounts$/); + }); +}); diff --git a/ts/src/domain/wallet/derivation-match.ts b/ts/src/domain/wallet/derivation-match.ts new file mode 100644 index 000000000..c0dc3f4b0 --- /dev/null +++ b/ts/src/domain/wallet/derivation-match.ts @@ -0,0 +1,131 @@ +/** + * Which BIP44 template produced a stored account — the one decision that tells a legacy account + * from a current one. + * + * Nothing in wallets.json records the template: an account derived before the TRON path was + * corrected and one derived after are both `addresses["1"] = { tron, evm }`. The cached address + * is the only evidence, and reading it takes the seed. So this is the single place that answers + * the question, and both callers that hold a seed go through it — the signer, which refuses a + * legacy match, and `backup --keystore`, which exports it. + */ +import type { Bytes, ChainFamily, KeyPair } from "../types/index.js"; +import { Derivation } from "../derivation/index.js"; +import { addressCodec } from "../family/index.js"; +import { WalletError } from "../errors/index.js"; + +export interface ResolvedDerivation { + path: string; + /** `legacy` = a template this CLI no longer produces through its default derive flow. */ + scheme: "current" | "legacy"; + keyPair: KeyPair; +} + +/** + * The template whose address equals `cachedAddress`, or `undefined` when none does. + * + * `undefined` is not the same statement as `legacy`: it means the file disagrees with the vault + * (a hand-edited wallets.json, or a wallet pointing at the wrong vault), which is a different + * problem with a different fix, so callers must not collapse the two. + */ +export function resolveDerivation( + seed: Bytes, + family: ChainFamily, + index: number, + cachedAddress: string, +): ResolvedDerivation | undefined { + const codec = addressCodec(family); + // Canonical on both sides: an address cached before canonicalisation may be all-lowercase EVM, + // and it still names the same account. + const wanted = codec.canonical(cachedAddress); + + const candidates: Array<{ path: string; scheme: "current" | "legacy" }> = [ + { path: Derivation.path(family, index), scheme: "current" }, + ...Derivation.legacyPaths(family, index).map((path) => ({ path, scheme: "legacy" as const })), + ]; + + for (const { path, scheme } of candidates) { + const keyPair = Derivation.derive(seed, path); + if (codec.canonical(codec.fromPublicKey(keyPair.publicKey)) === wanted) { + return { path, scheme, keyPair }; + } + } + return undefined; +} + +/** + * Every account in an address map that a legacy template explains — accounts this version's + * default mnemonic recovery will not recreate automatically. + * + * The native `backup` writes the mnemonic, which can still derive these keys when given their old + * paths. This version's import/derive flow uses the current template by default, though, so the + * warning names every account that needs an explicit migration. + * + * Index 0 is skipped without deriving: both templates agree there, so it can never be stranded, + * and naming it would push an untouched user through a rescue they do not need. An address no + * template explains is skipped too — that is a file/vault disagreement (`resolveDerivation` + * returning `undefined`), a different problem with a different fix. + */ +export function legacyAccounts( + seed: Bytes, + addresses: Record>>, +): Array<{ index: number; path: string }> { + const out: Array<{ index: number; path: string }> = []; + for (const [key, addr] of Object.entries(addresses)) { + const index = Number(key); + if (!Number.isInteger(index) || index === 0) continue; + const cached = addr?.tron; + if (cached === undefined) continue; + const resolved = resolveDerivation(seed, "tron", index, cached); + if (resolved?.scheme === "legacy") out.push({ index, path: resolved.path }); + } + return out.sort((a, b) => a.index - b.index); +} + +/** Complete, ordered recovery procedure shipped with the affected release. */ +export const LEGACY_DERIVATION_RECOVERY_GUIDE = + "https://github.com/tronprotocol/wallet-cli/blob/wallet-cli-4.13.1/ts/docs/troubleshooting/legacy-derivation-recovery.md"; + +/** + * The refusal a stranded account raises, wherever it is raised. + * + * Signing and `derive` refuse for different reasons — one key cannot be produced, one wallet must + * not mix templates — but both direct the user to the same ordered recovery procedure. The error + * must not inline only the TRON half of that procedure: deleting the slot also removes its EVM + * address from the local account list. + */ +export function legacyDerivationError( + ref: string, + path: string, + refused: "sign" | "derive", + labels: { account?: string; wallet?: string } = {}, +): WalletError { + const walletId = ref.split(".")[0]!; + const account = labels.account ? JSON.stringify(labels.account) : ref; + const wallet = labels.wallet ? JSON.stringify(labels.wallet) : walletId; + const lead = + refused === "sign" + ? `account ${account} was derived at ${path}, a TRON path this version no longer produces, so it cannot be signed here.` + : `wallet ${wallet} holds account ${account} at ${path}, a TRON path this version no longer produces, so no further accounts can be derived from it.`; + return new WalletError( + "legacy_derivation", + `${lead} Follow the complete recovery procedure before deleting anything:\n` + + ` ${LEGACY_DERIVATION_RECOVERY_GUIDE}`, + ); +} + +/** + * `resolveDerivation` finding no match at all: the file and the vault disagree, which is a + * different problem from a stranded account and carries no rescue — there is nothing to export, + * because no key of this seed owns that address. + */ +export function derivationMismatchError( + family: ChainFamily, + ref: string, + consequence?: string, +): WalletError { + return new WalletError( + "derivation_mismatch", + `the stored ${family} address for ${ref} matches no derivation path of this seed; ` + + `wallets.json and the vault disagree${consequence ? `, ${consequence}` : ""}`, + ); +} diff --git a/ts/src/domain/wallet/wallet.test.ts b/ts/src/domain/wallet/wallet.test.ts index cf88a1fd5..4ae39e7f9 100644 --- a/ts/src/domain/wallet/wallet.test.ts +++ b/ts/src/domain/wallet/wallet.test.ts @@ -91,7 +91,7 @@ describe("address derivation covers every family", () => { const addresses = deriveSeedAddresses(seed, 2); expect(addresses.tron).toBe( - new TronAddress().fromPublicKey(Derivation.derive(seed, "m/44'/195'/2'/0/0").publicKey), + new TronAddress().fromPublicKey(Derivation.derive(seed, "m/44'/195'/0'/0/2").publicKey), ); expect(addresses.evm).toBe( evmAddressFromPublicKey(Derivation.derive(seed, "m/44'/60'/0'/0/2").publicKey), diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index e5fa066c5..e50cc12b4 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -1,15 +1,18 @@ import { describe, it, expect, beforeEach } from "vitest"; import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process"; -import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, 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"; import { TokenBook } from "../src/adapters/outbound/tokenbook/index.js"; import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js"; -import type { TokenEntry } from "../src/domain/types/index.js"; +import type { TokenEntry, WalletsFile } from "../src/domain/types/index.js"; import { DETACHED } from "./detached.js"; const ENTRY = join(process.cwd(), "src", "index.ts"); +const PACKAGE_VERSION = ( + JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")) as { version: string } +).version; const MNEMONIC = "test test test test test test test test test test test junk"; const TRON1 = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; const DEFAULT_PW = "testpw123A"; @@ -61,6 +64,23 @@ function seedWallet(label = "main") { return ks.import({ secret: MNEMONIC, type: "seed", label }).accountId; } +function seedLegacyWallet() { + const accountId = seedWallet(); + const store = new AtomicFileStore(); + const path = join(HOME, "wallets.json"); + const file = store.readJson(path)!; + const source = file.wallets[0]!.source as Extract< + WalletsFile["wallets"][0]["source"], + { type: "seed" } + >; + source.addresses["1"] = { + tron: "TCjow1qG4ZvDNj5ZRCF2RSuS2kMCGKK1JJ", + evm: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + }; + store.writeJsonAll([{ path, value: file }]); + return accountId.split(".")[0]!; +} + // Write a user-layer token directly (bypassing the live-RPC `token add` path) so list/remove // can be exercised deterministically — mirrors seedWallet()'s in-process keystore approach. function seedToken(networkId: string, ref: string, entry: TokenEntry) { @@ -68,10 +88,13 @@ function seedToken(networkId: string, ref: string, entry: TokenEntry) { } describe("golden CLI — meta & introspection", () => { - it("--version prints the version, exit 0", () => { + // Read from package.json rather than pinned: this asserts that --version reports the version + // this build IS, which is the actual contract. A literal here makes every release bump a test + // failure, and the fix for that failure is to retype the same number in a second place. + it("--version prints the package version, exit 0", () => { const r = run(["--version"]); expect(r.status).toBe(0); - expect(r.stdout.trim()).toBe("4.13.0"); + expect(r.stdout.trim()).toBe(PACKAGE_VERSION); }); it("root --help shows the TRON first-release command surface", () => { @@ -140,6 +163,23 @@ describe("golden CLI — meta & introspection", () => { expect(r.json.required).toContain("address"); }); + it("derive help and schema expose optional seed/account selection", () => { + const help = run(["derive", "--help"], { password: null }); + expect(help.status).toBe(0); + expect(help.stdout).toMatch(/^ +--seed-id .*\[optional\]$/m); + expect(help.stdout).toMatch( + /^ +--account .*defaults to the active account.*\[optional\]$/m, + ); + expect(help.stdout).toContain("takes precedence over --account"); + + const schema = run(["derive", "--json-schema"], { password: null }); + expect(schema.status).toBe(0); + expect(schema.json.properties.seedId).toBeDefined(); + expect(schema.json.properties.account).toBeDefined(); + expect(schema.json.required ?? []).not.toContain("seedId"); + expect(schema.json.required ?? []).not.toContain("account"); + }); + it("root --json-schema emits a full command catalog with global flags", () => { const r = run(["--json-schema"], { password: null }); expect(r.status).toBe(0); @@ -314,9 +354,9 @@ describe("golden CLI — wallet lifecycle (shared identity)", () => { expect(backup.json.data.out).toBe(out); }); - it("derive makes the newly derived HD account the active one", () => { + it("derive defaults to the active HD account, including an active child", () => { const seedId = seedWallet().split(".")[0]!; // "main" at index 0, active; seed id = wlt_x - const r = run(["--output", "json", "derive", "--seed-id", seedId, "--label", "child"]); + const r = run(["--output", "json", "derive", "--label", "child"]); expect(r.status).toBe(0); expect(r.json.command).toBe("derive"); expect(r.json.data.index).toBe(1); @@ -324,6 +364,40 @@ describe("golden CLI — wallet lifecycle (shared identity)", () => { // and `current` now resolves to the derived child, confirming the switch persisted const current = run(["--output", "json", "current"], { password: null }); expect(current.json.data.label).toBe("child"); + + const next = run(["--output", "json", "derive"]); + expect(next.status).toBe(0); + expect(next.json.data.accountId).toBe(`${seedId}.2`); + }); + + it("derive gives --seed-id precedence over --account", () => { + const seedId = seedWallet().split(".")[0]!; + const r = run([ + "--output", + "json", + "derive", + "--seed-id", + seedId, + "--account", + "missing-account", + ]); + + expect(r.status).toBe(0); + expect(r.json.data.accountId).toBe(`${seedId}.1`); + }); + + it("derive warns when it reselects an existing legacy slot", () => { + const seedId = seedLegacyWallet(); + const r = run(["--output", "json", "derive", "--account", "main", "--index", "1"]); + + expect(r.status).toBe(0); + expect(r.json.data).toMatchObject({ + status: "existing", + accountId: `${seedId}.1`, + derivationPath: { tron: "m/44'/195'/1'/0/0" }, + }); + expect(r.json.meta.warnings).toHaveLength(1); + expect(r.json.meta.warnings[0]).toContain("default mnemonic recovery will not recreate"); }); }); @@ -1005,6 +1079,23 @@ describe("golden CLI — startup migration", () => { expect(JSON.parse(readFileSync(path, "utf8")).version).toBe(1); }); + it("refuses an unexplained cached address without changing the v1 file", () => { + const path = windBackToV1(); + const before = JSON.parse(readFileSync(path, "utf8")); + before.wallets[0].source.addresses["0"].tron = "T-stale-address"; + writeFileSync(path, JSON.stringify(before)); + + const r = run(["--output", "json", "list"], { password: DEFAULT_PW }); + + expect(r.status).toBe(1); + expect(r.json.error).toMatchObject({ + code: "derivation_mismatch", + message: expect.stringContaining(`${before.wallets[0].id}.0`), + }); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual(before); + expect(existsSync(`${path}.v1.bak`)).toBe(false); + }); + it("checks migration before --help", () => { const path = windBackToV1(); const r = run(["--output", "json", "--help"], { password: null });