From 891efd49cdbfadb98afaf480802cb2292166e5b4 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Tue, 30 Jun 2026 11:39:07 +0800 Subject: [PATCH 1/2] feat(ts): implement TypeScript wallet CLI --- .gitignore | 9 +- ...escript-wallet-cli-architecture-plan-v2.md | 1336 +++++ ...t-wallet-cli-architecture-plan-v2.zh-TW.md | 1160 ++++ ts/.dependency-cruiser.cjs | 49 + ts/.gitignore | 6 + ts/README.md | 52 + ts/docs/architecture.md | 230 + ts/docs/evm-development-plan.zh-TW.md | 591 ++ ...-cli-architecture-source-of-truth.zh-TW.md | 721 +++ ts/package-lock.json | 5143 +++++++++++++++++ ts/package.json | 48 + ts/scripts/compare-help-parity.mjs | 63 + ts/scripts/compare-live-report.mjs | 116 + ts/scripts/import-wallet.exp | 29 + ts/scripts/nile-live-suite.mjs | 311 + .../adapters/inbound/cli/arity/arity.test.ts | 62 + ts/src/adapters/inbound/cli/arity/index.ts | 112 + ts/src/adapters/inbound/cli/command-id.ts | 12 + .../adapters/inbound/cli/commands/config.ts | 33 + .../adapters/inbound/cli/commands/network.ts | 23 + .../adapters/inbound/cli/commands/shared.ts | 60 + .../cli/commands/text-formatters.test.ts | 167 + .../inbound/cli/commands/tron/account.ts | 72 + .../inbound/cli/commands/tron/block.ts | 26 + .../inbound/cli/commands/tron/contract.ts | 134 + .../inbound/cli/commands/tron/index.ts | 48 + .../inbound/cli/commands/tron/message.ts | 11 + .../inbound/cli/commands/tron/shared.ts | 18 + .../inbound/cli/commands/tron/stake.ts | 122 + .../inbound/cli/commands/tron/token.ts | 88 + .../adapters/inbound/cli/commands/tron/tx.ts | 123 + .../cli/commands/wallet.import-ledger.test.ts | 34 + .../inbound/cli/commands/wallet.test.ts | 355 ++ .../adapters/inbound/cli/commands/wallet.ts | 239 + .../inbound/cli/context/context.test.ts | 31 + ts/src/adapters/inbound/cli/context/index.ts | 94 + .../adapters/inbound/cli/contracts/command.ts | 74 + .../inbound/cli/contracts/envelope.ts | 46 + .../cli/contracts/execution-context.ts | 16 + .../adapters/inbound/cli/contracts/index.ts | 4 + .../adapters/inbound/cli/contracts/runtime.ts | 40 + ts/src/adapters/inbound/cli/globals/index.ts | 132 + ts/src/adapters/inbound/cli/help/catalog.ts | 86 + ts/src/adapters/inbound/cli/help/index.ts | 396 ++ .../inbound/cli/input/prompt/index.ts | 249 + .../inbound/cli/input/prompt/prompter.test.ts | 90 + .../cli/input/prompt/validators.test.ts | 40 + .../inbound/cli/input/prompt/validators.ts | 24 + .../inbound/cli/input/secret/index.ts | 165 + .../inbound/cli/input/secret/secret.test.ts | 81 + .../inbound/cli/output/envelope.test.ts | 31 + .../adapters/inbound/cli/output/envelope.ts | 70 + ts/src/adapters/inbound/cli/output/index.ts | 98 + .../inbound/cli/output/output.test.ts | 138 + ts/src/adapters/inbound/cli/registry/index.ts | 76 + .../inbound/cli/registry/registry.test.ts | 45 + .../inbound/cli/render/family-render.test.ts | 19 + ts/src/adapters/inbound/cli/render/index.ts | 572 ++ ts/src/adapters/inbound/cli/render/layout.ts | 46 + ts/src/adapters/inbound/cli/render/scalars.ts | 59 + ts/src/adapters/inbound/cli/schemas/index.ts | 19 + ts/src/adapters/inbound/cli/shell/index.ts | 319 + .../adapters/inbound/cli/shell/shell.test.ts | 349 ++ ts/src/adapters/inbound/cli/stream/index.ts | 74 + .../inbound/cli/stream/stream.test.ts | 105 + .../outbound/chain/tron/history-reader.ts | 84 + ts/src/adapters/outbound/chain/tron/index.ts | 8 + .../outbound/chain/tron/provider.test.ts | 17 + .../adapters/outbound/chain/tron/provider.ts | 53 + .../chain/tron/signing-strategy.test.ts | 30 + .../outbound/chain/tron/signing-strategy.ts | 26 + .../chain/tron/tron-responses.test.ts | 72 + .../outbound/chain/tron/tron-responses.ts | 67 + ts/src/adapters/outbound/chain/tron/tron.ts | 306 + ts/src/adapters/outbound/config/builtins.ts | 65 + .../adapters/outbound/config/config.test.ts | 47 + ts/src/adapters/outbound/config/index.ts | 108 + .../outbound/config/yaml-config-document.ts | 28 + ts/src/adapters/outbound/keystore/index.ts | 481 ++ .../outbound/keystore/keystore.test.ts | 298 + ts/src/adapters/outbound/ledger/index.ts | 144 + .../outbound/persistence/backup-writer.ts | 27 + .../persistence/crypto/crypto.test.ts | 35 + .../outbound/persistence/crypto/index.ts | 55 + .../adapters/outbound/persistence/fs/index.ts | 96 + .../adapters/outbound/price/coingecko.test.ts | 60 + ts/src/adapters/outbound/price/coingecko.ts | 67 + ts/src/adapters/outbound/price/index.ts | 29 + ts/src/adapters/outbound/price/price.test.ts | 26 + .../adapters/outbound/tokenbook/builtins.ts | 15 + ts/src/adapters/outbound/tokenbook/index.ts | 98 + .../outbound/tokenbook/tokenbook.test.ts | 87 + .../application/contracts/execution-policy.ts | 18 + .../application/contracts/execution-scope.ts | 14 + ts/src/application/contracts/index.ts | 3 + ts/src/application/contracts/progress.ts | 7 + ts/src/application/ports/account-store.ts | 18 + ts/src/application/ports/backup-writer.ts | 9 + ts/src/application/ports/chain/broadcaster.ts | 10 + .../ports/chain/gateway-provider.ts | 19 + .../application/ports/chain/tron-gateway.ts | 123 + .../ports/chain/tron-history-reader.ts | 22 + .../ports/config-document-repository.ts | 10 + ts/src/application/ports/ledger-device.ts | 24 + ts/src/application/ports/network-registry.ts | 8 + ts/src/application/ports/price-provider.ts | 6 + ts/src/application/ports/prompt.ts | 22 + ts/src/application/ports/token-repository.ts | 13 + ts/src/application/ports/wallet-repository.ts | 40 + .../application/services/capability/index.ts | 36 + .../ledger-account-interactive.test.ts | 29 + .../services/ledger-account.test.ts | 44 + ts/src/application/services/ledger-account.ts | 76 + ts/src/application/services/pipeline/index.ts | 86 + ts/src/application/services/signer/index.ts | 43 + .../services/signer/ledger.test.ts | 31 + ts/src/application/services/signer/ledger.ts | 38 + .../services/signer/resolver.test.ts | 35 + .../application/services/signer/software.ts | 32 + ts/src/application/services/target/index.ts | 60 + .../services/target/target.test.ts | 67 + .../services/transaction-mode.test.ts | 30 + .../application/services/transaction-mode.ts | 28 + .../application/services/tron-confirmation.ts | 52 + .../application/use-cases/config-service.ts | 64 + .../application/use-cases/message-service.ts | 16 + .../use-cases/tron/account-service.ts | 130 + .../use-cases/tron/block-service.ts | 10 + .../use-cases/tron/contract-service.ts | 129 + .../use-cases/tron/stake-service.ts | 109 + .../use-cases/tron/token-service.ts | 132 + .../use-cases/tron/transaction-service.ts | 231 + .../application/use-cases/wallet-service.ts | 123 + ts/src/bootstrap/argv.ts | 59 + ts/src/bootstrap/composition.ts | 125 + ts/src/bootstrap/families/tron.ts | 34 + ts/src/bootstrap/families/types.ts | 30 + ts/src/bootstrap/family-registry.ts | 12 + ts/src/bootstrap/runner.test.ts | 55 + ts/src/bootstrap/runner.ts | 49 + ts/src/domain/address/index.ts | 54 + ts/src/domain/amounts/amounts.test.ts | 44 + ts/src/domain/amounts/index.ts | 41 + ts/src/domain/derivation/derivation.test.ts | 36 + ts/src/domain/derivation/index.ts | 53 + ts/src/domain/errors/errors.test.ts | 35 + ts/src/domain/errors/index.ts | 64 + ts/src/domain/family/family.test.ts | 10 + ts/src/domain/family/index.ts | 45 + ts/src/domain/resources/index.ts | 39 + ts/src/domain/resources/resources.test.ts | 24 + ts/src/domain/sources/index.ts | 50 + ts/src/domain/sources/sources.test.ts | 43 + ts/src/domain/types/index.ts | 29 + ts/src/domain/types/keystore.ts | 18 + ts/src/domain/types/network.ts | 51 + ts/src/domain/types/primitives.ts | 3 + ts/src/domain/types/token.ts | 19 + ts/src/domain/types/tx.ts | 122 + ts/src/domain/types/wallet.ts | 57 + ts/src/domain/wallet/index.ts | 132 + ts/src/domain/wallet/wallet.test.ts | 72 + ts/src/index.ts | 11 + ts/test/golden.test.ts | 391 ++ ts/tsconfig.json | 23 + ts/tsup.config.ts | 16 + ts/vitest.config.ts | 29 + 167 files changed, 21489 insertions(+), 3 deletions(-) create mode 100644 docs/archive/typescript-wallet-cli-architecture-plan-v2.md create mode 100644 docs/archive/typescript-wallet-cli-architecture-plan-v2.zh-TW.md create mode 100644 ts/.dependency-cruiser.cjs create mode 100644 ts/.gitignore create mode 100644 ts/README.md create mode 100644 ts/docs/architecture.md create mode 100644 ts/docs/evm-development-plan.zh-TW.md create mode 100644 ts/docs/typescript-wallet-cli-architecture-source-of-truth.zh-TW.md create mode 100644 ts/package-lock.json create mode 100644 ts/package.json create mode 100644 ts/scripts/compare-help-parity.mjs create mode 100644 ts/scripts/compare-live-report.mjs create mode 100644 ts/scripts/import-wallet.exp create mode 100644 ts/scripts/nile-live-suite.mjs create mode 100644 ts/src/adapters/inbound/cli/arity/arity.test.ts create mode 100644 ts/src/adapters/inbound/cli/arity/index.ts create mode 100644 ts/src/adapters/inbound/cli/command-id.ts create mode 100644 ts/src/adapters/inbound/cli/commands/config.ts create mode 100644 ts/src/adapters/inbound/cli/commands/network.ts create mode 100644 ts/src/adapters/inbound/cli/commands/shared.ts create mode 100644 ts/src/adapters/inbound/cli/commands/text-formatters.test.ts create mode 100644 ts/src/adapters/inbound/cli/commands/tron/account.ts create mode 100644 ts/src/adapters/inbound/cli/commands/tron/block.ts create mode 100644 ts/src/adapters/inbound/cli/commands/tron/contract.ts create mode 100644 ts/src/adapters/inbound/cli/commands/tron/index.ts create mode 100644 ts/src/adapters/inbound/cli/commands/tron/message.ts create mode 100644 ts/src/adapters/inbound/cli/commands/tron/shared.ts create mode 100644 ts/src/adapters/inbound/cli/commands/tron/stake.ts create mode 100644 ts/src/adapters/inbound/cli/commands/tron/token.ts create mode 100644 ts/src/adapters/inbound/cli/commands/tron/tx.ts create mode 100644 ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts create mode 100644 ts/src/adapters/inbound/cli/commands/wallet.test.ts create mode 100644 ts/src/adapters/inbound/cli/commands/wallet.ts create mode 100644 ts/src/adapters/inbound/cli/context/context.test.ts create mode 100644 ts/src/adapters/inbound/cli/context/index.ts create mode 100644 ts/src/adapters/inbound/cli/contracts/command.ts create mode 100644 ts/src/adapters/inbound/cli/contracts/envelope.ts create mode 100644 ts/src/adapters/inbound/cli/contracts/execution-context.ts create mode 100644 ts/src/adapters/inbound/cli/contracts/index.ts create mode 100644 ts/src/adapters/inbound/cli/contracts/runtime.ts create mode 100644 ts/src/adapters/inbound/cli/globals/index.ts create mode 100644 ts/src/adapters/inbound/cli/help/catalog.ts create mode 100644 ts/src/adapters/inbound/cli/help/index.ts create mode 100644 ts/src/adapters/inbound/cli/input/prompt/index.ts create mode 100644 ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts create mode 100644 ts/src/adapters/inbound/cli/input/prompt/validators.test.ts create mode 100644 ts/src/adapters/inbound/cli/input/prompt/validators.ts create mode 100644 ts/src/adapters/inbound/cli/input/secret/index.ts create mode 100644 ts/src/adapters/inbound/cli/input/secret/secret.test.ts create mode 100644 ts/src/adapters/inbound/cli/output/envelope.test.ts create mode 100644 ts/src/adapters/inbound/cli/output/envelope.ts create mode 100644 ts/src/adapters/inbound/cli/output/index.ts create mode 100644 ts/src/adapters/inbound/cli/output/output.test.ts create mode 100644 ts/src/adapters/inbound/cli/registry/index.ts create mode 100644 ts/src/adapters/inbound/cli/registry/registry.test.ts create mode 100644 ts/src/adapters/inbound/cli/render/family-render.test.ts create mode 100644 ts/src/adapters/inbound/cli/render/index.ts create mode 100644 ts/src/adapters/inbound/cli/render/layout.ts create mode 100644 ts/src/adapters/inbound/cli/render/scalars.ts create mode 100644 ts/src/adapters/inbound/cli/schemas/index.ts create mode 100644 ts/src/adapters/inbound/cli/shell/index.ts create mode 100644 ts/src/adapters/inbound/cli/shell/shell.test.ts create mode 100644 ts/src/adapters/inbound/cli/stream/index.ts create mode 100644 ts/src/adapters/inbound/cli/stream/stream.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/history-reader.ts create mode 100644 ts/src/adapters/outbound/chain/tron/index.ts create mode 100644 ts/src/adapters/outbound/chain/tron/provider.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/provider.ts create mode 100644 ts/src/adapters/outbound/chain/tron/signing-strategy.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/signing-strategy.ts create mode 100644 ts/src/adapters/outbound/chain/tron/tron-responses.test.ts create mode 100644 ts/src/adapters/outbound/chain/tron/tron-responses.ts create mode 100644 ts/src/adapters/outbound/chain/tron/tron.ts create mode 100644 ts/src/adapters/outbound/config/builtins.ts create mode 100644 ts/src/adapters/outbound/config/config.test.ts create mode 100644 ts/src/adapters/outbound/config/index.ts create mode 100644 ts/src/adapters/outbound/config/yaml-config-document.ts create mode 100644 ts/src/adapters/outbound/keystore/index.ts create mode 100644 ts/src/adapters/outbound/keystore/keystore.test.ts create mode 100644 ts/src/adapters/outbound/ledger/index.ts create mode 100644 ts/src/adapters/outbound/persistence/backup-writer.ts create mode 100644 ts/src/adapters/outbound/persistence/crypto/crypto.test.ts create mode 100644 ts/src/adapters/outbound/persistence/crypto/index.ts create mode 100644 ts/src/adapters/outbound/persistence/fs/index.ts create mode 100644 ts/src/adapters/outbound/price/coingecko.test.ts create mode 100644 ts/src/adapters/outbound/price/coingecko.ts create mode 100644 ts/src/adapters/outbound/price/index.ts create mode 100644 ts/src/adapters/outbound/price/price.test.ts create mode 100644 ts/src/adapters/outbound/tokenbook/builtins.ts create mode 100644 ts/src/adapters/outbound/tokenbook/index.ts create mode 100644 ts/src/adapters/outbound/tokenbook/tokenbook.test.ts create mode 100644 ts/src/application/contracts/execution-policy.ts create mode 100644 ts/src/application/contracts/execution-scope.ts create mode 100644 ts/src/application/contracts/index.ts create mode 100644 ts/src/application/contracts/progress.ts create mode 100644 ts/src/application/ports/account-store.ts create mode 100644 ts/src/application/ports/backup-writer.ts create mode 100644 ts/src/application/ports/chain/broadcaster.ts create mode 100644 ts/src/application/ports/chain/gateway-provider.ts create mode 100644 ts/src/application/ports/chain/tron-gateway.ts create mode 100644 ts/src/application/ports/chain/tron-history-reader.ts create mode 100644 ts/src/application/ports/config-document-repository.ts create mode 100644 ts/src/application/ports/ledger-device.ts create mode 100644 ts/src/application/ports/network-registry.ts create mode 100644 ts/src/application/ports/price-provider.ts create mode 100644 ts/src/application/ports/prompt.ts create mode 100644 ts/src/application/ports/token-repository.ts create mode 100644 ts/src/application/ports/wallet-repository.ts create mode 100644 ts/src/application/services/capability/index.ts create mode 100644 ts/src/application/services/ledger-account-interactive.test.ts create mode 100644 ts/src/application/services/ledger-account.test.ts create mode 100644 ts/src/application/services/ledger-account.ts create mode 100644 ts/src/application/services/pipeline/index.ts create mode 100644 ts/src/application/services/signer/index.ts create mode 100644 ts/src/application/services/signer/ledger.test.ts create mode 100644 ts/src/application/services/signer/ledger.ts create mode 100644 ts/src/application/services/signer/resolver.test.ts create mode 100644 ts/src/application/services/signer/software.ts create mode 100644 ts/src/application/services/target/index.ts create mode 100644 ts/src/application/services/target/target.test.ts create mode 100644 ts/src/application/services/transaction-mode.test.ts create mode 100644 ts/src/application/services/transaction-mode.ts create mode 100644 ts/src/application/services/tron-confirmation.ts create mode 100644 ts/src/application/use-cases/config-service.ts create mode 100644 ts/src/application/use-cases/message-service.ts create mode 100644 ts/src/application/use-cases/tron/account-service.ts create mode 100644 ts/src/application/use-cases/tron/block-service.ts create mode 100644 ts/src/application/use-cases/tron/contract-service.ts create mode 100644 ts/src/application/use-cases/tron/stake-service.ts create mode 100644 ts/src/application/use-cases/tron/token-service.ts create mode 100644 ts/src/application/use-cases/tron/transaction-service.ts create mode 100644 ts/src/application/use-cases/wallet-service.ts create mode 100644 ts/src/bootstrap/argv.ts create mode 100644 ts/src/bootstrap/composition.ts create mode 100644 ts/src/bootstrap/families/tron.ts create mode 100644 ts/src/bootstrap/families/types.ts create mode 100644 ts/src/bootstrap/family-registry.ts create mode 100644 ts/src/bootstrap/runner.test.ts create mode 100644 ts/src/bootstrap/runner.ts create mode 100644 ts/src/domain/address/index.ts create mode 100644 ts/src/domain/amounts/amounts.test.ts create mode 100644 ts/src/domain/amounts/index.ts create mode 100644 ts/src/domain/derivation/derivation.test.ts create mode 100644 ts/src/domain/derivation/index.ts create mode 100644 ts/src/domain/errors/errors.test.ts create mode 100644 ts/src/domain/errors/index.ts create mode 100644 ts/src/domain/family/family.test.ts create mode 100644 ts/src/domain/family/index.ts create mode 100644 ts/src/domain/resources/index.ts create mode 100644 ts/src/domain/resources/resources.test.ts create mode 100644 ts/src/domain/sources/index.ts create mode 100644 ts/src/domain/sources/sources.test.ts create mode 100644 ts/src/domain/types/index.ts create mode 100644 ts/src/domain/types/keystore.ts create mode 100644 ts/src/domain/types/network.ts create mode 100644 ts/src/domain/types/primitives.ts create mode 100644 ts/src/domain/types/token.ts create mode 100644 ts/src/domain/types/tx.ts create mode 100644 ts/src/domain/types/wallet.ts create mode 100644 ts/src/domain/wallet/index.ts create mode 100644 ts/src/domain/wallet/wallet.test.ts create mode 100644 ts/src/index.ts create mode 100644 ts/test/golden.test.ts create mode 100644 ts/tsconfig.json create mode 100644 ts/tsup.config.ts create mode 100644 ts/vitest.config.ts diff --git a/.gitignore b/.gitignore index 648c86304..83de291e0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,9 @@ FileTest bin # Wallet keystore files created at runtime -Wallet/ -Mnemonic/ -wallet_data/ +/Wallet/ +/Mnemonic/ +/wallet_data/ # QA runtime output qa/results/ @@ -38,3 +38,6 @@ qa/.verify.lock/ graphify-out/ .vscode/ docs/superpowers + + +/ts-deprecated/ \ No newline at end of file diff --git a/docs/archive/typescript-wallet-cli-architecture-plan-v2.md b/docs/archive/typescript-wallet-cli-architecture-plan-v2.md new file mode 100644 index 000000000..dd111ca9c --- /dev/null +++ b/docs/archive/typescript-wallet-cli-architecture-plan-v2.md @@ -0,0 +1,1336 @@ +# TypeScript Wallet CLI Architecture Plan — V2 + +> Restructured edition of `typescript-wallet-cli-architecture-plan.md`. Same decisions and content, +> reorganized into five sections: **(1) Goals → (2) Layered architecture diagram → (3) Per-layer +> responsibilities + pseudo code → (4) Planned commands → (5) Design decisions & detail breakdown.** +> Scope is **TRON + EVM** (Solana out of scope). Locked decisions are marked **[Decision]**. + +--- + +## 1. Goals + +What this project sets out to deliver: + +**Product** + +- A new **TypeScript** implementation of `wallet-cli`. +- A **human-friendly and AI-friendly** entry point to blockchains. +- **Standard CLI only — no REPL, no interactive stdin prompts.** Every command is complete from argv, env, stdin-flags, or config, and runs once. +- Multi-chain for **TRON and EVM chains (Base, Optimism, …)**, without pretending the two behave the same. + +**Engineering principles** + +- **AI-readable by default.** JSON mode emits a fixed envelope with predictable fields; text mode is for humans and is never the only source of meaning. +- **Strict stream discipline.** `stdout` = results, `stderr` = diagnostics, `stdin` = only flags that explicitly declare it. +- **Stable output contract.** Fields may be added, never renamed/removed without a versioned change. +- **Deterministic exit codes** (`0/1/2`) with the detailed reason in `error.code`. +- **Order-independent flags.** Users perceive one flat flag namespace — global and command options can appear before, between, or after the positional command path. The global/command split is internal only. +- **Share infrastructure, not domains.** Chains share *libraries* (keystore, derivation, output, parsing, config), not a forced domain interface. Each chain owns its full command surface. +- **Composable internals.** Parse → validate → plan → sign → broadcast → format are separate, testable stages. + +**Explicit non-goals** + +- No interactive REPL; no hidden prompts during execution. +- No universal blockchain abstraction that erases chain differences. +- No output that mixes logs/warnings/data on one stream. +- **No backward compatibility with the Java keystore layout** (`Wallet/`, `Mnemonic/`, `Ledger/`). **[Decision: clean break.]** +- Solana and other non-EVM/non-TRON chains. + +**Why this shape (lesson from the Java Standard CLI)** + +- The Java Standard CLI has **116 commands** (~70 read-only, ~44 signing). +- **~40+ are TRON-only with no EVM equivalent** (freeze/unfreeze, delegate-resource, vote-witness, proposals, TRC10, Bancor, GasFree, resource queries). +- The genuinely shared surface is only **~15–20%** (native balance, native/token transfer, block/tx queries, contract call/deploy, wallet management). +- → Forcing both chains under one provider interface would serve only that 15–20% and dump the rest into an escape hatch. Hence: per-chain namespaces, shared infrastructure only. + +**Contract ideas preserved from Java**: global vs command-local options separated; structured success/error envelope; exit codes distinguish success/execution/usage; explicit env/stdin auth (`MASTER_PASSWORD`); exactly one terminal outcome per command; `--quiet`/`--verbose` affect diagnostics only. + +**First milestone (narrow & complete)** — proves the architecture before high-risk signing: + +- TS scaffold, Standard CLI only; `--output text|json`, `--quiet`, `--verbose`, `--help`, `--version`. +- Stable JSON envelope, `0/1/2` exit-code contract. +- Seed/vault keystore with master-password unlock; `wallet create/import/list/set-active`. +- `chains list`, `capabilities --network `. +- `account balance --network nile` + `account balance --network base` **from one shared wallet identity**. +- Golden tests proving stdout/stderr behavior and keystore round-trips. + +--- + +## 2. Layered Architecture (left → right) + +Data flows left to right, one layer feeding the next. Chain modules consume the shared +infrastructure libraries rather than implementing a shared domain interface. + +```mermaid +flowchart LR + %% ========================= + %% Styles + %% ========================= + classDef input fill:#E8F0FE,stroke:#4C78A8,color:#1F2D3D,stroke-width:1.5px; + classDef cli fill:#EAF7EA,stroke:#59A14F,color:#1F2D3D,stroke-width:1.5px; + classDef contract fill:#FFF4E5,stroke:#F28E2B,color:#1F2D3D,stroke-width:1.5px; + classDef runtime fill:#F3E8FF,stroke:#9C6ADE,color:#1F2D3D,stroke-width:1.5px; + classDef chain fill:#E6F7F5,stroke:#2CB1A1,color:#1F2D3D,stroke-width:1.5px; + classDef infra fill:#FDEBEC,stroke:#E15759,color:#1F2D3D,stroke-width:1.5px; + classDef output fill:#F5F5F5,stroke:#7F7F7F,color:#1F2D3D,stroke-width:1.5px; + classDef sink fill:#FFF1F2,stroke:#D37295,color:#1F2D3D,stroke-width:1.5px; + + %% ========================= + %% Layers + %% ========================= + subgraph L1["① Input"] + direction TB + IN["argv
env
stdin"] + end + + subgraph L2["② CLI Layer"] + direction TB + GP["argv normalizer
(positionals + flat flags)"] + RT["route shape resolver"] + RG["concrete command registry"] + end + + subgraph L3["③ Contract Layer"] + direction TB + SCH["schema validation
(zod)"] + CAP["capability gate"] + end + + subgraph L4["④ Runtime Layer"] + direction TB + CTX["execution context"] + CFG["config loader"] + NREG["network registry
alias resolver"] + BCFG["built-in defaults"] + UCFG["~/.wallet-cli/config.yaml"] + OVR["env vars
CLI flags"] + end + + subgraph L5["⑤ Chain Modules"] + direction TB + TRON["TRON module
(~100 cmds)"] + EVM["EVM module
(~20 cmds)"] + end + + subgraph L6["⑥ Infra Libraries (shared)"] + direction TB + KS["keystore"] + DV["derivation"] + RPC["RPC clients"] + SGN["signer / ledger"] + end + + subgraph L7["⑦ Output Layer"] + direction TB + FMT["json / text formatter"] + end + + subgraph L8["⑧ Stream Manager"] + direction TB + SM["stream manager
(stdout/stderr/stdin discipline)"] + end + + subgraph L9["⑨ Sinks"] + direction TB + OUT["stdout
result envelope"] + ERR["stderr
diagnostics"] + end + + %% ========================= + %% Main Flow + %% ========================= + IN --> GP --> RT + RT -- route shape + global flags --> CTX + CTX --> NREG + NREG -- network.family + path --> RG + RG --> SCH --> CAP + CAP --> TRON + CAP --> EVM + + %% Module -> Shared Infra + TRON --> KS + TRON --> DV + TRON --> RPC + TRON --> SGN + + EVM --> KS + EVM --> DV + EVM --> RPC + EVM --> SGN + + %% Module -> Output + TRON --> FMT + EVM --> FMT + + %% Runtime Support + BCFG -.-> CFG + UCFG -.-> CFG + OVR -.-> CFG + CFG -. injects .-> CTX + CFG -. builds .-> NREG + CTX -. configures .-> SM + + %% Output -> Stream Manager -> Sinks + FMT --> SM + SM --> OUT + SM --> ERR + + %% ========================= + %% Class Mapping + %% ========================= + class IN input; + class GP,RT,RG cli; + class SCH,CAP contract; + class CTX,CFG,NREG,BCFG,UCFG,OVR runtime; + class TRON,EVM chain; + class KS,DV,RPC,SGN infra; + class FMT output; + class SM runtime; + class OUT,ERR sink; +``` + +Each chain module (⑤) registers whatever commands it actually supports and reaches into the shared +libraries (⑥). There is **no** `AccountProvider` / `TransactionProvider` / `TokenProvider` / +`SigningProvider` that both chains must implement — the key inversion versus the original revision. + +--- + +## 3. Layer Responsibilities + +Each layer below lists its **responsibility**, a **core pseudo code** sketch, and **design notes**. +Where a layer behaves specially for Ledger (signing), it links to [§6](#6-design-decisions--detail-breakdown). + +Summary table: + +| Layer | Module(s) | Responsibility | +| --- | --- | --- | +| ② CLI | `cli/` | Normalize argv, resolve network-bound or top-level command, run one command, return exit code. | +| ③ Contract | `contract/` | Stable schemas for inputs, outputs, errors, capabilities. | +| ④ Runtime | `runtime/` | Build execution context from config/env/flags; resolve network registry and process resources. | +| ⑤ Chain Modules | `chains//` | Implement a chain's full command set, codecs, RPC client, signer, address format. | +| ⑤ Chain core | `chains/core/` | Define the `ChainModule` interface and the capability registry. Nothing more. | +| ⑥ Keystore | `keystore/` | Chain-agnostic storage of seeds, keys, wallet identities; unlock with master password. | +| ⑥ Derivation | `derivation/` | BIP39/BIP32 derivation, per-chain coin types. | +| ⑦ Output | `output/` | Convert outcomes into JSON or text without changing behavior. | +| ⑧ Stream Manager | `runtime/stream-manager.ts` | Enforce stdout/stderr/stdin discipline and quiet/verbose behavior. | +| ⑨ Sinks | process streams | Final destinations: `stdout` for results, `stderr` for diagnostics. | +| — Errors | `errors/` | Normalize usage, execution, transport, chain-specific failures. | +| — Top-level cmds | `commands/` | Chain-neutral commands: `chains`, `capabilities`, `config`, `wallet`. | + +Full package layout: + +```text +src/ + cli/ main.ts global-options.ts command-router.ts command-registry.ts help-renderer.ts exit-codes.ts + contract/ output-envelope.ts error-codes.ts command-schema.ts capabilities.ts + runtime/ execution-context.ts stream-manager.ts config-loader.ts logger.ts + keystore/ store.ts vault.ts key.ts crypto.ts unlock.ts wallet.ts # SHARED, chain-agnostic + derivation/ bip39.ts bip32.ts coin-types.ts # tron=195, evm=60 + chains/ + core/ chain-module.ts network-descriptor.ts capability-registry.ts + tron/ tron-module.ts tron-commands/ tron-rpc-client.ts tron-signer.ts tron-address.ts # Base58Check, tronweb + evm/ evm-module.ts evm-commands/ evm-rpc-client.ts evm-signer.ts evm-address.ts # 0x/EIP-55, viem + commands/ chains.ts capabilities.ts config.ts wallet.ts # create/import/list/set-active/export-address + output/ json-formatter.ts text-formatter.ts diagnostic-writer.ts + errors/ cli-error.ts usage-error.ts execution-error.ts chain-error.ts + tests/ +``` + +### ② CLI Layer (`cli/`) + +**Responsibility:** normalize argv into a command path + a flat flag set, resolve network aliases when +needed, resolve the concrete command, validate flags against the resolved command, run exactly one +command, return an exit code. + +**[Decision: order-independent flags.]** The user perceives a single flat flag namespace — there is no +visible global-vs-command distinction. Positional tokens (non-`--`) form the command path; flags may +appear **anywhere** (before, between, or after positionals) and are collected regardless of position. +`wallet account balance --network nile --address T... --output json` and +`wallet --output json account --address T... balance --network nile` are equivalent. + +```ts +function main(argv): exitCode { + // pass 1 — custom normalization before parser subcommand semantics + const { positionals, flags } = splitArgv(argv) // positionals = command path; flags = every --x + const route = registry.resolveRouteShape(positionals) // top-level or network-bound command shape + if (!route) return EXIT.USAGE // 2 + + // pass 2 — parse only global/meta flags needed to resolve runtime and network + const globals = GLOBAL_OPTIONS.parse(flags.pickGlobalAndMeta()) + const ctx = buildExecutionContext(globals) // loads config, streams, lazy wallet + const network = route.network === "required" + ? resolveNetworkAlias(globals.network, ctx.config) // e.g. "bsc" -> "evm:56" + : undefined + + // pass 3 — resolve the concrete command and validate the merged option schema + const cmd = registry.resolveConcrete(route, network?.family) // e.g. evm + tx.send-native + if (!cmd) return EXIT.USAGE + const schema = mergeSchema(GLOBAL_OPTIONS, cmd.input) + const parsed = schema.parse(flags) // unknown/duplicate flag → usage_error + try { + checkCapability(cmd, network) // network-aware gate + const out = await cmd.run(ctx, pick(parsed, cmd.input)) // → Contract layer (zod) + formatter.success(cmd.id, chainMeta(cmd, network), out) // → Output layer + return EXIT.OK // 0 + } catch (e) { + formatter.error(normalizeError(e)) // typed → envelope + return e.isUsage ? EXIT.USAGE : EXIT.EXEC // 2 or 1 + } +} +``` + +**Notes:** +- The global/command split is an **internal** concern (which struct a flag routes to), never a + syntactic one the user must respect. A flag name must be unique across the merged namespace; a + collision between a global and a command flag is a registration-time error, not a runtime one. +- Positional order *does* matter (it is the command path); flag order never does. +- Unknown flags are rejected **after** command resolution because command-specific flags may appear + before the command path. Do not rely on a traditional subcommand parser that treats "before command" + options as global and "after command" options as local. +- Network-bound command paths are resolved through `--network`: the alias parser returns a canonical + network id and family, then the registry resolves `family + command path` to a concrete command id. +- Network-free top-level commands (`wallet`, `chains`, `config`) do not require `--network`. +- One terminal outcome per run; never throw raw to the console. + +#### Option Taxonomy + +Users see one flat option namespace, but developers must classify options by owner and sensitivity. +This classification decides which layer handles the option, whether it may be persisted, and whether +its value may appear in logs or output. + +| Category | Owner layer | Value source | Can be logged? | Can be stored in config? | Examples | Purpose | +| --- | --- | --- | --- | --- | --- | --- | +| Global runtime options | Runtime | argv / env / config | Yes, if non-secret | Yes, except network defaults | `--output`, `--network`, `--wallet`, `--timeout`, `--quiet`, `--verbose` | Shape execution context. | +| Command options | Command / Chain module | argv | Yes, if non-secret | No | `--address`, `--to`, `--amount-sun`, `--token`, `--contract`, `--method` | Business input for one command. | +| Endpoint override options | Runtime config-loader | argv / env / config | Yes, sanitized | Yes | `--grpc-endpoint`, `--rpc-url` | Override the resolved network endpoint. | +| Secret-bearing options | Runtime secret resolver | stdin / env / encrypted file | No | No | `--password-stdin`, `--private-key-stdin`, `--mnemonic-stdin`, `--tx-stdin` | Read secrets without putting values in argv/config/logs. | +| Meta options | CLI | argv | Yes | No | `--help`, `-h`, `--version` | Short-circuit normal command execution. | + +Rules: + +- Secret-bearing flags are options, but their **values are not ordinary parsed flag values**. The flag + only authorizes the runtime to read from a secret source. +- Do not support raw secret values in argv, such as `--private-key `, `--mnemonic `, or + `--password `. They leak through shell history, process lists, and logs. +- `--password-stdin` may unlock encrypted vault/key files. `--private-key-stdin` and + `--mnemonic-stdin` are import-only secret sources. `--tx-stdin` is for explicit transaction input, + not general business stdin. +- Endpoint override options are runtime options even when they are documented near chain commands; + they override `~/.wallet-cli/config.yaml` for one command run. +- Network definitions and aliases live in config, but default network selection does not. Network-bound + commands must receive `--network` explicitly. + +### ③ Contract Layer (`contract/`) + +**Responsibility:** own the stable input/output/error/capability schemas. Validation here is the +single source of truth for help text, JSON-schema export, and agent introspection. + +```ts +type CommandDefinition = { + id: string // "tron.account.balance" + path: string[] // ["account", "balance"] or ["wallet", "list"] + summary: string + family?: string // "tron" | "evm" for concrete network-bound commands + network: "none" | "required" + capability?: string // gated against the chain's capability registry + wallet: "none" | "optional" | "required" + auth: "none" | "optional" | "required" + fields: z.ZodObject // per-field schemas; for incremental/interactive validation + input: z.ZodType // = fields.superRefine(...); whole-object + cross-field, one-shot parse + examples: CommandExample[] + run(ctx: ExecutionContext, input: I): Promise +} +``` + +**Notes:** `zod` is the backbone — one schema drives validation + help + agent JSON-schema. +Required/optional/default values and cross-field validation live in the `input` schema, not in +separate `required[]`, `optional[]`, or `validateOptions()` fields. `network` describes whether the +command needs a resolved network descriptor; `wallet` describes whether it needs a wallet +identity/address; `auth` describes whether it needs secret unlocking or hardware signing. The +capability gate rejects unsupported commands after the runtime layer resolves the target network, but +before the command performs any chain operation (see capability flow in §6). + +Conditional option requirements are expressed with `zod` too: + +```ts +const sendNativeFields = z.object({ + to: evmAddressSchema, + amountWei: uintStringSchema.optional(), + amountEth: z.string().optional(), + gasPrice: uintStringSchema.optional(), + maxFee: uintStringSchema.optional(), + maxPriorityFee: uintStringSchema.optional(), + dryRun: z.boolean().default(false), +}) +const sendNativeInput = sendNativeFields.superRefine((v, ctx) => { + if (!v.amountWei && !v.amountEth) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["amountWei"], message: "Provide an amount" }) + } + if (v.amountWei && v.amountEth) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["amountWei"], message: "Use only one amount unit" }) + } + if (v.gasPrice && (v.maxFee || v.maxPriorityFee)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["gasPrice"], message: "Use legacy or EIP-1559 fees, not both" }) + } +}) +``` + +Schema validation checks shape and cross-field rules. Capability/network validation checks whether the +resolved network supports that command or fee model (for example, BSC legacy gas vs Base EIP-1559). + +**[Decision: interactive front-ends reuse the per-field schemas.]** Standard mode has no interactive +prompts (see §1 design constraints). But if an interactive front-end is later layered on top of the CLI +(prompting the user field by field, analogous to the Java REPL), it must **not** ship a second +validation path — that would drift from the schema. It reuses the contract layer's per-field +sub-schemas instead. + +`zod` rules naturally split into two kinds, each with a different earliest point at which it can run: + +| Rule kind | Example | Earliest it can be validated | +| --- | --- | --- | +| Single-field shape | `to` is a valid address, `amountWei` is a uint | the **moment** the user enters that field | +| Cross-field relation (`superRefine`) | one amount unit only, fee models mutually exclusive | only **after** the related fields are collected (impossible earlier) | + +So the command definition exposes both `fields` (per-field) and `input` (= `fields.superRefine(...)`, +whole-object + cross-field): + +- **Standard (non-interactive) path**: unchanged — `input.parse(flags)` validates in one shot. +- **Interactive path (lives in the CLI layer ②, not the contract layer ③)**: resolve network → + resolve the concrete command → take `fields.shape` → prompt field by field, validating each answer + immediately with `fields.shape[key].safeParse(answer)` and re-asking the same question on failure → + once all fields are collected, run `input.parse(collected)` to apply the cross-field rules. + +A bad address then errors immediately (single-field sub-schema), while things like amount-unit exclusivity +are checked at the end (cross-field rules cannot run earlier anyway). Interactive and standard share the +exact same field schemas, so they never drift. Interactive only changes *how the input object is +assembled*; once assembled it goes through the same `cmd.run(ctx, input)` pipeline. Interactive mode must +also resolve the network first to obtain the `fields` for the right `network.family` — consistent with +the pass2→pass3 order in §2; interactive just inserts "prompt + validate each field" between +`resolveConcrete` and the final `input.parse`. + +### ④ Runtime Layer (`runtime/`) + +**Responsibility:** assemble the `ExecutionContext` from layered config, env, and flags; build the +network registry and configure process resources such as streams and timeouts. + +```ts +function buildExecutionContext(globals): ExecutionContext { + const config = loadConfig(globals) // built-ins < ~/.wallet-cli/config.yaml < env < flags + const networkRegistry = buildNetworkRegistry(config) // canonical ids + alias index + const streams = new StreamManager(globals.output, globals.quiet) // configured I/O controller + const resolveWallet = () => resolveActiveWallet(globals) // lazy; many read/config cmds need none + return { config, networkRegistry, streams, resolveWallet, output: globals.output } +} +``` + +**Notes:** secrets are never placed in the context's serializable surface. Wallet resolution is lazy so +wallet-free commands (`chains list`, `capabilities`, `config get`) do not fail when no wallet exists. +`config-loader` owns endpoint resolution, including user overrides for built-in networks and custom +network definitions. + +#### Network Registry And Aliases + +The system's canonical network identity is `{family}:{chainId}`. User input may use aliases, but +runtime, config merge, capability checks, caches, and output contracts use the canonical id. + +```ts +type ChainFamily = "tron" | "evm" +type NetworkId = `${ChainFamily}:${string}` // e.g. "tron:nile", "evm:56" + +type NetworkDescriptor = { + id: NetworkId + family: ChainFamily + chainId: string // EVM numeric chain id as string; TRON network id/name + aliases: string[] // user-facing names accepted by --network + rpcUrl?: string // EVM JSON-RPC + grpcEndpoint?: string // TRON gRPC + solidityGrpcEndpoint?: string // TRON solidity node, optional + feeModel?: "legacy" | "eip1559" | "tron-resource" + capabilities: string[] +} +``` + +Examples: + +```text +tron -> tron:mainnet +nile -> tron:nile +shasta -> tron:shasta +eth -> evm:1 +bsc -> evm:56 +sepolia -> evm:11155111 +base -> evm:8453 +optimism -> evm:10 +``` + +Rules: + +- `--network` accepts either a canonical id (`evm:56`) or a globally unique alias (`bsc`). +- Alias parsing happens only at the CLI/runtime boundary. Chain modules receive a + `NetworkDescriptor`, not the raw user alias. +- Aliases must be globally unique. If user config creates an ambiguous alias, commands fail with + `ambiguous_network_alias`. +- Network-bound commands require `--network`; there is no default network for commands that touch a + chain. This avoids accidentally reading from or signing on the wrong chain. +- Network-free commands (`wallet list`, `wallet import`, `chains list`, `config get`) do not require + `--network`. + +### ⑤ Chain Modules (`chains/`) + +**Responsibility:** each chain implements its **entire** command surface. The only contract is: + +```ts +// chains/core/chain-module.ts — [Decision: no universal provider interfaces] +interface ChainModule { + family: string // "tron" | "evm" + networks(): NetworkDescriptor[] + capabilities(): CapabilityDescriptor[] + registerCommands(registry: CommandRegistry, ctx: RuntimeContext): void +} + +// chains/tron/tron-module.ts +const TronModule: ChainModule = { + family: "tron", + networks: () => [NILE, SHASTA, MAINNET], + capabilities: () => [...account, ...tx, ...resources, ...governance], // includes TRON-only keys + registerCommands(reg) { + reg.add(tronAccountBalance) + reg.add(tronFreeze) // TRON-only, no EVM counterpart + reg.add(tronVoteWitness) // TRON-only + // …~100 commands grouped by family + }, +} +``` + +**Notes:** +- **When to share a command:** bottom-up, *rule of three*. A shared helper (e.g. a `balance` factory) + appears only once **two** chains have the same intent *and* input shape. Even then the data stays + chain-shaped (TRON balance carries bandwidth/energy; EVM carries gas). +- Address encoding, codecs, RPC client, and signer are chain-local (`tron-*` vs `evm-*`). + +### ⑥ Infra Libraries — Keystore / Derivation / RPC / Signer (`keystore/`, `derivation/`) + +**Responsibility:** chain-agnostic storage and key handling consumed by every chain. The signer +resolves how to sign based on the active wallet's `source.type` and the command's chain family. + +```ts +// derivation/paths.ts — coin types are hardcoded; paths are computed from a template + account index +const COIN_TYPE = { tron: 195, evm: 60 } +const derivationPath = (family, account) => `m/44'/${COIN_TYPE[family]}'/${account}'/0/0` + +// keystore/store.ts — registry is plaintext; secrets are separate encrypted files. Unit = account (index) +function resolveAddress(wallet, accountIndex, chainFamily): string { + const address = wallet.addresses[accountIndex ?? ""]?.[chainFamily] + if (!address) throw new WalletError("missing_wallet_address") + return address +} + +function resolveSigner(wallet, accountIndex, chainFamily, ctx): Signer { + switch (wallet.source.type) { + case "privateKey": // non-HD: no path, no index + return softwareSigner(decryptKey(wallet.source.keyId, masterPassword())) + case "seed": { + const path = derivationPath(chainFamily, accountIndex) // template + index, not stored + return softwareSigner(deriveKey(decryptVault(wallet.source.vaultId), path)) + } + case "ledger": { + const path = derivationPath(chainFamily, accountIndex) + return ledgerSigner(wallet.source.deviceId, path) // ⚠ see §6 Ledger + } + } +} +``` + +**Notes:** +- Storage unit is the **wallet backed by a seed, raw key, or Ledger registration**; the addressing unit is an **account** under it (HD wallets hold several). Not a single-chain address — one account exposes both TRON and EVM addresses. +- Wallet metadata is plaintext (so `wallet list` needs no password); only seeds/keys are encrypted. +- **Ledger behaves differently from every software source** — it holds no secret and blocks on a hardware confirmation. Full behavior, flow, and the watch-only model are in [§6 → Ledger](#ledger-model--active-wallet-driven-signing). This layer only routes to `ledgerSigner`; it does not special-case the caller. + +### ⑦ Output Layer (`output/`) + +**Responsibility:** turn a domain outcome (success or typed error) into result and diagnostic frames, +without altering command behavior. It formats content; the stream manager writes it to process streams. + +```ts +function emit(outcome, ctx) { + if (ctx.output === "json") { + ctx.streams.result(JSON.stringify(envelope(outcome, ctx))) // exactly one stdout frame + } else { + if (outcome.ok) ctx.streams.result(renderText(outcome)) + else ctx.streams.diagnostic(concise(outcome.error)) + } + for (const w of outcome.warnings) ctx.streams.diagnostic(w) // stderr / meta.warnings +} +``` + +**Notes:** JSON mode produces exactly one result frame; stream manager sends that frame to stdout and +all diagnostics to stderr. Empty data is `{}` not `null`; large amounts are strings; binary declares +its encoding. + +### ⑧ Stream Manager (`runtime/stream-manager.ts`) + +**Responsibility:** enforce terminal I/O discipline. It is runtime-owned, but sits between the output +formatter and process streams. + +```ts +class StreamManager { + result(bytes: string): void // stdout, final command result only + diagnostic(msg: Diagnostic): void // stderr, warnings/progress/debug/human errors + readSecretOnce(kind: SecretKind): Promise +} +``` + +**Notes:** +- `stdout` is reserved for command results. In JSON mode it receives exactly one result envelope. +- `stderr` receives diagnostics, warnings, progress messages, text-mode errors, Ledger waiting + messages, and verbose debug output. +- `stdin` is closed by default for business input. Only explicit stdin flags may read from it, and each + read is memoized so multiple consumers cannot hang the process. +- Third-party library output must not pollute JSON stdout; wrappers should route or suppress noisy + dependency output through this manager. + +--- + +## 4. Flag Classification + +This section defines the CLI's flag surface. It is intentionally separate from command grouping: +developers should first know what kind of flag they are adding, which layer owns it, and whether it may +be persisted or logged. + +**Product shape** + +```text +wallet --network [options...] +wallet [options...] +``` + +The **positional path** (` `, or a top-level ``) is order-sensitive and +identifies the command shape. For network-bound commands, `--network` is required and resolves to a +canonical `NetworkDescriptor`; the descriptor's `family` then selects the concrete chain command +implementation (`tron.*` or `evm.*`). + +**Flags are position-independent** ([Decision](#-cli-layer-cli) in §3). Global options and +command-options share one flat namespace from the user's point of view — any `--flag` may appear +before, between, or after the positionals. The tables below classify flags by ownership and handling, +not by where the user must type them. + +### 4.1 Global Runtime Flags + +| Flag | 說明 | +| --- | --- | +| `--output text\|json` | 輸出格式;`json` 走固定 envelope。 | +| `--network ` | 選具體網路,可用 canonical id(`evm:56`) 或 alias(`bsc`, `nile`)。Network-bound commands 必帶。 | +| `--account ` | Primary selector for tx/sign, precise to an account (`wlt_x.0` or a unique label); defaults to `activeAccount`. | +| `--wallet ` | Selects a whole wallet → uses its active/default account (e.g. index 0). A convenience; prefer `--account` for high-risk operations. | +| `--quiet` | 抑制非必要診斷(不影響 command data)。 | +| `--verbose` | 輸出 debug 級診斷到 stderr。 | +| `--timeout ` | 操作逾時(含 Ledger 等待確認)。 | +| `--no-device-wait` | Ledger 簽名時不等待,立即失敗(給自動化用)。 | +| `--help` / `-h` | 顯示說明。 | +| `--version` | 顯示版本。 | + +### 4.2 Endpoint Override Flags + +| Flag | 說明 | +| --- | --- | +| `--grpc-endpoint ` | 覆寫本次 TRON command 的 resolved gRPC endpoint。 | +| `--rpc-url ` | 覆寫本次 EVM-compatible command 的 resolved JSON-RPC endpoint。 | + +Endpoint override flags are runtime flags owned by `config-loader`. They override built-ins and +`~/.wallet-cli/config.yaml` for one command run, but they are not command business inputs. + +### 4.3 Secret-Bearing Flags + +| Flag | 說明 | +| --- | --- | +| `--password-stdin` | 從 stdin 讀 master password 以解鎖 vault/key;可覆寫 `MASTER_PASSWORD` env。 | +| `--private-key-stdin` | `wallet import --type privateKey` 從 stdin 讀 raw private key。 | +| `--mnemonic-stdin` | `wallet import --type seed` 從 stdin 讀 BIP39 mnemonic。 | +| `--tx-stdin` | 從 stdin 讀明確指定的 transaction payload。 | + +Secret-bearing stdin flags are explicit opt-in and read stdin exactly once (see §6 Stream management). + +### 4.4 Common Command-Input Flag Families + +Command input flags are defined by each command's `zod` schema. The table below is descriptive only; +required/optional/default/conditional behavior comes from the schema for the concrete command resolved +by `network.family + path`. + +| Flag family | Examples | Owner | Notes | +| --- | --- | --- | --- | +| Target address | `--address`, `--to`, `--receiver` | Chain command | Validated against the resolved network family's address codec. | +| Amount | `--amount`, `--amount-sun`, `--amount-wei` | Chain command | Large values are strings; units are command-specific. | +| Token / contract | `--token`, `--contract`, `--method`, `--params` | Chain command | TRON and EVM share names where intent matches, but codecs differ. | +| Fee/resource | `--fee-limit`, `--gas-price`, `--max-fee`, `--max-priority-fee`, `--resource` | Chain command + capability gate | Schema handles shape; capability/network gate handles fee model support. | +| Execution mode | `--dry-run`, `--broadcast` | Chain command | Pipeline controls whether to return plan, signed tx, or broadcast result. | +| Wallet management | `--type`, `--label`, `--account`, `--chain` | Top-level `wallet` command | Local wallet/account management, not chain execution. Paths derive from account index + a hardcoded template, so there is no `--path-*`. | + +--- + +## 5. Planned Command Groups + +> Representative grouping only. The full surface will be enumerated in a follow-up against the Java +> Standard CLI command inventory plus the EVM-compatible additions. Commands are grouped by user intent, +> not by flag category. + +Network-bound commands use: + +```text +wallet --network [options...] +``` + +Top-level local commands use: + +```text +wallet [options...] +``` + +### 5.1 Local Wallet And Config + +| Group | Representative commands | Network required? | Purpose | +| --- | --- | --- | --- | +| Wallet identity | `wallet create`, `wallet import`, `wallet list`, `wallet set-active`, `wallet export-address` | No | Manage local wallet identities, encrypted secrets, and derived addresses. | +| Config | `config get`, `config set` | No | Read/write non-secret user config such as endpoints and network aliases. | +| Chains / networks | `chains list`, `chains networks` | No | List supported chain families, canonical network ids, aliases, and endpoint metadata. | +| Capabilities | `capabilities --network ` | Yes | Show machine-readable capabilities for the resolved network. | + +### 5.2 Account And Query + +| Group | Representative commands | Network required? | Notes | +| --- | --- | --- | --- | +| Account | `account balance`, `account info`, `account resources` | Yes | Shared names, chain-shaped data. TRON can include bandwidth/energy; EVM can include nonce/gas-relevant account data. | +| Blocks / transactions | `get-block`, `tx status`, `tx receipt` | Yes | Read-only chain queries. | +| Tokens | `token balance`, `token info`, `token allowance` | Yes | TRC20/ERC-20 share intent but use chain-specific address and ABI/codecs. | + +### 5.3 Transaction And Contract + +| Group | Representative commands | Network required? | Notes | +| --- | --- | --- | --- | +| Native transfer | `tx send-native` | Yes | Resolves to TRON or EVM implementation by `network.family`. | +| Token transfer | `tx send-token` | Yes | TRC20/ERC-20 transfer; schema and codecs are chain-specific. | +| Transaction pipeline | `tx build`, `tx sign`, `tx broadcast` | Yes | Planned stages for dry-run, offline signing, and agent workflows. | +| Contract | `contract call`, `contract send`, `contract deploy`, `contract trigger` | Yes | Shared command intent; TVM/EVM encoding remains chain-specific. | + +### 5.4 TRON-Specific Resource And Governance + +| Group | Representative commands | Network required? | Notes | +| --- | --- | --- | --- | +| Resources / staking | `freeze`, `unfreeze`, `delegate-resource`, `undelegate-resource` | Yes | TRON-only capabilities gated by `networkId`. | +| Governance | `vote-witness`, `witness list`, `proposal create`, `proposal approve`, `proposal delete` | Yes | TRON-only command groups from the Java CLI surface. | +| TRC10 / exchange / gasfree | `asset-issue`, `participate`, `exchange`, `market-order`, `gasfree` | Yes | TRON-only areas preserved as first-class commands, not hidden behind EVM abstractions. | + +### 5.5 EVM-Compatible Specifics + +| Group | Representative commands | Network required? | Notes | +| --- | --- | --- | --- | +| Fee controls | `tx send-native` with `--gas-price` or EIP-1559 fee flags | Yes | Network descriptor controls `legacy` vs `eip1559` capability. | +| Message signing | `message sign`, typed-data signing | Yes | EVM signing formats are separate from TRON message/TIP-712 behavior. | +| Contract deployment | `contract deploy` | Yes | EVM bytecode/ABI flow; TRON deployment remains chain-specific. | + +--- + +## 6. Design Decisions & Detail Breakdown + +Breakdown of details under the big architecture above. Anything that did not belong in §1–§5 lives here. + +### Keystore & Key Management + +The part hardest to change later, so specified concretely. **[Decisions: seed/vault-centric storage; +single master password; clean break from the Java layout.]** + +**Why wallet-centric, not address-centric.** A BIP39 seed is inherently multi-chain: the same seed +derives a TRON account via coin type 195 and an EVM account via coin type 60. A raw secp256k1 private +key can also be rendered as both an EVM address and a TRON address. The Java format stored a mnemonic +bound to a single TRON address, which makes multi-chain impossible to express. Here the **unit of +user-visible storage is the wallet identity**, backed by one seed vault, raw private key, or Ledger +registration. Addresses are derived views stored under `addresses[chain]`, not separately-stored +secrets. + +Importing a software secret does **not** ask the user whether it is "a TRON key" or "an EVM key". +The secret is chain-agnostic. The CLI derives and records the supported address views for that wallet: +TRON as Base58Check `T...`, EVM as EIP-55 `0x...`. Secrets and wallet metadata are **separated**: the +wallet list is plaintext so `wallet list` works without unlocking; only seeds and raw keys are +encrypted. + +**On-disk layout:** + +The root defaults to `~/.wallet-cli/` and can be overridden to any path via the `WALLET_CLI_HOME` +environment variable (test/CI isolation, sandboxes without `$HOME`, multiple profiles). The override +relocates the **entire tree** — `config.yaml`, `wallets.json`, `vaults/`, `keys/`, `ledger/` move +together — because `wallets.json`'s wallet entries point to `vaults/` and `keys/` via `source` under +the same tree, so all four must stay co-located. `WALLET_CLI_HOME` changes location only, never encryption: +secrets stay encrypted at the new location. + +```text +$WALLET_CLI_HOME/ or ~/.wallet-cli/ # latter is the default; former relocates the whole tree + config.yaml # plaintext user config — no secrets + wallets.json # plaintext registry — no secrets + vaults/.json # encrypted BIP39 seed/entropy + keys/.json # encrypted raw private key + ledger/.json # watch-only: device + registered paths (no secret) +``` + +**`wallets.json`:** + +```json +{ + "version": 1, + "activeAccount": "wlt_x.0", + "wallets": [ + { + "id": "wlt_x", + "source": { "type": "seed", "vaultId": "vlt_9f3a", "accounts": [0, 1] }, + "addresses": { + "0": { "tron": "T...", "evm": "0x..." }, + "1": { "tron": "T...", "evm": "0x..." } + } + }, + { + "id": "wlt_k", + "source": { "type": "privateKey", "keyId": "key_7b2c" }, + "addresses": { "": { "tron": "T...", "evm": "0x..." } } + }, + { + "id": "wlt_l", + "source": { "type": "ledger", "deviceId": "led_a1", "accounts": [0] }, + "addresses": { "0": { "tron": "T...", "evm": "0x..." } } + } + ], + "labels": { + "wlt_x": "main-seed", + "wlt_x.0": "main", + "wlt_x.1": "savings", + "wlt_k": "hot", + "wlt_l": "ledger" + } +} +``` + +Rules: + +- **The addressing unit is an account, not a wallet.** One wallet (`wlt_x`) = one secret source + (vault/key/ledger). Seed and Ledger are HD; the `accounts` array lists the known BIP44 account + indices (`[0, 1]`), each index being one account with its own TRON+EVM addresses. `wlt_k` (a raw + private key) is non-HD: no `accounts`, no index, a single account. +- **An account reference** is the addressing unit threaded through the whole structure: `wlt_x.` + (HD) or `wlt_x` (privateKey). It is simultaneously what `activeAccount` points to, the key in + `labels`, what `--account` selects, and the key in the `addresses` cache. +- **Paths are not stored as strings; they are computed from a template.** `m/44'/{coinType}'/{account}'/0/0`, + where the coin type (tron=195, evm=60) and `purpose/change/address_index` are hardcoded in the + project; only the `accounts` indices are stored. `add-account` appends the next index for seed/ledger + (privateKey has **no** such command). +- `addresses` is a cache of derived public identifiers, **keyed by account index** (privateKey uses + `""`). Stored plaintext for fast `wallet list` and read-only commands; recomputable after unlocking + the secret or querying the device. +- `activeAccount` points at an account ref (`wlt_x.0`), **not a whole wallet** — because the unit of + signing / transacting is an account. A TRON command uses that account's `addresses[index].tron`, an + EVM command uses `.evm`; a missing chain view fails with `missing_wallet_address`. +- A secret (seed or raw secp256k1 key) is chain-agnostic; **chain membership is expressed by which + address views exist under the account**, not by the secret file. + +**Identity and display name: `id`, account ref, `labels`** — identity (the machine reference) and +display name (what the user chose) are stored separately: + +| Field | Role | Properties | +| --- | --- | --- | +| `id` (`wlt_3f9k2p7q`) | wallet-level stable key | system-generated, immutable, never reused, opaque | +| account ref (`wlt_x.0` / `wlt_k`) | addressing unit | `id` + account index; privateKey has no index | +| `labels[ref]` (`"main"`) | human display name | user-chosen, **unique**, renamable; lives in the **root `labels` map** | + +- **`id` generation**: format `wlt_` + Crockford base32 of CSPRNG bytes (e.g. `randomBytes(5)`, 40 + bits). **Do not seed it with the current time** (same-millisecond collisions, predictable); + uniqueness comes from "generate, then check against the registry's existing ids, regenerate on + collision." The `id` is **never derived from the secret**, to avoid leaving a key-correlated + fingerprint in the plaintext `wallets.json`. `vaultId`/`keyId` follow the same "random, never reused" + rule (the `vlt_9f3a`/`key_7b2c` above are illustrative; real ones are random). +- **`labels` moves to the root, keyed by account ref.** The wallet record stays "purely which + keys/accounts exist"; the label is a separate presentation layer. A key may be wallet-level (`wlt_x`, + pure grouping) or account-level (`wlt_x.0`, what signing actually selects), so one map names both a + whole wallet and individual accounts. + - **Uniqueness spans the entire `labels` map** (wallet-level + account-level share one namespace) so + `--account main` reverse-resolves to exactly one target; `import`/`rename` compare trimmed + + case-insensitive and reject collisions. Reserved prefix: a label must not start with `wlt_`, or it + would be confusable with a ref. + - **Deletes must clear orphans explicitly**: removing an account/wallet does not auto-remove its + `labels[ref]` (separate structure, not embedded), so it must be cleaned up actively. +- **Why keep `id`/ref when `label` is already unique**: a unique label is unique only *at a point in + time*, **not across time** — the user can delete `main` and later create a new `main` (a different + private key). If a label were the reference, a script would **silently rebind to a different key** + after delete+recreate. An immutable, never-reused `id`/ref makes a pinned reference "either hit + exactly or error" — never a silent rebind; rename touches only the `labels` map, leaving + `activeAccount` and external references intact. +- **Selection resolution**: + - `--account ` — the **primary selector** for tx/sign, precise to an account. If the + value starts with `wlt_`, resolve as a **ref** (exact hit or not-found); otherwise as a **label** → + exactly 1 uses it, 0 is not-found, **2 or more is an ambiguity hard error** (list candidate refs + + addresses, require a ref), and **signing / transaction paths never guess for the user**. + - `--wallet ` — selects a **whole wallet** → uses its active/default account (e.g. index + 0). A convenience; high-risk operations should pin with `--account`. + - The ambiguity branch is defense in depth: write-time uniqueness normally prevents it, but it still + hard-stops a hand-edited file. +- **import responsibilities**: the user supplies the secret (`--private-key-stdin`/`--mnemonic-stdin`) + and an optional `--label`; the CLI auto-generates the `id`, creates account 0, derives `addresses`, + writes the encrypted vault/key, fills in `vaultId`/`keyId`, and writes the ref's display name into the + root `labels`. When `--label` is omitted, a default is assigned (e.g. `wallet-N`). Duplicate-import + de-duplication compares `addresses`, not the id. Renaming uses `wallet rename --account + --label ` (or `--wallet `) and touches only the `labels` map. + +**Encryption envelope** — each `vaults/*.json` and `keys/*.json` is an independent encrypted blob in +the standard Web3-style envelope (chosen for crypto quality, not compatibility): + +```json +{ + "id": "vlt_1", + "type": "bip39-seed", // or "raw-privkey" for keys/*.json + "version": 1, + "crypto": { + "cipher": "aes-128-ctr", + "ciphertext": "…", + "cipherparams": { "iv": "…" }, + "kdf": "scrypt", + "kdfparams": { "n": 262144, "r": 8, "p": 1, "dklen": 32, "salt": "…" }, + "mac": "keccak256(dk[16:32] || ciphertext)" + } +} +``` + +- Each secret file carries its own `salt`, so the **single master password** derives a distinct key per file. A leaked file remains individually encrypted. +- Master password is resolved from `MASTER_PASSWORD` env var, or `--password-stdin`. Secrets are never logged and never appear in any JSON envelope. +- Plaintext is the BIP39 entropy (vault) or the 32-byte private key (key). + +**Import methods.** `wallet import` supports: raw private key, BIP39 mnemonic (becomes a vault), and +Ledger wallet registration (becomes a watch-only wallet identity). Software imports default to all +supported chain views (`tron` and `evm`) unless a future advanced flag narrows the set. There is +intentionally **no** importer for the old Java directory format. + +### Ledger Model & Active-Wallet-Driven Signing + +**[Decision: Ledger wallets are watch-only registry entries — no secret on disk.]** A +`ledger/.json` records the device and the registered account indices; each Ledger +wallet in `wallets.json` references `{ deviceId, accounts }` (paths are computed from the index + a +hardcoded template, not stored). All signing is delegated to the device at command time. Ledger uses +the same HD/account model as a seed, differing only in that the secret lives in hardware and signing +happens on-device. + +Registering a Ledger wallet is a one-time **human** action (device connected, app open). It caches +only the public address — it does **not** cache any signing capability. After registration the device +can be unplugged; read-only and tx-build commands keep working, but every signature still requires the +device and a physical button press. + +**Active wallet drives behavior.** Signing behavior is not a per-command flag — it is decided at +runtime by the **active wallet's `source.type`** (the `resolveSigner` switch in §3 ⑥). The active +active account is whatever `wallet set-active` last selected (`activeAccount`), or an explicit +`--account`/`--wallet` override. The same command (`tx send-native --network nile …`) behaves differently depending on which +wallet is active, with no change in arguments: + +```mermaid +flowchart TD + A[Signing command] --> B[Resolve active account] + B --> C{source.type?} + C -- seed / privateKey --> D[Unlock with master password] + D --> E[Sign in-process — unattended OK] + C -- ledger --> F[Build unsigned tx, no device needed] + F --> G{Device ready?
connected, unlocked, correct app} + G -- no --> H[error: auth_required
actionable message] + G -- yes --> I[Send to device, stderr: waiting for confirmation] + I --> J{User confirms within --timeout?} + J -- yes --> K[Sign, broadcast, result envelope] + J -- no/reject/timeout --> L[error: signing_rejected, exit 1] +``` + +This mirrors normal wallet UX: **"which wallet is active" decides "is a device needed"**. A software +key signs unattended; a Ledger wallet makes the same command block on hardware confirmation. + +**This stays fully non-interactive.** The Ledger device confirmation is **not** CLI interactivity. The +CLI never shows a stdin prompt, never enters a REPL, and reads no extra input — it simply **blocks on +external hardware I/O**, the same way it would block on a slow RPC call. The command is still fully +specified by flags up front and runs once. So Ledger does not violate the non-interactive design; the +only inherent fact is that a hardware wallet's button press cannot be pre-authorized by any flag. + +**Concrete Ledger signing flow:** + +1. Active account = an account of a Ledger wallet (via prior `wallet set-active`, or `--account`/`--wallet`). +2. Run `wallet tx send-native --network nile …` (all flags up front, single shot). +3. Build the unsigned tx and estimate fees — **no device required** for these stages. +4. Check device: if not connected / locked / wrong app, return `auth_required` with an actionable message (e.g. "connect Ledger and open the TRON app"). +5. Device ready → send to device for signing; block up to `--timeout`; print `waiting for device confirmation…` to **stderr** (in JSON mode also surface `meta.warnings: [{ "code": "awaiting_device_confirmation" }]`). stdout stays silent. +6. User confirms on device → signature → broadcast → emit the result envelope to stdout. +7. Rejection or `--timeout` elapsed → `error.code: signing_rejected`, exit `1`. + +**Human vs agent emerges naturally — no session sniffing.** Human and agent run the **same command on +the same path**; the CLI does not detect or special-case who is calling (no TTY sniffing — that would +make behavior depend on hidden state instead of flags). The difference is emergent: + +- **Human:** a hand is present to press the device button → the block resolves and signing succeeds. +- **Agent:** no one presses the button → the block reaches `--timeout` and returns an error. + +If an agent should fail fast instead of waiting out the timeout, that is expressed by an **explicit +flag** (`--no-device-wait`), keeping behavior flag-driven and deterministic. Building and signing are +separate pipeline stages precisely so the no-device work completes and is validated before the device +is ever needed — a transaction that would fail never asks the user to plug in their Ledger. + +#### Ledger integration research (Node CLI) + +| Need | Package | Notes | +| --- | --- | --- | +| Transport | `@ledgerhq/hw-transport-node-hid` | Node HID via `node-hid`/`usb`. **WebHID is browser-only and requires a click-context UI event — a hard blocker for a CLI**, so it is not an option here. | +| Transport (CLI reuse) | `@ledgerhq/hw-transport-node-hid-singleton` | Manages a single reused connection; convenient for a one-device-at-a-time CLI. | +| TRON app | `@ledgerhq/hw-app-trx` | `getAddress(path)`, `signTransaction(path, rawTxHex, tokenSignatures, …)`, `signTransactionHash(path, hash)`, `signPersonalMessage`, `signTIP712HashedMessage`, `getAppConfiguration()`. | +| EVM app | `@ledgerhq/hw-app-eth` | `getAddress(path)`, `signTransaction`, `signPersonalMessage`, `signEIP712Message`. | + +Keep transport and app-module versions aligned — there is a known class of `undefined`-response bugs +(e.g. `signPersonalMessage` reading `response[0]`) caused by version drift. Ledger is also moving +toward a newer `@ledgerhq/device-management-kit`; worth evaluating, but the `hw-transport-node-hid` + +`hw-app-*` stack is the proven path today. + +**How Ledger maps onto the keystore model:** + +- **Register** (`wallet import` Ledger): open the relevant device app(s), call `app.getAddress(derivationPath(family, 0))` for each chain of account 0, store `{ deviceId, accounts: [0] }` in `ledger/.json` and a wallet entry in `wallets.json` with `source: { type: "ledger", deviceId, accounts: [0] }` plus the index-keyed derived `addresses`, then write the display name into the root `labels`. **No secret is written.** `add-account` asks the device for the new index's public key and appends it. +- **Sign** (transaction pipeline, `source.type === "ledger"`): build the unsigned tx, compute the path via `derivationPath(chainFamily, accountIndex)`, then for TRON call `signTransaction(path, rawTxHex, …)` (or `signTransactionHash` per app config); for EVM call `signTransaction`. Attach the returned signature. The private key never leaves the device. + +`deviceId`: Ledger HID does not expose a convenient stable serial. The Java CLI identifies a device by +the address at a canonical default path. That trick is chain-coupled, so for the multi-chain design the +cleaner option is a **user-supplied label** plus the address-at-reference-path as a sanity check. +(Open design point — decide at implementation time.) + +Device preconditions (unlocked, correct app open, blind-signing/contract-data enabled where the tx +requires it) should be checked via `getAppConfiguration()` and reported as actionable errors rather +than opaque transport failures. + +_Sources:_ [@ledgerhq/hw-app-trx (npm)](https://www.npmjs.com/package/@ledgerhq/hw-app-trx) · +[Node HID integration — Ledger Developer Portal](https://developers.ledger.com/docs/device-interaction/ledgerjs/integration/desktop-application/node-electron-hid) · +[@ledgerhq/hw-transport-node-hid (npm)](https://www.npmjs.com/package/@ledgerhq/hw-transport-node-hid) · +[Transports — Ledger Developer Portal](https://developers.ledger.com/docs/device-interaction/integration/how_to/transports) + +### Capability-First Design + +Every chain publishes machine-readable capabilities so humans, scripts, and agents can discover what is +supported before calling a command. + +```text +account.balance.native account.balance.token +tx.native.transfer tx.token.transfer +tx.estimate tx.sign tx.broadcast message.sign +contract.call contract.deploy +resources.energy resources.bandwidth # TRON only +staking.freeze staking.delegate # TRON only +governance.vote governance.proposal # TRON only +fee.eip1559 # EVM only +``` + +```mermaid +flowchart LR + A[Command requested] --> B[Normalize argv
positionals + flat flags] + B --> C[Resolve network alias
to canonical id] + C --> D[Resolve concrete command
network.family + path] + D --> E[Validate merged option schema] + E --> F{Capability exists
for networkId?} + F -- no --> G[Return unsupported_network_capability] + F -- yes --> H[Execute] +``` + +### Command Surface: Shared vs Chain-Specific + +| Family | TRON | EVM | Shared command name? | +| --- | --- | --- | --- | +| `account balance` (native) | ✅ | ✅ | Yes (helper, chain-shaped data) | +| `account info` | ✅ | ✅ | Yes | +| `tx send-native` | ✅ | ✅ | Yes | +| `tx send-token` (TRC20/ERC-20) | ✅ | ✅ | Yes | +| `tx build / sign / broadcast / status` | ✅ | ✅ | Yes (pipeline below) | +| `contract call / deploy` | ✅ (TVM) | ✅ (EVM) | Name shared, codecs not | +| `freeze / unfreeze / delegate-resource` | ✅ | ✗ | TRON-only namespace | +| `vote-witness / witness / brokerage` | ✅ | ✗ | TRON-only namespace | +| `proposal create/approve/delete` | ✅ | ✗ | TRON-only namespace | +| TRC10 `asset-issue / participate` | ✅ | ✗ | TRON-only namespace | +| Bancor `exchange / market-order` | ✅ | ✗ | TRON-only namespace | +| `gasfree` | ✅ | ✗ | TRON-only namespace | +| EIP-1559 fee controls | ✗ | ✅ | EVM-only namespace | + +### Output Contract + +JSON output always emits exactly one object to `stdout`. + +Success: + +```json +{ + "schema": "wallet-cli.result.v1", + "success": true, + "command": "tron.account.balance", + "chain": { "family": "tron", "networkId": "tron:nile", "network": "nile", "chainId": "nile" }, + "data": {}, + "meta": { "durationMs": 123, "warnings": [] } +} +``` + +Error: + +```json +{ + "schema": "wallet-cli.result.v1", + "success": false, + "command": "evm.tx.send-native", + "chain": { "family": "evm", "networkId": "evm:8453", "network": "base", "chainId": "8453" }, + "error": { "code": "insufficient_funds", "message": "…", "details": {} }, + "meta": { "durationMs": 98, "warnings": [] } +} +``` + +Rules: + +- JSON mode writes only the final result envelope to `stdout`; diagnostics go to `stderr` only. +- Text mode may write human-formatted output to `stdout`; text-mode errors write a concise message to `stderr`. +- One command run emits exactly one terminal result. +- Empty data is `{}`, never `null`. +- Amounts are **strings** when precision may exceed JavaScript safe-integer range (always true for wei/sun). +- Binary data declares its encoding (`hex`, `base64`, or chain-native address format). +- `chain.networkId` is the stable canonical network identity. `chain.network` is the alias used by the + request or the descriptor's primary alias and is for readability only. + +### Exit Codes + +**[Decision: keep 0/1/2 only.]** The detailed failure reason lives in `error.code`, which is the real +contract for automation. Extra numeric codes complicate shell scripting for little gain. + +| Code | Meaning | +| --- | --- | +| `0` | Success | +| `1` | Execution error (RPC failures, signing failures, insufficient funds, rejected transactions, auth/secret errors) | +| `2` | Usage error (malformed flags, missing required options, invalid command shape) | + +### Error Code Taxonomy + +```text +usage_error unknown_command invalid_option missing_option invalid_value +missing_network unsupported_chain unsupported_network ambiguous_network_alias +unsupported_capability unsupported_network_capability +auth_required auth_failed secret_source_error +rpc_error rate_limited timeout +insufficient_funds transaction_rejected signing_rejected +invalid_address missing_wallet_address invalid_amount encoding_error +execution_error internal_error +``` + +### Stream And Input Management + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Parser + participant Runtime + participant Registry + participant Contract + participant Handler + participant Formatter + User->>CLI: argv + env + optional stdin + CLI->>Parser: normalize argv and route shape + Parser-->>CLI: route shape or usage error + CLI->>Runtime: load config, build context + Runtime-->>CLI: network registry + streams + lazy wallet + CLI->>Runtime: resolve --network alias to NetworkDescriptor + CLI->>Registry: resolve concrete command using network.family + path + Registry-->>CLI: command definition or usage error + CLI->>Contract: validate merged option schema + Contract-->>CLI: validated globals + command input + CLI->>Contract: capability gate for networkId + CLI->>Handler: context + NetworkDescriptor + validated input + Handler-->>Runtime: domain result or typed error + Runtime->>Formatter: terminal outcome + Formatter-->>User: stdout result + Formatter-->>User: stderr diagnostics +``` + +Rules: + +- `stdin` is disabled by default for business input. +- `--password-stdin`, `--private-key-stdin`, `--mnemonic-stdin`, `--tx-stdin` are explicit opt-in flags. +- Any stdin-consuming flag reads stdin once and memoizes the value in the runtime secret/input resolver. +- Command handlers must not read `process.stdin` directly; they receive validated command input and + request secret material through runtime helpers. +- Secrets must not be logged or included in JSON envelopes. +- Warnings are structured under `meta.warnings` in JSON, printed to `stderr` in text mode. +- Progress output is disabled in JSON mode unless explicitly sent to `stderr`. + +### Configuration Model + +User configuration lives at `/config.yaml`, where the root defaults to `~/.wallet-cli/` and can be +overridden via `WALLET_CLI_HOME` (see on-disk layout above). It is plaintext and is for non-secret, +user-level defaults: preferred output mode, RPC endpoints, timeouts, network aliases, and custom +networks. It is the place to override built-in endpoints such as TRON Nile or BSC without passing +endpoint flags on every command. + +**Root resolution precedes config layering.** `WALLET_CLI_HOME` is a bootstrap input, not part of the +config-value layering below — you must know where the root is before you can find `config.yaml`. So it +is resolved at the very start of `buildExecutionContext`, before `loadConfig`, and does not participate +in the later value-override computation. + +Layered precedence, later overrides earlier: + +1. Built-in defaults → 2. `~/.wallet-cli/config.yaml` → 3. project config file (if explicitly enabled) → +4. environment variables → 5. global CLI options → 6. command-local options. + +Example `~/.wallet-cli/config.yaml`: + +```yaml +defaultOutput: text +timeoutMs: 30000 +networks: + "tron:mainnet": + family: tron + chainId: mainnet + aliases: [tron] + grpcEndpoint: grpc.trongrid.io:50051 + + "tron:nile": + family: tron + chainId: nile + aliases: [nile] + grpcEndpoint: grpc.xxx.example:50051 + solidityGrpcEndpoint: grpc-solidity.xxx.example:50051 + + "tron:shasta": + family: tron + chainId: shasta + aliases: [shasta] + grpcEndpoint: grpc.shasta.trongrid.io:50051 + + "evm:1": + family: evm + chainId: "1" + aliases: [eth, ethereum] + rpcUrl: https://ethereum-rpc.example + feeModel: eip1559 + + "evm:56": + family: evm + chainId: "56" + aliases: [bsc, bnb] + rpcUrl: https://bsc-dataseed.binance.org + feeModel: legacy + + "evm:11155111": + family: evm + chainId: "11155111" + aliases: [sepolia] + rpcUrl: https://sepolia-rpc.example + feeModel: eip1559 +``` + +Resolution rules: + +- `--network nile` resolves alias `nile` to canonical id `tron:nile`; if user config defines + `networks.tron:nile.grpcEndpoint`, it overrides the built-in Nile endpoint. +- `--network bsc` resolves alias `bsc` to canonical id `evm:56`; if user config defines + `networks.evm:56.rpcUrl`, it overrides the built-in BSC endpoint. +- `--network evm:56` bypasses alias lookup and resolves the canonical id directly. +- Endpoint flags (`--grpc-endpoint`, `--rpc-url`) override both built-in defaults and config for that + single command run. +- Custom networks are valid when they use a canonical id key and include the fields required by that + chain family (`grpcEndpoint` for TRON, `rpcUrl` + `chainId` for EVM). +- Aliases are user-facing only. Runtime, chain modules, capability checks, caches, and output use the + canonical network id. +- Config must not contain private keys, mnemonics, master passwords, API secrets, or bearer tokens. + Secrets stay in encrypted vault/key files, environment variables, or explicit stdin flags. + +### Transaction Pipeline + +Transaction commands move through explicit stages rather than jumping from flags to broadcast. + +```mermaid +flowchart TD + A[Validated tx command
+ NetworkDescriptor] --> B[Resolve wallet address
for network.family] + B --> C[Resolve signer
seed/key/ledger] + C --> D[Build unsigned transaction
chain-specific shape] + D --> E[Estimate fee/resource impact
gas or bandwidth/energy] + E --> F{Dry run?} + F -- yes --> G[Return unsigned transaction plan] + F -- no --> H[Sign transaction] + H --> I{Broadcast?} + I -- no --> J[Return signed transaction] + I -- yes --> K[Broadcast transaction] + K --> L[Return tx id and receipt hints] +``` + +Benefits: agents can dry-run; humans can inspect plans; offline signing becomes possible later; +chain-specific fee/resource models stay visible (TRON bandwidth/energy vs EVM gas/EIP-1559). + +### Multi-Chain Differences To Preserve + +| Concern | TRON | EVM-compatible networks | +| --- | --- | --- | +| Native unit | SUN/TRX | wei/ETH | +| Address format | Base58Check `T...` | hex `0x...` (EIP-55) | +| Fee model | bandwidth/energy/TRX | gas, EIP-1559 | +| Token model | TRC-10 / TRC-20 | ERC-20 / ERC-721 | +| Transaction shape | protobuf-derived | EIP-155 / EIP-1559 typed tx | +| Governance | SRs / proposals | n/a in CLI scope | +| Contract calls | TVM, TRON address encoding | EVM ABI | +| BIP44 coin type | 195 | 60 | + +Use a shared command name only when user intent is genuinely shared (native balance, native transfer). +Use chain-specific names when shared semantics would mislead. + +### Recommended TypeScript Libraries + +| Need | Candidate | +| --- | --- | +| CLI parsing | Custom argv normalization + `zod`; optional `commander`/`clipanion` only for help rendering or dispatch after normalization | +| Runtime schema validation | `zod` | +| EVM RPC/signing | `viem` | +| TRON support | `tronweb` plus custom codecs/signing where needed | +| Keystore crypto | `@noble/hashes` (scrypt, keccak), `@noble/ciphers` (aes-ctr) | +| BIP39/BIP32 | `@scure/bip39`, `@scure/bip32` | +| Ledger | `@ledgerhq/hw-transport-node-hid` + chain app modules (see research above) | +| Config parsing | `yaml` | +| Testing | `vitest` | +| Golden CLI tests | spawn-process tests with JSON snapshot fixtures | +| Packaging | `tsx` for dev, `tsup`/`esbuild` for build | + +`zod` is the backbone: command input schemas double as help text, JSON-schema export for agents, and +runtime validation. + +### Testing Strategy + +1. **Parser/routing tests** — flat options, help/meta options, missing `--network` for network-bound commands, alias-to-canonical-id resolution, ambiguous aliases. +2. **Schema tests** — command input validation, conditional `zod` rules, and stable output envelopes. +3. **Keystore tests** — encrypt/decrypt round-trips, multi-chain derivation from one vault, wallet registry integrity, master-password failure paths. +4. **Golden CLI tests** — spawn the compiled CLI, compare JSON envelopes for success and error. +5. **Noisy dependency tests** — ensure tronweb/viem logs never pollute `stdout` in JSON mode. +6. **Precision tests** — large integer amounts remain strings. +7. **Chain integration tests** — opt-in, network-tagged, isolated from unit tests. + +### TS-First Build Order + +This is not a Java command-by-command migration plan. The Java CLI is used as a **coverage inventory** +and behavioral reference only; the TypeScript implementation should be built around the architecture in +this document: network-first routing, `zod` contracts, wallet identity storage, strict stream +discipline, and chain-owned modules. + +1. Define the stable contracts first: output envelope, error taxonomy, `CommandDefinition`, + `NetworkDescriptor`, wallet registry schema, and option taxonomy. +2. Build the core CLI spine with no blockchain dependency: argv normalization, flat flag handling, + route-shape resolution, meta options, exit codes, and help rendering. +3. Build runtime infrastructure: `~/.wallet-cli/config.yaml`, canonical network registry, alias + resolver, stream manager, secret stdin resolver, logger, and timeout handling. +4. Implement wallet identity storage: encrypted seed/private-key vaults, `wallet create/import/list`, + `wallet set-active`, and `wallet export-address`. +5. Add introspection commands: `chains list`, `chains networks`, `capabilities --network`, JSON-schema + export, and command help generated from command metadata. +6. Prove the shared vertical slice across both families: `account balance --network nile` and + `account balance --network base` from one wallet identity, with golden stdout/stderr tests. +7. Add the transaction pipeline as a reusable pattern, then implement native transfer for both + families: `tx send-native --network nile` and `tx send-native --network base/bsc`. +8. Add token transfer and contract call/send for both families where intent matches, keeping codecs, + fee models, and transaction shapes inside each chain module. +9. Expand TRON-only surfaces by capability group: resources/staking, governance, TRC10, exchange, + GasFree. Use the Java CLI to check coverage, not to copy structure. +10. Expand EVM-compatible surfaces by capability group: fee models, message signing, typed data, + deployment, and network-specific behavior such as legacy gas on BSC. +11. Add Ledger support after the software signing pipeline is stable; Ledger remains a signer source + selected by active wallet, not a separate command mode. + +Build rule: each new command must enter through the same path — command metadata, `zod` schema, +network/capability gate, chain module implementation, output contract, and golden tests. Do not add +one-off parser branches just to match Java CLI syntax. diff --git a/docs/archive/typescript-wallet-cli-architecture-plan-v2.zh-TW.md b/docs/archive/typescript-wallet-cli-architecture-plan-v2.zh-TW.md new file mode 100644 index 000000000..afbb39dbc --- /dev/null +++ b/docs/archive/typescript-wallet-cli-architecture-plan-v2.zh-TW.md @@ -0,0 +1,1160 @@ +# TypeScript Wallet CLI 架構規劃 — V2(繁體中文版) + +> 本文件為 `typescript-wallet-cli-architecture-plan.md` 的重組版本。決策與內容相同, +> 重新整理為六個章節:**(1) 目標 → (2) 分層架構圖 → (3) 各層職責與偽程式碼 → (4) Flag Classification → (5) Planned Command Groups → (6) 設計決策與細節拆解。** +> 範圍為 **TRON + EVM**(Solana 不在範圍內)。已鎖定的決策標記為 **[決策]**。 + +--- + +## 1. 目標 + +本專案要達成的成果: + +**產品** + +- 以 **TypeScript** 重新實作 `wallet-cli`。 +- 作為區塊鏈的 **對人友善且對 AI 友善** 的入口。 +- **僅標準 CLI — 無 REPL、無互動式 stdin 提示。** 每條命令皆由 argv、環境變數、stdin 旗標或設定檔完整指定,且只執行一次。 +- 多鏈支援 **TRON 與 EVM 鏈(Base、Optimism 等)**,不假裝兩者行為相同。 + +**工程原則** + +- **預設為 AI 可讀。** JSON 模式輸出固定 envelope 與可預測欄位;文字模式供人類閱讀,且絕非唯一語意來源。 +- **嚴格的串流紀律。** `stdout` = 結果,`stderr` = 診斷資訊,`stdin` = 僅限明確宣告的旗標。 +- **穩定的輸出契約。** 可新增欄位,未經版本化變更不得重新命名或移除欄位。 +- **確定性的結束碼**(`0/1/2`),詳細原因在 `error.code`。 +- **與順序無關的旗標。** 使用者感知為單一扁平旗標命名空間 — 全域與命令選項可出現在位置參數命令路徑之前、之間或之後。全域/命令的劃分僅為內部實作。 +- **共享基礎設施,而非共享領域。** 各鏈共享*函式庫*(keystore、衍生、輸出、解析、設定),而非強制統一的領域介面。每條鏈擁有完整命令面。 +- **可組合的內部流程。** 解析 → 驗證 → 規劃 → 簽名 → 廣播 → 格式化為獨立、可測試的階段。 + +**明確非目標** + +- 無互動式 REPL;執行期間無隱藏提示。 +- 無抹平鏈差異的通用區塊鏈抽象。 +- 無將日誌/警告/資料混在同一串流的輸出。 +- **與 Java keystore 版面不相容**(`Wallet/`、`Mnemonic/`、`Ledger/`)。**[決策:乾淨切斷。]** +- Solana 及其他非 EVM/非 TRON 鏈。 + +**為何採此形狀(來自 Java Standard CLI 的教訓)** + +- Java Standard CLI 有 **116 條命令**(約 70 條唯讀、約 44 條需簽名)。 +- **約 40+ 條為 TRON 專屬且無 EVM 對應**(freeze/unfreeze、delegate-resource、vote-witness、提案、TRC10、Bancor、GasFree、資源查詢等)。 +- 真正共享的面僅約 **15–20%**(原生餘額、原生/代幣轉帳、區塊/交易查詢、合約呼叫/部署、錢包管理)。 +- → 若強制兩鏈共用同一 provider 介面,只能服務那 15–20%,其餘塞進逃生艙。因此:按鏈命名空間,僅共享基礎設施。 + +**自 Java 保留的契約概念**:全域與命令區域選項分離;結構化成功/錯誤 envelope;結束碼區分成功/執行/用法;明確的 env/stdin 驗證(`MASTER_PASSWORD`);每條命令恰好一個終端結果;`--quiet`/`--verbose` 僅影響診斷。 + +**第一個里程碑(窄而完整)** — 在高風險簽名之前驗證架構: + +- TS 腳手架,僅 Standard CLI;`--output text|json`、`--quiet`、`--verbose`、`--help`、`--version`。 +- 穩定 JSON envelope、`0/1/2` 結束碼契約。 +- 以 master password 解鎖的 seed/vault keystore;`wallet create/import/list/set-active`。 +- `chains list`、`capabilities --network `。 +- `account balance --network nile` 與 `account balance --network base` **來自同一共享錢包身分**。 +- Golden 測試驗證 stdout/stderr 行為與 keystore 往返。 + +--- + +## 2. 分層架構(由左至右) + +資料由左向右流動,一層餵給下一層。鏈模組消費共享基礎設施函式庫,而非實作共享領域介面。 + +```mermaid +flowchart LR + %% ========================= + %% Styles + %% ========================= + classDef input fill:#E8F0FE,stroke:#4C78A8,color:#1F2D3D,stroke-width:1.5px; + classDef cli fill:#EAF7EA,stroke:#59A14F,color:#1F2D3D,stroke-width:1.5px; + classDef contract fill:#FFF4E5,stroke:#F28E2B,color:#1F2D3D,stroke-width:1.5px; + classDef runtime fill:#F3E8FF,stroke:#9C6ADE,color:#1F2D3D,stroke-width:1.5px; + classDef chain fill:#E6F7F5,stroke:#2CB1A1,color:#1F2D3D,stroke-width:1.5px; + classDef infra fill:#FDEBEC,stroke:#E15759,color:#1F2D3D,stroke-width:1.5px; + classDef output fill:#F5F5F5,stroke:#7F7F7F,color:#1F2D3D,stroke-width:1.5px; + classDef sink fill:#FFF1F2,stroke:#D37295,color:#1F2D3D,stroke-width:1.5px; + + %% ========================= + %% Layers + %% ========================= + subgraph L1["① Input"] + direction TB + IN["argv
env
stdin"] + end + + subgraph L2["② CLI Layer"] + direction TB + GP["argv normalizer
(positionals + flat flags)"] + RT["route shape resolver"] + RG["concrete command registry"] + end + + subgraph L3["③ Contract Layer"] + direction TB + SCH["schema validation
(zod)"] + CAP["capability gate"] + end + + subgraph L4["④ Runtime Layer"] + direction TB + CTX["execution context"] + CFG["config loader"] + NREG["network registry
alias resolver"] + BCFG["built-in defaults"] + UCFG["~/.wallet-cli/config.yaml"] + OVR["env vars
CLI flags"] + end + + subgraph L5["⑤ Chain Modules"] + direction TB + TRON["TRON module
(~100 cmds)"] + EVM["EVM module
(~20 cmds)"] + end + + subgraph L6["⑥ Infra Libraries (shared)"] + direction TB + KS["keystore"] + DV["derivation"] + RPC["RPC clients"] + SGN["signer / ledger"] + end + + subgraph L7["⑦ Output Layer"] + direction TB + FMT["json / text formatter"] + end + + subgraph L8["⑧ Stream Manager"] + direction TB + SM["stream manager
(stdout/stderr/stdin discipline)"] + end + + subgraph L9["⑨ Sinks"] + direction TB + OUT["stdout
result envelope"] + ERR["stderr
diagnostics"] + end + + %% ========================= + %% Main Flow + %% ========================= + IN --> GP --> RT + RT -- 路由形狀 + 全域旗標 --> CTX + CTX --> NREG + NREG -- network.family + path --> RG + RG --> SCH --> CAP + CAP --> TRON + CAP --> EVM + + %% Module -> Shared Infra + TRON --> KS + TRON --> DV + TRON --> RPC + TRON --> SGN + + EVM --> KS + EVM --> DV + EVM --> RPC + EVM --> SGN + + %% Module -> Output + TRON --> FMT + EVM --> FMT + + %% Runtime Support + BCFG -.-> CFG + UCFG -.-> CFG + OVR -.-> CFG + CFG -. injects .-> CTX + CFG -. builds .-> NREG + CTX -. configures .-> SM + + %% Output -> Stream Manager -> Sinks + FMT --> SM + SM --> OUT + SM --> ERR + + %% ========================= + %% Class Mapping + %% ========================= + class IN input; + class GP,RT,RG cli; + class SCH,CAP contract; + class CTX,CFG,NREG,BCFG,UCFG,OVR runtime; + class TRON,EVM chain; + class KS,DV,RPC,SGN infra; + class FMT output; + class SM runtime; + class OUT,ERR sink; +``` + +各鏈模組(⑤)註冊其實際支援的命令,並使用共享函式庫(⑥)。**沒有** `AccountProvider` / `TransactionProvider` / `TokenProvider` / +`SigningProvider` 供兩鏈共同實作 — 這是相對初版規劃的關鍵反轉。 + +--- + +## 3. 各層職責 + +以下各層列出 **職責**、**核心偽程式碼** 草圖與 **設計備註**。 +若某層對 Ledger(簽名)有特殊行為,會連結至 [§6](#6-設計決策與細節拆解)。 + +摘要表: + +| Layer | Module(s) | Responsibility | +| --- | --- | --- | +| ② CLI | `cli/` | 正規化 argv、解析需網路或頂層命令、執行單一命令、回傳 exit code。 | +| ③ Contract | `contract/` | 輸入、輸出、錯誤、capabilities 的穩定 schema。 | +| ④ Runtime | `runtime/` | 由 config/env/flags 建立 `ExecutionContext`;解析 network registry 與程序資源。 | +| ⑤ Chain Modules | `chains//` | 實作該鏈完整 command surface、codec、RPC client、signer、address format。 | +| ⑤ Chain core | `chains/core/` | 定義 `ChainModule` 介面與 capability registry,僅此而已。 | +| ⑥ Keystore | `keystore/` | 與鏈無關的 seed、key、wallet identity 儲存;以 master password 解鎖。 | +| ⑥ Derivation | `derivation/` | BIP39/BIP32 derivation、各鏈 coin type。 | +| ⑦ Output | `output/` | 將 outcome 轉為 JSON 或 text,不改變 command 行為。 | +| ⑧ Stream Manager | `runtime/stream-manager.ts` | 強制 stdout/stderr/stdin discipline 與 quiet/verbose 行為。 | +| ⑨ Sinks | process streams | 最終目的地:`stdout` 為結果、`stderr` 為 diagnostics。 | +| — Errors | `errors/` | 正規化 usage、execution、transport、鏈專屬失敗。 | +| — Top-level cmds | `commands/` | 鏈中立命令:`chains`、`capabilities`、`config`、`wallet`。 | + +完整套件配置: + +```text +src/ + cli/ main.ts global-options.ts command-router.ts command-registry.ts help-renderer.ts exit-codes.ts + contract/ output-envelope.ts error-codes.ts command-schema.ts capabilities.ts + runtime/ execution-context.ts stream-manager.ts config-loader.ts logger.ts + keystore/ store.ts vault.ts key.ts crypto.ts unlock.ts wallet.ts # 共享、與鏈無關 + derivation/ bip39.ts bip32.ts coin-types.ts # tron=195, evm=60 + chains/ + core/ chain-module.ts network-descriptor.ts capability-registry.ts + tron/ tron-module.ts tron-commands/ tron-rpc-client.ts tron-signer.ts tron-address.ts # Base58Check, tronweb + evm/ evm-module.ts evm-commands/ evm-rpc-client.ts evm-signer.ts evm-address.ts # 0x/EIP-55, viem + commands/ chains.ts capabilities.ts config.ts wallet.ts # create/import/list/set-active/export-address + output/ json-formatter.ts text-formatter.ts diagnostic-writer.ts + errors/ cli-error.ts usage-error.ts execution-error.ts chain-error.ts + tests/ +``` + +### ② CLI Layer(`cli/`) + +**職責:** 將 argv 正規化為命令路徑 + 扁平旗標集,必要時解析網路別名,解析具體命令,依解析結果驗證旗標,恰好執行一條命令,回傳結束碼。 + +**[決策:與順序無關的旗標。]** 使用者感知為單一扁平旗標命名空間 — 沒有可見的全域 vs 命令區分。位置 token(非 `--`)構成命令路徑;旗標可出現在**任何位置**(前、中、後),與位置無關地收集。 +`wallet account balance --network nile --address T... --output json` 與 +`wallet --output json account --address T... balance --network nile` 等價。 + +```ts +function main(argv): exitCode { + // pass 1 — 在 parser 子命令語意之前自訂正規化 + const { positionals, flags } = splitArgv(argv) // positionals = 命令路徑;flags = 每個 --x + const route = registry.resolveRouteShape(positionals) // 頂層或需網路的命令形狀 + if (!route) return EXIT.USAGE // 2 + + // pass 2 — 僅解析解析執行期與網路所需的全域/元旗標 + const globals = GLOBAL_OPTIONS.parse(flags.pickGlobalAndMeta()) + const ctx = buildExecutionContext(globals) // 載入設定、串流、惰性錢包 + const network = route.network === "required" + ? resolveNetworkAlias(globals.network, ctx.config) // 例:"bsc" -> "evm:56" + : undefined + + // pass 3 — 解析具體命令並驗證合併後的選項 schema + const cmd = registry.resolveConcrete(route, network?.family) // 例:evm + tx.send-native + if (!cmd) return EXIT.USAGE + const schema = mergeSchema(GLOBAL_OPTIONS, cmd.input) + const parsed = schema.parse(flags) // 未知/重複旗標 → usage_error + try { + checkCapability(cmd, network) // 需知網路的閘門 + const out = await cmd.run(ctx, pick(parsed, cmd.input)) // → 契約層(zod) + formatter.success(cmd.id, chainMeta(cmd, network), out) // → 輸出層 + return EXIT.OK // 0 + } catch (e) { + formatter.error(normalizeError(e)) // 型別化 → envelope + return e.isUsage ? EXIT.USAGE : EXIT.EXEC // 2 或 1 + } +} +``` + +**備註:** +- 全域/命令劃分為**內部**關注(旗標路由到哪個 struct),非使用者必須遵守的語法。合併命名空間內旗標名稱必須唯一;全域與命令旗標碰撞為註冊期錯誤,非執行期。 +- 位置參數順序*確實*重要(即命令路徑);旗標順序永遠不重要。 +- 未知旗標在命令解析**之後**才拒絕,因命令專屬旗標可能出現在命令路徑之前。勿依賴傳統子命令 parser 將「命令前」當全域、「命令後」當區域。 +- 需網路的命令路徑透過 `--network` 解析:別名解析器回傳 canonical 網路 id 與 family,註冊表再解析 `family + 命令路徑` 為具體命令 id。 +- 不需網路的頂層命令(`wallet`、`chains`、`config`)不要求 `--network`。 +- 每次執行恰好一個終端結果;絕不將 raw 例外拋到主控台。 + +#### Option Taxonomy + +使用者看到扁平 option 命名空間,開發者須依 owner 與 sensitivity 分類。 +此分類決定哪一層處理 option、是否可持久化、值是否可出現在日誌或輸出。使用者面向的 flag 分表見 [§4 Flag Classification](#4-flag-classification)。 + +| 類別 | 擁有層 | 值來源 | 可記錄日誌? | 可存入設定? | 範例 | 用途 | +| --- | --- | --- | --- | --- | --- | --- | +| 全域執行期選項 | Runtime | argv / env / config | 是(若非秘密) | 是(網路預設除外) | `--output`、`--network`、`--wallet`、`--timeout`、`--quiet`、`--verbose` | 塑造執行上下文。 | +| 命令選項 | Command / 鏈模組 | argv | 是(若非秘密) | 否 | `--address`、`--to`、`--amount-sun`、`--token`、`--contract`、`--method` | 單一命令的業務輸入。 | +| 端點覆寫選項 | Runtime config-loader | argv / env / config | 是(已消毒) | 是 | `--grpc-endpoint`、`--rpc-url` | 覆寫解析後的網路端點。 | +| 含秘密選項 | Runtime secret resolver | stdin / env / 加密檔 | 否 | 否 | `--password-stdin`、`--private-key-stdin`、`--mnemonic-stdin`、`--tx-stdin` | 從秘密來源讀取,不把值放在 argv/設定/日誌。 | +| 元選項 | CLI | argv | 是 | 否 | `--help`、`-h`、`--version` | 短路正常命令執行。 | + +規則: + +- 含秘密旗標仍是選項,但其**值不是一般解析後的旗標值**。旗標僅授權執行期從秘密來源讀取。 +- 不支援在 argv 放 raw 秘密值,例如 `--private-key `、`--mnemonic `、`--password `。會洩漏到 shell 歷史、程序列表與日誌。 +- `--password-stdin` 可解鎖加密 vault/key 檔。`--private-key-stdin` 與 `--mnemonic-stdin` 僅為匯入用秘密來源。`--tx-stdin` 用於明確的交易輸入,非一般業務 stdin。 +- 端點覆寫選項即使文件寫在鏈命令附近,仍屬執行期選項;會覆寫 `~/.wallet-cli/config.yaml` 單次執行。 +- 網路定義與別名在設定中,但沒有預設網路選擇。需網路的命令必須明確帶 `--network`。 + +### ③ 契約層(`contract/`) + +**職責:** 擁有穩定的輸入/輸出/錯誤/能力 schema。此處驗證為說明文字、JSON-schema 匯出與 agent 內省的單一真相來源。 + +```ts +type CommandDefinition = { + id: string // "tron.account.balance" + path: string[] // ["account", "balance"] 或 ["wallet", "list"] + summary: string + family?: string // 需網路的具體命令:"tron" | "evm" + network: "none" | "required" + capability?: string // 對鏈能力註冊表閘門 + wallet: "none" | "optional" | "required" + auth: "none" | "optional" | "required" + fields: z.ZodObject // 逐欄 schema;供互動式前端即時驗證 + input: z.ZodType // = fields.superRefine(...);整體形狀 + 跨欄,供一次性 parse + examples: CommandExample[] + run(ctx: ExecutionContext, input: I): Promise +} +``` + +**備註:** `zod` 為骨幹 — 單一 schema 驅動驗證、說明與 agent JSON-schema。 +必填/選填/預設與跨欄位驗證在 `input` schema 內,而非獨立的 `required[]`、`optional[]` 或 `validateOptions()`。 +`network` 描述是否需要解析後的網路描述符;`wallet` 描述是否需要錢包身分/位址;`auth` 描述是否需要秘密解鎖或硬體簽名。 +能力閘門在 Runtime 解析目標 network 後、command 執行任何鏈操作前拒絕不支援的命令(見 §6 capability flow)。 + +條件式選項需求也用 `zod` 表達: + +```ts +const sendNativeFields = z.object({ + to: evmAddressSchema, + amountWei: uintStringSchema.optional(), + amountEth: z.string().optional(), + gasPrice: uintStringSchema.optional(), + maxFee: uintStringSchema.optional(), + maxPriorityFee: uintStringSchema.optional(), + dryRun: z.boolean().default(false), +}) +const sendNativeInput = sendNativeFields.superRefine((v, ctx) => { + if (!v.amountWei && !v.amountEth) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["amountWei"], message: "Provide an amount" }) + } + if (v.amountWei && v.amountEth) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["amountWei"], message: "Use only one amount unit" }) + } + if (v.gasPrice && (v.maxFee || v.maxPriorityFee)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["gasPrice"], message: "Use legacy or EIP-1559 fees, not both" }) + } +}) +``` + +Schema 驗證檢查形狀與跨欄位規則。能力/網路驗證檢查解析後的網路是否支援該命令或費用模型(例如 BSC legacy gas vs Base EIP-1559)。 + +**[決策:互動式前端復用逐欄 schema。]** standard 模式無互動 prompt(見 §1 設計約束)。但若日後要在 CLI 層之上另外提供互動式前端(逐欄詢問使用者,類比 Java 版 REPL),不可另寫一套驗證——否則規則會與 schema 漂移。改為復用契約層的逐欄子 schema。 + +zod 規則天生分兩類,最早可驗時機不同: + +| 規則種類 | 例子 | 最早能驗的時間 | +| --- | --- | --- | +| 單欄形狀 | `to` 是否合法位址、`amountWei` 是否 uint | 使用者輸入該欄**當下** | +| 跨欄關係(`superRefine`) | 金額單位擇一、fee 模型互斥 | 收齊相關欄位**之後**(邏輯上不可能更早) | + +因此命令定義同時暴露 `fields`(逐欄)與 `input`(= `fields.superRefine(...)`,整體 + 跨欄): + +- **Standard(非互動)路徑**:照舊 `input.parse(flags)` 一次驗完。 +- **互動路徑(住在 CLI 層 ②,非契約層 ③)**:先解析 network → 解析 concrete command → 取 `fields.shape` → 逐欄 prompt,每個答案立即以 `fields.shape[key].safeParse(answer)` 當場驗、錯則重問同一題 → 全部收齊後再 `input.parse(collected)` 補上跨欄規則。 + +如此地址打錯立即回錯(單欄子 schema),金額單位互斥之類於最後統一檢查(跨欄本就無法更早);互動與 standard 共用同一批欄位 schema,不會漂移。互動只改變「input 物件如何湊出」,湊出後走同一條 `cmd.run(ctx, input)` 管線。互動模式同樣須先確定網路,才能取得對應 `network.family` 的 `fields`——這與 §2 的 pass2→pass3 順序一致,互動只是把「逐欄 prompt + 即時驗」插在 `resolveConcrete` 之後、最終 `input.parse` 之前。 + +### ④ 執行期層(`runtime/`) + +**職責:** 由分層 config、env 與 flags 組裝 `ExecutionContext`;建立 network registry 並設定 streams、timeout 等程序資源。 + +```ts +function buildExecutionContext(globals): ExecutionContext { + const config = loadConfig(globals) // built-ins < ~/.wallet-cli/config.yaml < env < flags + const networkRegistry = buildNetworkRegistry(config) // canonical ids + alias index + const streams = new StreamManager(globals.output, globals.quiet) // configured I/O controller + const resolveWallet = () => resolveActiveWallet(globals) // lazy; many read/config cmds need none + return { config, networkRegistry, streams, resolveWallet, output: globals.output } +} +``` + +**備註:** secrets 永不放在 context 可序列化表面。wallet resolution 為 lazy,使無 wallet 命令(`chains list`、`capabilities`、`config get`)在無 wallet 時不會失敗。 +`config-loader` 擁有 endpoint resolution,含使用者對 built-in networks 的覆寫與 custom network 定義。 + +#### 網路註冊表與別名 + +系統的 canonical 網路身分為 `{family}:{chainId}`。使用者輸入可用別名, +但執行期、設定合併、能力檢查、快取與輸出契約使用 canonical id。 + +```ts +type ChainFamily = "tron" | "evm" +type NetworkId = `${ChainFamily}:${string}` // 例:"tron:nile", "evm:56" + +type NetworkDescriptor = { + id: NetworkId + family: ChainFamily + chainId: string // EVM 數字 chain id 字串;TRON 網路 id/名稱 + aliases: string[] // --network 接受的使用者名稱 + rpcUrl?: string // EVM JSON-RPC + grpcEndpoint?: string // TRON gRPC + solidityGrpcEndpoint?: string // TRON solidity 節點,選用 + feeModel?: "legacy" | "eip1559" | "tron-resource" + capabilities: string[] +} +``` + +範例: + +```text +tron -> tron:mainnet +nile -> tron:nile +shasta -> tron:shasta +eth -> evm:1 +bsc -> evm:56 +sepolia -> evm:11155111 +base -> evm:8453 +optimism -> evm:10 +``` + +規則: + +- `--network` 接受 canonical id(`evm:56`)或全域唯一別名(`bsc`)。 +- 別名解析僅在 CLI/執行期邊界。鏈模組收到 `NetworkDescriptor`,非原始使用者別名。 +- 別名必須全域唯一。若使用者設定造成歧義別名,命令以 `ambiguous_network_alias` 失敗。 +- 需網路的命令必須帶 `--network`;觸及鏈的命令沒有預設網路,避免誤讀或誤簽錯鏈。 +- 不需網路的命令(`wallet list`、`wallet import`、`chains list`、`config get`)不要求 `--network`。 + +### ⑤ 鏈模組(`chains/`) + +**職責:** 每條鏈實作其**完整**命令面。唯一契約為: + +```ts +// chains/core/chain-module.ts — [決策:無通用 provider 介面] +interface ChainModule { + family: string // "tron" | "evm" + networks(): NetworkDescriptor[] + capabilities(): CapabilityDescriptor[] + registerCommands(registry: CommandRegistry, ctx: RuntimeContext): void +} + +// chains/tron/tron-module.ts +const TronModule: ChainModule = { + family: "tron", + networks: () => [NILE, SHASTA, MAINNET], + capabilities: () => [...account, ...tx, ...resources, ...governance], // 含 TRON 專屬鍵 + registerCommands(reg) { + reg.add(tronAccountBalance) + reg.add(tronFreeze) // TRON 專屬,無 EVM 對應 + reg.add(tronVoteWitness) // TRON 專屬 + // …按 family 分組約 ~100 條命令 + }, +} +``` + +**備註:** +- **何時共享命令:** 自下而上,*三次法則*。共享 helper(例如 `balance` 工廠)僅在**兩**鏈有相同意圖*且*輸入形狀時出現。即使如此資料仍保持鏈形狀(TRON 餘額含 bandwidth/energy;EVM 含 gas)。 +- 位址編碼、codec、RPC 客戶端、簽名器為鏈本地(`tron-*` vs `evm-*`)。 + +### ⑥ 基礎設施函式庫 — Keystore / 衍生 / RPC / 簽名器(`keystore/`、`derivation/`) + +**職責:** 各鏈消費的與鏈無關儲存與金鑰處理。簽名器依使用中錢包的 `source.type` 與命令的鏈 family 決定如何簽名。 + +```ts +// derivation/paths.ts — coin type 寫死在專案,路徑由模板 + account index 算出 +const COIN_TYPE = { tron: 195, evm: 60 } +const derivationPath = (family, account) => `m/44'/${COIN_TYPE[family]}'/${account}'/0/0` + +// keystore/store.ts — 註冊表為明文;秘密為獨立加密檔。定址單位為 account(index) +function resolveAddress(wallet, accountIndex, chainFamily): string { + const address = wallet.addresses[accountIndex ?? ""]?.[chainFamily] + if (!address) throw new WalletError("missing_wallet_address") + return address +} + +function resolveSigner(wallet, accountIndex, chainFamily, ctx): Signer { + switch (wallet.source.type) { + case "privateKey": // 非 HD:無路徑、無 index + return softwareSigner(decryptKey(wallet.source.keyId, masterPassword())) + case "seed": { + const path = derivationPath(chainFamily, accountIndex) // 模板 + index,不存字串 + return softwareSigner(deriveKey(decryptVault(wallet.source.vaultId), path)) + } + case "ledger": { + const path = derivationPath(chainFamily, accountIndex) + return ledgerSigner(wallet.source.deviceId, path) // ⚠ 見 §6 Ledger + } + } +} +``` + +**備註:** +- 儲存單位為**由 seed、raw key 或 Ledger 註冊支撐的錢包**;其下的定址單位是 **account**(HD 錢包可有多個)。非單鏈位址 — 一個 account 同時暴露 TRON 與 EVM 位址。 +- 錢包 metadata 為明文(`wallet list` 不需密碼);僅 seed/key 加密。 +- **Ledger 與所有 software source 行為不同** — 不持有 secret 且會阻塞等待硬體確認。完整行為、流程與 watch-only 模型見 [§6 → Ledger](#ledger-model--active-wallet-driven-signing)。本層僅路由到 `ledgerSigner`,不對 caller 特殊處理。 + +### ⑦ Output Layer(`output/`) + +**職責:** 將 domain outcome(成功或 typed error)轉為 result 與 diagnostic frames,不改變 command 行為。負責格式化內容;實際寫入 process streams 由 Stream Manager 負責。 + +```ts +function emit(outcome, ctx) { + if (ctx.output === "json") { + ctx.streams.result(JSON.stringify(envelope(outcome, ctx))) // exactly one stdout frame + } else { + if (outcome.ok) ctx.streams.result(renderText(outcome)) + else ctx.streams.diagnostic(concise(outcome.error)) + } + for (const w of outcome.warnings) ctx.streams.diagnostic(w) // stderr / meta.warnings +} +``` + +**備註:** JSON 模式產生恰好一個 result frame;Stream Manager 將該 frame 送到 stdout、所有 diagnostics 送到 stderr。空 `data` 為 `{}` 而非 `null`;大額為字串;binary 需宣告 encoding。 + +### ⑧ Stream Manager(`runtime/stream-manager.ts`) + +**職責:** 強制 terminal I/O discipline。由 Runtime 擁有,但位於 Output formatter 與 process streams 之間。 + +```ts +class StreamManager { + result(bytes: string): void // stdout, final command result only + diagnostic(msg: Diagnostic): void // stderr, warnings/progress/debug/human errors + readSecretOnce(kind: SecretKind): Promise +} +``` + +**備註:** +- `stdout` 保留給 command results。JSON 模式恰好接收一個 result envelope。 +- `stderr` 接收 diagnostics、warnings、progress、text-mode errors、Ledger 等待訊息與 verbose debug。 +- 預設關閉 business input 的 `stdin`。僅 explicit stdin flags 可讀取,且每次 read 會 memoize,避免多個 consumer 卡住 process。 +- 第三方 library 輸出不得污染 JSON stdout;wrapper 應透過此 manager 路由或抑制 noisy dependency output。 + +--- + +## 4. Flag Classification + +本章定義 CLI 的 flag surface,刻意與 command grouping 分開:開發者應先知道要加的是哪一類 flag、哪一層擁有它、以及是否可持久化或記錄日誌。 + +**產品形狀** + +```text +wallet --network [options...] +wallet [options...] +``` + +**positional path**(` ` 或頂層 ``)對順序敏感,識別 command shape。network-bound commands 必須帶 `--network`,解析為 canonical `NetworkDescriptor`;描述符的 `family` 再選擇具體 chain command implementation(`tron.*` 或 `evm.*`)。 + +**flags 與位置無關**(§3 [決策](#②-cli-layercli))。global options 與 command options 在使用者眼中為單一扁平命名空間 — 任何 `--flag` 可出現在 positionals 前、中、後。下方表格依 ownership 與 handling 分類,而非規定使用者必須在哪裡輸入。 + +### 4.1 Global Runtime Flags + +| Flag | 說明 | +| --- | --- | +| `--output text\|json` | 輸出格式;`json` 走固定 envelope。 | +| `--network ` | 選具體 network,可用 canonical id(`evm:56`)或 alias(`bsc`、`nile`)。network-bound commands 必帶。 | +| `--account ` | tx/sign 的主選擇器,精確到 account(`wlt_x.0` 或唯一 label);未指定時使用 `activeAccount`。 | +| `--wallet ` | 選整個錢包 → 用其 active/預設 account(如 index 0)。便利用途;高風險操作建議用 `--account`。 | +| `--quiet` | 抑制非必要 diagnostics(不影響 command data)。 | +| `--verbose` | 輸出 debug 級 diagnostics 到 stderr。 | +| `--timeout ` | 操作逾時(含 Ledger 等待確認)。 | +| `--no-device-wait` | Ledger 簽名時不等待,立即失敗(供自動化用)。 | +| `--help` / `-h` | 顯示說明。 | +| `--version` | 顯示版本。 | + +### 4.2 Endpoint Override Flags + +| Flag | 說明 | +| --- | --- | +| `--grpc-endpoint ` | 覆寫本次 TRON command 的 resolved gRPC endpoint。 | +| `--rpc-url ` | 覆寫本次 EVM-compatible command 的 resolved JSON-RPC endpoint。 | + +Endpoint override flags 為 Runtime flags,由 `config-loader` 擁有。會覆寫 built-ins 與 `~/.wallet-cli/config.yaml` 單次執行,但不是 command business input。 + +### 4.3 Secret-Bearing Flags + +| Flag | 說明 | +| --- | --- | +| `--password-stdin` | 從 stdin 讀 master password 以解鎖 vault/key;可覆寫 `MASTER_PASSWORD` env。 | +| `--private-key-stdin` | `wallet import --type privateKey` 從 stdin 讀 raw private key。 | +| `--mnemonic-stdin` | `wallet import --type seed` 從 stdin 讀 BIP39 mnemonic。 | +| `--tx-stdin` | 從 stdin 讀明確指定的 transaction payload。 | + +Secret-bearing stdin flags 為 explicit opt-in,且 stdin 恰好讀取一次(見 §6 Stream management)。 + +### 4.4 Common Command-Input Flag Families + +Command input flags 由各 command 的 `zod` schema 定義。下方表格僅供描述;required/optional/default/conditional 行為來自 concrete command 的 schema(由 `network.family + path` 解析)。 + +| Flag family | Examples | Owner | Notes | +| --- | --- | --- | --- | +| Target address | `--address`, `--to`, `--receiver` | Chain command | 依 resolved network family 的 address codec 驗證。 | +| Amount | `--amount`, `--amount-sun`, `--amount-wei` | Chain command | 大數為字串;單位依 command 而定。 | +| Token / contract | `--token`, `--contract`, `--method`, `--params` | Chain command | TRON 與 EVM 在意圖相同時共享名稱,codec 不同。 | +| Fee/resource | `--fee-limit`, `--gas-price`, `--max-fee`, `--max-priority-fee`, `--resource` | Chain command + capability gate | schema 處理形狀;capability/network gate 處理 fee model 支援。 | +| Execution mode | `--dry-run`, `--broadcast` | Chain command | pipeline 控制回傳 plan、signed tx 或 broadcast result。 | +| Wallet management | `--type`, `--label`, `--account`, `--chain` | Top-level `wallet` command | 用於本地 wallet/account 管理,非鏈上執行。路徑由 account index + 寫死模板算出,故無 `--path-*`。 | + +--- + +## 5. Planned Command Groups + +> 僅代表性分組。完整 command surface 將於後續對照 Java Standard CLI inventory 與 EVM-compatible 增補列舉。Commands 依 user intent 分組,而非依 flag category。 + +Network-bound commands 使用: + +```text +wallet --network [options...] +``` + +Top-level local commands 使用: + +```text +wallet [options...] +``` + +### 5.1 Local Wallet And Config + +| Group | Representative commands | Network required? | Purpose | +| --- | --- | --- | --- | +| Wallet identity | `wallet create`, `wallet import`, `wallet list`, `wallet set-active`, `wallet export-address` | No | 管理本地 wallet identities、encrypted secrets 與 derived addresses。 | +| Config | `config get`, `config set` | No | 讀寫非 secret 使用者設定(endpoints、network aliases 等)。 | +| Chains / networks | `chains list`, `chains networks` | No | 列出支援的 chain families、canonical network ids、aliases 與 endpoint metadata。 | +| Capabilities | `capabilities --network ` | Yes | 顯示 resolved network 的 machine-readable capabilities。 | + +### 5.2 Account And Query + +| Group | Representative commands | Network required? | Notes | +| --- | --- | --- | --- | +| Account | `account balance`, `account info`, `account resources` | Yes | 共享名稱、chain-shaped data。TRON 可含 bandwidth/energy;EVM 可含 nonce/gas-relevant account data。 | +| Blocks / transactions | `get-block`, `tx status`, `tx receipt` | Yes | 唯讀鏈查詢。 | +| Tokens | `token balance`, `token info`, `token allowance` | Yes | TRC20/ERC-20 共享 intent,address 與 ABI/codec 為鏈專屬。 | + +### 5.3 Transaction And Contract + +| Group | Representative commands | Network required? | Notes | +| --- | --- | --- | --- | +| Native transfer | `tx send-native` | Yes | 依 `network.family` 解析為 TRON 或 EVM implementation。 | +| Token transfer | `tx send-token` | Yes | TRC20/ERC-20 transfer;schema 與 codec 為鏈專屬。 | +| Transaction pipeline | `tx build`, `tx sign`, `tx broadcast` | Yes | 規劃中的 dry-run、offline signing 與 agent workflow 階段。 | +| Contract | `contract call`, `contract send`, `contract deploy`, `contract trigger` | Yes | 共享 command intent;TVM/EVM encoding 仍為鏈專屬。 | + +### 5.4 TRON-Specific Resource And Governance + +| Group | Representative commands | Network required? | Notes | +| --- | --- | --- | --- | +| Resources / staking | `freeze`, `unfreeze`, `delegate-resource`, `undelegate-resource` | Yes | TRON-only capabilities,由 `networkId` gate。 | +| Governance | `vote-witness`, `witness list`, `proposal create`, `proposal approve`, `proposal delete` | Yes | 來自 Java CLI surface 的 TRON-only command groups。 | +| TRC10 / exchange / gasfree | `asset-issue`, `participate`, `exchange`, `market-order`, `gasfree` | Yes | TRON-only 領域保留為 first-class commands,不藏在 EVM abstraction 後。 | + +### 5.5 EVM-Compatible Specifics + +| Group | Representative commands | Network required? | Notes | +| --- | --- | --- | --- | +| Fee controls | `tx send-native` with `--gas-price` or EIP-1559 fee flags | Yes | `NetworkDescriptor` 控制 `legacy` vs `eip1559` capability。 | +| Message signing | `message sign`, typed-data signing | Yes | EVM signing formats 與 TRON message/TIP-712 行為分離。 | +| Contract deployment | `contract deploy` | Yes | EVM bytecode/ABI flow;TRON deployment 仍為鏈專屬。 | + +--- + +## 6. 設計決策與細節拆解 + +上述大架構下的細節拆解。不屬 §1–§5 的內容皆在此。 + +### Keystore 與金鑰管理 + +日後最難改動的部分,故具體規格化。**[決策:以 seed/vault 為中心的儲存;單一 master password;與 Java 版面乾淨切斷。]** + +**為何以錢包為中心,而非以位址為中心。** BIP39 seed 本質上為多鏈:同一 seed 經 coin type 195 衍生 TRON 帳戶、經 coin type 60 衍生 EVM 帳戶。 +raw secp256k1 private key 也可同時呈現為 EVM 與 TRON 位址。Java 格式將 mnemonic 綁定單一 TRON 位址,無法表達多鏈。 +此處**使用者可見的儲存單位為錢包身分**,由一個 seed vault、raw private key 或 Ledger 註冊支撐。 +位址為 `addresses[chain]` 下的衍生視圖,非分開儲存的秘密。 + +匯入軟體秘密時**不**詢問使用者這是「TRON 金鑰」還是「EVM 金鑰」。秘密與鏈無關。 +CLI 衍生並記錄該錢包支援的位址視圖:TRON 為 Base58Check `T...`,EVM 為 EIP-55 `0x...`。 +秘密與錢包 metadata **分離**:錢包列表為明文,`wallet list` 無需解鎖;僅 seed 與 raw key 加密。 + +**磁碟版面:** + +根目錄預設為 `~/.wallet-cli/`,可由環境變數 `WALLET_CLI_HOME` 覆寫為任意路徑(測試/CI 隔離、無 `$HOME` 的沙箱、多 profile)。覆寫的是**整棵樹**:`config.yaml`、`wallets.json`、`vaults/`、`keys/`、`ledger/` 一起搬,因為 `wallets.json` 的錢包項目經 `source` 指向同樹下的 `vaults/`、`keys/`,四者必須同住。`WALLET_CLI_HOME` 只改位置,不改加密——秘密在新位置一樣加密。 + +```text +$WALLET_CLI_HOME/ 或 ~/.wallet-cli/ # 後者為預設;前者覆寫整棵樹 + config.yaml # 明文使用者設定 — 無秘密 + wallets.json # 明文註冊表 — 無秘密 + vaults/.json # 加密的 BIP39 seed/entropy + keys/.json # 加密的 raw private key + ledger/.json # 唯讀:裝置 + 已註冊路徑(無秘密) +``` + +**`wallets.json`:** + +```json +{ + "version": 1, + "activeAccount": "wlt_x.0", + "wallets": [ + { + "id": "wlt_x", + "source": { "type": "seed", "vaultId": "vlt_9f3a", "accounts": [0, 1] }, + "addresses": { + "0": { "tron": "T...", "evm": "0x..." }, + "1": { "tron": "T...", "evm": "0x..." } + } + }, + { + "id": "wlt_k", + "source": { "type": "privateKey", "keyId": "key_7b2c" }, + "addresses": { "": { "tron": "T...", "evm": "0x..." } } + }, + { + "id": "wlt_l", + "source": { "type": "ledger", "deviceId": "led_a1", "accounts": [0] }, + "addresses": { "0": { "tron": "T...", "evm": "0x..." } } + } + ], + "labels": { + "wlt_x": "main-seed", + "wlt_x.0": "main", + "wlt_x.1": "savings", + "wlt_k": "hot", + "wlt_l": "ledger" + } +} +``` + +規則: + +- **定址單位是 account,不是錢包。** 一個錢包(`wlt_x`)= 一個秘密來源(vault/key/ledger)。seed 與 ledger 為 HD,`accounts` 陣列列出已知的 BIP44 account 索引(`[0, 1]`);每個 index = 一個 account,各有自己的 tron+evm 位址。`wlt_k`(raw private key)非 HD,無 `accounts`、無 index、單一 account。 +- **account reference** 為貫穿全結構的定址單位:`wlt_x.`(HD)或 `wlt_x`(privateKey)。它同時是 `activeAccount` 指的東西、`labels` 的 key、`--account` 選的東西、`addresses` cache 的 key。 +- **路徑不存字串,由模板算出。** `m/44'/{coinType}'/{account}'/0/0`,其中 coin type(tron=195、evm=60)與 `purpose/change/address_index` 寫死在專案;只存 `accounts` 的 index。`add-account` 對 seed/ledger append 下一個 index(privateKey **無**此命令)。 +- `addresses` 為衍生公開識別的 cache,**按 account index 鍵**(privateKey 用 `""`)。明文儲存以利秒列 `wallet list` 與唯讀命令;解鎖秘密或查詢裝置後可重算。 +- `activeAccount` 指一個 account ref(`wlt_x.0`),**而非整個錢包**——因為簽名/發交易的單位是 account。TRON 命令用該 account 的 `addresses[index].tron`,EVM 用 `.evm`;缺該鏈視圖則 `missing_wallet_address`。 +- 秘密(seed 或 raw secp256k1 key)與鏈無關;**鏈歸屬由 account 下存在哪些位址視圖表達**,而非秘密檔本身。 + +**身分與顯示名:`id`、account ref、`labels`** — 身分(機器參照)與顯示名(人類取的)分離儲存: + +| 欄位 | 角色 | 特性 | +| --- | --- | --- | +| `id`(`wlt_3f9k2p7q`) | 錢包層穩定鍵 | 系統生成、不可變、不可重用、opaque | +| account ref(`wlt_x.0`/`wlt_k`) | 定址單位 | 由 `id` + account index 組成;privateKey 無 index | +| `labels[ref]`(`"main"`) | 人類顯示名 | 使用者取、**唯一**、可改名;存在 **root `labels` map** | + +- **`id` 生成**:格式 `wlt_` + Crockford base32(來自 CSPRNG 亂數,如 `randomBytes(5)`,40 bits)。**不用時間當 seed**(同毫秒會撞、可預測);唯一性靠「生成後比對註冊表現有 id,撞到就重生」。`id` **絕不由秘密衍生**,以免在明文 `wallets.json` 留下與私鑰相關的指紋。`vaultId`/`keyId` 採同一「隨機、不重用」原則(範例的 `vlt_9f3a`、`key_7b2c` 為示意;實際為隨機)。 +- **`labels` 移到 root,key 為 account ref。** wallet 記錄因此維持「純粹有哪些 key/account」,label 是獨立展示層。key 可為錢包層(`wlt_x`,純分組)或 account 層(`wlt_x.0`,簽名實際選的就是這個),故同一張 map 能同時命名整個錢包與個別 account。 + - **唯一性橫跨整張 `labels` map**(wallet 層 + account 層為**同一命名空間**),`--account main` 才能反查唯一命中;`import`/`rename` 以 trim + 大小寫不敏感比對,撞名即拒絕。保留前綴:label 不得以 `wlt_` 開頭,否則與 ref 混淆。 + - **刪除要顯式清孤兒**:刪 account/wallet 時,對應的 `labels[ref]` 不會自動消失(分離結構非內嵌),必須主動清。 +- **為何 label 已唯一仍保留 id/ref**:唯一 label 只在「某一時刻」唯一,**不跨時間**——使用者可刪掉 `main` 再建一個同名 `main`(不同私鑰)。若用 label 當引用,腳本會在刪除+重建後**靜默改指到另一把鑰匙**。`id`/ref 不可變且不重用,使釘死的引用「要嘛精確命中、要嘛報錯」,永不靜默改指;rename 也只動 `labels` map,`activeAccount` 與外部引用不受影響。 +- **選取解析**: + - `--account ` — tx/sign 的**主選擇器**,精確到 account。value 以 `wlt_` 開頭 → 當 **ref**(精確命中或 not-found);否則當 **label** → 剛好 1 個用它、0 個 not-found、**2 個以上為歧義硬報錯**(列出候選 ref + address,要求改用 ref),**簽名/發交易等高風險路徑絕不替使用者猜**。 + - `--wallet ` — 選**整個錢包** → 用其 active/預設 account(如 index 0)。便利用途;高風險操作建議用 `--account` 精確指定。 + - 歧義分支為防呆:寫入時的唯一性已使其正常不會發生,但檔案被手動編輯時仍硬擋。 +- **import 分工**:使用者提供秘密(`--private-key-stdin`/`--mnemonic-stdin`)與選填 `--label`;CLI 自動生成 `id`、建 account 0、衍生 `addresses`、寫加密 vault/key 並回填 `vaultId`/`keyId`、在 root `labels` 寫入該 ref 的顯示名。`--label` 省略時給預設(如 `wallet-N`)。重複 import 的去重比對 `addresses` 而非 id。改名用 `wallet rename --account --label `(或 `--wallet `),僅動 `labels` map。 + +**加密 envelope** — 每個 `vaults/*.json` 與 `keys/*.json` 為獨立加密 blob,採標準 Web3 風格 envelope(選用因其密碼學品質,非為相容): + +```json +{ + "id": "vlt_1", + "type": "bip39-seed", // keys/*.json 為 "raw-privkey" + "version": 1, + "crypto": { + "cipher": "aes-128-ctr", + "ciphertext": "…", + "cipherparams": { "iv": "…" }, + "kdf": "scrypt", + "kdfparams": { "n": 262144, "r": 8, "p": 1, "dklen": 32, "salt": "…" }, + "mac": "keccak256(dk[16:32] || ciphertext)" + } +} +``` + +- 每個秘密檔自帶 `salt`,故**單一 master password** 對每檔衍生不同金鑰。單檔洩漏仍個別加密。 +- Master password 由 `MASTER_PASSWORD` env 或 `--password-stdin` 解析。秘密永不記錄且不出現在任何 JSON envelope。 +- 明文為 BIP39 entropy(vault)或 32-byte private key(key)。 + +**匯入方式。** `wallet import` 支援:raw private key、BIP39 mnemonic(成為 vault)、Ledger 錢包註冊(成為唯讀錢包身分)。 +軟體匯入預設所有支援的鏈視圖(`tron` 與 `evm`),除非未來進階旗標縮小集合。 +**刻意不提供**舊 Java 目錄格式的匯入器。 + +### Ledger Model & Active-Wallet-Driven Signing + +**[決策:Ledger 錢包為唯讀註冊項目 — 磁碟上無秘密。]** +`ledger/.json` 記錄裝置與已註冊的 account 索引;`wallets.json` 中每個 Ledger 錢包引用 `{ deviceId, accounts }`(路徑由 index + 寫死模板算出,不存字串)。 +所有簽名在命令執行時委派給裝置。Ledger 與 seed 同套 HD/account 模型,只差秘密住在硬體、在裝置內簽。 + +註冊 Ledger 錢包為一次性**人工**操作(裝置連接、App 開啟)。僅快取公開位址 — **不**快取任何簽名能力。 +註冊後裝置可拔除;唯讀與 tx 建構命令仍可用,但每次簽名仍需要裝置與實體按鍵。 + +**使用中錢包驅動行為。** 簽名行為非每命令旗標 — 由執行期**使用中錢包的 `source.type`** 決定(§3 ⑥ 的 `resolveSigner` switch)。 +使用中 account 為 `wallet set-active` 最後選擇者(`activeAccount`),或明確 `--account`/`--wallet` 覆寫。 +同一命令(`tx send-native --network nile …`)依使用中錢包不同而行為不同,參數不變: + +```mermaid +flowchart TD + A[簽名命令] --> B[解析使用中 account] + B --> C{source.type?} + C -- seed / privateKey --> D[以 master password 解鎖] + D --> E[程序內簽名 — 可無人值守] + C -- ledger --> F[建構未簽名 tx,不需裝置] + F --> G{裝置就緒?
已連接、已解鎖、正確 App} + G -- 否 --> H[error: auth_required
可操作訊息] + G -- 是 --> I[送至裝置,stderr:等待確認] + I --> J{使用者在 --timeout 內確認?} + J -- 是 --> K[簽名、廣播、結果 envelope] + J -- 否/拒絕/逾時 --> L[error: signing_rejected, exit 1] +``` + +這符合一般錢包 UX:**「哪個錢包使用中」決定「是否需要裝置」**。軟體金鑰可無人值守簽名;Ledger 錢包使同一命令阻塞等待硬體確認。 + +**仍完全非互動。** Ledger 裝置確認**不是** CLI 互動。CLI 永不顯示 stdin 提示、永不進入 REPL、不讀額外輸入 — 僅**阻塞於外部硬體 I/O**, +如同阻塞於慢速 RPC。命令仍由旗標 upfront 完整指定且只執行一次。故 Ledger 不違反非互動設計; +唯一固有事實是硬體錢包按鍵無法由任何旗標預先授權。 + +**具體 Ledger 簽名流程:** + +1. 使用中 account = 某 Ledger 錢包的一個 account(先前 `wallet set-active` 或 `--account`/`--wallet`)。 +2. 執行 `wallet tx send-native --network nile …`(所有旗標 upfront,單次執行)。 +3. 建構未簽名 tx 並估算費用 — 這些階段**不需裝置**。 +4. 檢查裝置:未連接/鎖定/錯誤 App 時回傳 `auth_required` 與可操作訊息(例:「連接 Ledger 並開啟 TRON App」)。 +5. 裝置就緒 → 送至裝置簽名;阻塞至多 `--timeout`;將 `waiting for device confirmation…` 印到 **stderr**(JSON 模式亦在 `meta.warnings: [{ "code": "awaiting_device_confirmation" }]` 呈現)。stdout 保持沉默。 +6. 使用者在裝置確認 → 簽名 → 廣播 → 將結果 envelope 輸出到 stdout。 +7. 拒絕或 `--timeout` 屆滿 → `error.code: signing_rejected`,exit `1`。 + +**人類與 agent 自然浮現 — 無 session 嗅探。** 人類與 agent 在**同一路徑**執行**同一命令**;CLI 不偵測或特殊化呼叫者(無 TTY 嗅探 — 那會使行為依隱藏狀態而非旗標)。 +差異自然浮現: + +- **人類:** 有人按裝置按鍵 → 阻塞解除,簽名成功。 +- **Agent:** 無人按鍵 → 阻塞至 `--timeout` 後回傳錯誤。 + +若 agent 應快速失敗而非等逾時,以**明確旗標**(`--no-device-wait`)表達,保持行為由旗標驅動且確定。 +建構與簽名為分離管線階段,使無需裝置的工作在需要裝置前完成並驗證 — 注定失敗的交易永遠不要求使用者插上 Ledger。 + +#### Ledger 整合研究(Node CLI) + +| 需求 | 套件 | 備註 | +| --- | --- | --- | +| Transport | `@ledgerhq/hw-transport-node-hid` | Node HID 經 `node-hid`/`usb`。**WebHID 僅瀏覽器且需 click-context UI 事件 — CLI 的硬性阻擋**,故非選項。 | +| Transport(CLI 重用) | `@ledgerhq/hw-transport-node-hid-singleton` | 管理單一重用連線;適合一次一裝置的 CLI。 | +| TRON app | `@ledgerhq/hw-app-trx` | `getAddress(path)`、`signTransaction(path, rawTxHex, tokenSignatures, …)`、`signTransactionHash(path, hash)`、`signPersonalMessage`、`signTIP712HashedMessage`、`getAppConfiguration()`。 | +| EVM app | `@ledgerhq/hw-app-eth` | `getAddress(path)`、`signTransaction`、`signPersonalMessage`、`signEIP712Message`。 | + +保持 transport 與 app 模組版本對齊 — 已知一類 `undefined` 回應 bug(例如 `signPersonalMessage` 讀 `response[0]`)由版本漂移引起。 +Ledger 亦朝新版 `@ledgerhq/device-management-kit` 演進;值得評估,但 `hw-transport-node-hid` + `hw-app-*` 堆疊為今日實證路徑。 + +**Ledger 如何對應 keystore 模型:** + +- **註冊**(`wallet import` Ledger):開啟相關裝置 App,對 account 0 的每條鏈呼叫 `app.getAddress(derivationPath(family, 0))`,將 `{ deviceId, accounts: [0] }` 存入 `ledger/.json`,並在 `wallets.json` 建立 `source: { type: "ledger", deviceId, accounts: [0] }` 與按 index 鍵的衍生 `addresses` 的錢包項目,再於 root `labels` 寫入顯示名。**不寫入秘密。** `add-account` 向裝置要新 index 的公鑰並 append。 +- **簽名**(交易管線,`source.type === "ledger"`):建構未簽名 tx,以 `derivationPath(chainFamily, accountIndex)` 算路徑,TRON 呼叫 `signTransaction(path, rawTxHex, …)`(或依 app 設定 `signTransactionHash`);EVM 呼叫 `signTransaction`。附加回傳簽名。私鑰永不離開裝置。 + +`deviceId`:Ledger HID 不便暴露穩定序號。Java CLI 以 canonical 預設路徑上的位址識別裝置。該技巧與鏈耦合,多鏈設計下較乾淨的選項為**使用者提供的 label** 加上參考路徑位址作為健全性檢查。 +(開放設計點 — 實作時決定。) + +裝置前置條件(已解鎖、正確 App、交易所需 blind-signing/contract-data 等)應經 `getAppConfiguration()` 檢查並回傳可操作錯誤,而非不透明傳輸失敗。 + +_來源:_ [@ledgerhq/hw-app-trx (npm)](https://www.npmjs.com/package/@ledgerhq/hw-app-trx) · +[Node HID integration — Ledger Developer Portal](https://developers.ledger.com/docs/device-interaction/ledgerjs/integration/desktop-application/node-electron-hid) · +[@ledgerhq/hw-transport-node-hid (npm)](https://www.npmjs.com/package/@ledgerhq/hw-transport-node-hid) · +[Transports — Ledger Developer Portal](https://developers.ledger.com/docs/device-interaction/integration/how_to/transports) + +### 能力優先設計 + +每條鏈發布機器可讀能力,使人類、腳本與 agent 在呼叫命令前可發現支援內容。 + +```text +account.balance.native account.balance.token +tx.native.transfer tx.token.transfer +tx.estimate tx.sign tx.broadcast message.sign +contract.call contract.deploy +resources.energy resources.bandwidth # 僅 TRON +staking.freeze staking.delegate # 僅 TRON +governance.vote governance.proposal # 僅 TRON +fee.eip1559 # 僅 EVM +``` + +```mermaid +flowchart LR + A[請求命令] --> B[正規化 argv
位置參數 + 扁平旗標] + B --> C[解析網路別名
為 canonical id] + C --> D[解析具體命令
network.family + path] + D --> E[驗證合併選項 schema] + E --> F{networkId 存在
該能力?} + F -- 否 --> G[回傳 unsupported_network_capability] + F -- 是 --> H[執行] +``` + +### 命令面:共享 vs 鏈專屬 + +| Family | TRON | EVM | 共享命令名? | +| --- | --- | --- | --- | +| `account balance`(原生) | ✅ | ✅ | 是(helper,鏈形狀資料) | +| `account info` | ✅ | ✅ | 是 | +| `tx send-native` | ✅ | ✅ | 是 | +| `tx send-token`(TRC20/ERC-20) | ✅ | ✅ | 是 | +| `tx build / sign / broadcast / status` | ✅ | ✅ | 是(下方管線) | +| `contract call / deploy` | ✅(TVM) | ✅(EVM) | 名稱共享,codec 不共享 | +| `freeze / unfreeze / delegate-resource` | ✅ | ✗ | 僅 TRON 命名空間 | +| `vote-witness / witness / brokerage` | ✅ | ✗ | 僅 TRON 命名空間 | +| `proposal create/approve/delete` | ✅ | ✗ | 僅 TRON 命名空間 | +| TRC10 `asset-issue / participate` | ✅ | ✗ | 僅 TRON 命名空間 | +| Bancor `exchange / market-order` | ✅ | ✗ | 僅 TRON 命名空間 | +| `gasfree` | ✅ | ✗ | 僅 TRON 命名空間 | +| EIP-1559 費用控制 | ✗ | ✅ | 僅 EVM 命名空間 | + +### 輸出契約 + +JSON 輸出始終向 `stdout` 恰好輸出一個物件。 + +成功: + +```json +{ + "schema": "wallet-cli.result.v1", + "success": true, + "command": "tron.account.balance", + "chain": { "family": "tron", "networkId": "tron:nile", "network": "nile", "chainId": "nile" }, + "data": {}, + "meta": { "durationMs": 123, "warnings": [] } +} +``` + +錯誤: + +```json +{ + "schema": "wallet-cli.result.v1", + "success": false, + "command": "evm.tx.send-native", + "chain": { "family": "evm", "networkId": "evm:8453", "network": "base", "chainId": "8453" }, + "error": { "code": "insufficient_funds", "message": "…", "details": {} }, + "meta": { "durationMs": 98, "warnings": [] } +} +``` + +規則: + +- JSON 模式僅將最終結果 envelope 寫入 `stdout`;診斷僅 `stderr`。 +- 文字模式可將人類格式輸出寫入 `stdout`;文字模式錯誤將簡短訊息寫入 `stderr`。 +- 一次命令執行恰好一個終端結果。 +- 空資料為 `{}`,永不 `null`。 +- 金額在精度可能超出 JavaScript 安全整數時為**字串**(wei/sun 恆為真)。 +- 二進位資料宣告編碼(`hex`、`base64` 或鏈原生位址格式)。 +- `chain.networkId` 為穩定 canonical 網路身分。`chain.network` 為請求所用別名或描述符主要別名,僅供可讀性。 + +### 結束碼 + +**[決策:僅保留 0/1/2。]** 詳細失敗原因在 `error.code`,那才是自動化的真契約。額外數字碼對 shell 腳本收益甚微。 + +| 碼 | 意義 | +| --- | --- | +| `0` | 成功 | +| `1` | 執行錯誤(RPC 失敗、簽名失敗、餘額不足、交易拒絕、驗證/秘密錯誤) | +| `2` | 用法錯誤(旗標格式錯誤、缺少必填選項、命令形狀無效) | + +### 錯誤碼分類 + +```text +usage_error unknown_command invalid_option missing_option invalid_value +missing_network unsupported_chain unsupported_network ambiguous_network_alias +unsupported_capability unsupported_network_capability +auth_required auth_failed secret_source_error +rpc_error rate_limited timeout +insufficient_funds transaction_rejected signing_rejected +invalid_address missing_wallet_address invalid_amount encoding_error +execution_error internal_error +``` + +### 串流與輸入管理 + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Parser + participant Runtime + participant Registry + participant Contract + participant Handler + participant Formatter + User->>CLI: argv + env + 選用 stdin + CLI->>Parser: 正規化 argv 與路由形狀 + Parser-->>CLI: 路由形狀或用法錯誤 + CLI->>Runtime: 載入設定、建立 context + Runtime-->>CLI: 網路註冊表 + 串流 + 惰性錢包 + CLI->>Runtime: 將 --network 別名解析為 NetworkDescriptor + CLI->>Registry: 以 network.family + path 解析具體命令 + Registry-->>CLI: 命令定義或用法錯誤 + CLI->>Contract: 驗證合併選項 schema + Contract-->>CLI: 已驗證 globals + 命令輸入 + CLI->>Contract: 對 networkId 能力閘門 + CLI->>Handler: context + NetworkDescriptor + 已驗證輸入 + Handler-->>Runtime: 領域結果或型別化錯誤 + Runtime->>Formatter: 終端結果 + Formatter-->>User: stdout 結果 + Formatter-->>User: stderr 診斷 +``` + +規則: + +- 預設禁用 `stdin` 作為業務輸入。 +- `--password-stdin`、`--private-key-stdin`、`--mnemonic-stdin`、`--tx-stdin` 為明確 opt-in 旗標。 +- 任何消費 stdin 的旗標讀取 stdin 一次並在執行期 secret/input resolver 中 memoize。 +- 命令 handler 不得直接讀 `process.stdin`;它們收到已驗證命令輸入並透過執行期 helper 請求秘密材料。 +- 秘密不得記錄或包含在 JSON envelope。 +- 警告在 JSON 下結構化於 `meta.warnings`,文字模式印到 `stderr`。 +- JSON 模式下進度輸出禁用,除非明確送到 `stderr`。 + +### 設定模型 + +使用者設定位於 `<根目錄>/config.yaml`,根目錄預設 `~/.wallet-cli/`,可由 `WALLET_CLI_HOME` 覆寫(見上方磁碟版面)。明文,用於非秘密的使用者級預設:偏好輸出模式、RPC 端點、逾時、網路別名與自訂網路。 +可覆寫內建端點(如 TRON Nile 或 BSC)而無需在每條命令傳端點旗標。 + +**根目錄解析早於 config 分層。** `WALLET_CLI_HOME` 是 bootstrap 輸入,不屬於下方的 config 值分層——必須先知道根目錄在哪,才找得到 `config.yaml`。因此它在 `buildExecutionContext` 最開頭、`loadConfig` 之前就解析,不參與後續的值覆蓋計算。 + +分層優先順序,後者覆蓋前者: + +1. 內建預設 → 2. `~/.wallet-cli/config.yaml` → 3. 專案設定檔(若明確啟用)→ +4. 環境變數 → 5. 全域 CLI 選項 → 6. 命令區域選項。 + +範例 `~/.wallet-cli/config.yaml`: + +```yaml +defaultOutput: text +timeoutMs: 30000 +networks: + "tron:mainnet": + family: tron + chainId: mainnet + aliases: [tron] + grpcEndpoint: grpc.trongrid.io:50051 + + "tron:nile": + family: tron + chainId: nile + aliases: [nile] + grpcEndpoint: grpc.xxx.example:50051 + solidityGrpcEndpoint: grpc-solidity.xxx.example:50051 + + "tron:shasta": + family: tron + chainId: shasta + aliases: [shasta] + grpcEndpoint: grpc.shasta.trongrid.io:50051 + + "evm:1": + family: evm + chainId: "1" + aliases: [eth, ethereum] + rpcUrl: https://ethereum-rpc.example + feeModel: eip1559 + + "evm:56": + family: evm + chainId: "56" + aliases: [bsc, bnb] + rpcUrl: https://bsc-dataseed.binance.org + feeModel: legacy + + "evm:11155111": + family: evm + chainId: "11155111" + aliases: [sepolia] + rpcUrl: https://sepolia-rpc.example + feeModel: eip1559 +``` + +解析規則: + +- `--network nile` 將別名 `nile` 解析為 canonical id `tron:nile`;若使用者設定定義 `networks.tron:nile.grpcEndpoint`,覆寫內建 Nile 端點。 +- `--network bsc` 將別名 `bsc` 解析為 `evm:56`;若使用者設定定義 `networks.evm:56.rpcUrl`,覆寫內建 BSC 端點。 +- `--network evm:56` 跳過別名查詢,直接解析 canonical id。 +- 端點旗標(`--grpc-endpoint`、`--rpc-url`)對該次執行覆寫內建預設與設定。 +- 自訂網路在採用 canonical id 鍵且包含該 family 所需欄位時有效(TRON 需 `grpcEndpoint`,EVM 需 `rpcUrl` + `chainId`)。 +- 別名僅面向使用者。執行期、鏈模組、能力檢查、快取與輸出使用 canonical 網路 id。 +- 設定不得含 private key、mnemonic、master password、API secret 或 bearer token。秘密留在加密 vault/key、環境變數或明確 stdin 旗標。 + +### 交易管線 + +交易命令經明確階段而非從旗標直接廣播。 + +```mermaid +flowchart TD + A[已驗證 tx 命令
+ NetworkDescriptor] --> B[解析 network.family 的
錢包位址] + B --> C[解析簽名器
seed/key/ledger] + C --> D[建構未簽名交易
鏈專屬形狀] + D --> E[估算費用/資源影響
gas 或 bandwidth/energy] + E --> F{乾跑?} + F -- 是 --> G[回傳未簽名交易計畫] + F -- 否 --> H[簽名交易] + H --> I{廣播?} + I -- 否 --> J[回傳已簽名交易] + I -- 是 --> K[廣播交易] + K --> L[回傳 tx id 與 receipt 提示] +``` + +效益:agent 可 dry-run;人類可檢視計畫;日後可離線簽名; +鏈專屬費用/資源模型保持可見(TRON bandwidth/energy vs EVM gas/EIP-1559)。 + +### 需保留的多鏈差異 + +| 關注點 | TRON | EVM 相容網路 | +| --- | --- | --- | +| 原生單位 | SUN/TRX | wei/ETH | +| 位址格式 | Base58Check `T...` | hex `0x...`(EIP-55) | +| 費用模型 | bandwidth/energy/TRX | gas、EIP-1559 | +| 代幣模型 | TRC-10 / TRC-20 | ERC-20 / ERC-721 | +| 交易形狀 | protobuf 衍生 | EIP-155 / EIP-1559 typed tx | +| 治理 | SR / 提案 | CLI 範圍內 n/a | +| 合約呼叫 | TVM、TRON 位址編碼 | EVM ABI | +| BIP44 coin type | 195 | 60 | + +僅在使用者意圖真正共享時使用共享命令名(原生餘額、原生轉帳)。 +共享語意會誤導時使用鏈專屬名稱。 + +### 建議的 TypeScript 函式庫 + +| 需求 | 候選 | +| --- | --- | +| CLI 解析 | 自訂 argv 正規化 + `zod`;可選 `commander`/`clipanion` 僅用於正規化後的說明或分派 | +| 執行期 schema 驗證 | `zod` | +| EVM RPC/簽名 | `viem` | +| TRON 支援 | `tronweb` 加上必要處的自訂 codec/簽名 | +| Keystore 密碼學 | `@noble/hashes`(scrypt、keccak)、`@noble/ciphers`(aes-ctr) | +| BIP39/BIP32 | `@scure/bip39`、`@scure/bip32` | +| Ledger | `@ledgerhq/hw-transport-node-hid` + 鏈 app 模組(見上文研究) | +| 設定解析 | `yaml` | +| 測試 | `vitest` | +| Golden CLI 測試 | spawn 程序測試 + JSON snapshot fixture | +| 打包 | 開發用 `tsx`,建置用 `tsup`/`esbuild` | + +`zod` 為骨幹:命令輸入 schema 同時作為說明、agent JSON-schema 匯出與執行期驗證。 + +### 測試策略 + +1. **解析/路由測試** — 扁平選項、說明/元選項、需網路命令缺少 `--network`、別名到 canonical id、歧義別名。 +2. **Schema 測試** — 命令輸入驗證、條件式 `zod` 規則、穩定輸出 envelope。 +3. **Keystore 測試** — 加解密往返、單一 vault 多鏈衍生、錢包註冊表完整性、master password 失敗路徑。 +4. **Golden CLI 測試** — spawn 編譯後 CLI,比對成功與錯誤的 JSON envelope。 +5. **雜訊依賴測試** — 確保 JSON 模式下 tronweb/viem 日誌不污染 `stdout`。 +6. **精度測試** — 大整數金額保持字串。 +7. **鏈整合測試** — opt-in、網路標記、與單元測試隔離。 + +### 遷移 / 建置順序 + +1. 定義輸出契約、命令定義模型、網路描述符模型與 keystore schema。 +2. 建核心 CLI 執行期:argv 正規化、設定載入、網路別名解析。 +3. 實作 seed/vault keystore + `wallet` 命令(create、import、list、set-active、export-address)。 +4. 加入 `chains list`、`capabilities --network`、說明/schema 匯出。 +5. 實作 TRON 唯讀命令。 +6. 實作 TRON 簽名命令(先轉帳,再資源/治理/TRC10)。 +7. 加入 EVM 模組(Base、Optimism):先唯讀,再簽名。 +8. 按鏈加入 build/sign/broadcast 管線。 +9. 在 transport 研究定案後加入 Ledger 簽名。 + +--- + +> **對照:** 英文原版見 [`typescript-wallet-cli-architecture-plan-v2.md`](./typescript-wallet-cli-architecture-plan-v2.md) diff --git a/ts/.dependency-cruiser.cjs b/ts/.dependency-cruiser.cjs new file mode 100644 index 000000000..1a1bcea08 --- /dev/null +++ b/ts/.dependency-cruiser.cjs @@ -0,0 +1,49 @@ +/** + * Enforced dependency direction: + * + * bootstrap (composition) -> inbound/outbound adapters -> application -> domain + * inbound adapters -> application -> domain + * + * Inbound and outbound adapters are peers. They may meet only in bootstrap/composition. + */ +module.exports = { + forbidden: [ + { + name: "no-circular", + severity: "error", + from: {}, + to: { circular: true }, + }, + { + name: "domain-is-independent", + severity: "error", + from: { path: "^src/domain/" }, + to: { path: "^src/(application|adapters|bootstrap)/" }, + }, + { + name: "application-owns-ports", + severity: "error", + comment: "production application code depends on domain and its own ports, never adapters", + from: { path: "^src/application/", pathNot: "\\.test\\.ts$" }, + to: { path: "^src/(adapters|bootstrap)/" }, + }, + { + name: "inbound-does-not-know-outbound", + severity: "error", + comment: "CLI adapters call application ports/use-cases; bootstrap/composition supplies outbound implementations", + from: { path: "^src/adapters/inbound/", pathNot: "\\.test\\.ts$" }, + to: { path: "^src/(adapters/outbound|bootstrap)/" }, + }, + { + name: "outbound-does-not-know-inbound", + severity: "error", + from: { path: "^src/adapters/outbound/", pathNot: "\\.test\\.ts$" }, + to: { path: "^src/(adapters/inbound|bootstrap)/" }, + }, + ], + options: { + doNotFollow: { path: "node_modules" }, + tsConfig: { fileName: "tsconfig.json" }, + enhancedResolveOptions: { extensions: [".ts", ".js"] }, + }, +}; diff --git a/ts/.gitignore b/ts/.gitignore new file mode 100644 index 000000000..1754c3ace --- /dev/null +++ b/ts/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.wallet-cli/ +.env +.private/ diff --git a/ts/README.md b/ts/README.md new file mode 100644 index 000000000..89c123878 --- /dev/null +++ b/ts/README.md @@ -0,0 +1,52 @@ +# wallet-cli + +TypeScript implementation of the TRON wallet CLI. This project owns its source, dependencies, +tests and live-test artifacts independently. + +The CLI contract is: + +- stable `wallet-cli.result.v1` JSON envelopes; +- deterministic `0 / 1 / 2` exit codes; +- exactly one terminal stdout frame in JSON mode; +- encrypted local wallet storage; +- stdin/TTY secret handling without secret argv/env values; +- TRON mainnet, Nile and Shasta targets; +- software and Ledger signing; +- extensible family plugins without a universal-chain abstraction. + +## Commands + +```bash +npm ci +npm run typecheck +npm run depcruise +npm test +npm run build +``` + +The live Nile suite reads the test identity from `../ts/.private/.env.test`, uses an isolated +`WALLET_CLI_HOME`, and never copies or logs the private material: + +```bash +npm run test:live:nile +``` + +Its raw output is written to +`docs/nile-full-command-test-2026-06-29-run2-rawlogs.md`. + +## Architecture + +```mermaid +flowchart LR + ENTRY[index.ts] --> BOOTSTRAP[bootstrap
runner + composition + family plugins] + BOOTSTRAP --> IN[inbound CLI adapter] + BOOTSTRAP --> OUT[outbound adapters] + IN --> APPLICATION[application
contracts + use cases + ports] + OUT --> APPLICATION + APPLICATION --> DOMAIN[domain
wallet + chain facts + value types] +``` + +Dependency direction is enforced by dependency-cruiser. Inbound and outbound adapters are peers +and may meet only in `bootstrap/composition.ts`. See the +[architecture source of truth](./docs/typescript-wallet-cli-architecture-source-of-truth.zh-TW.md) +for the complete contract; `docs/architecture.md` is its compact English overview. diff --git a/ts/docs/architecture.md b/ts/docs/architecture.md new file mode 100644 index 000000000..fe3b89850 --- /dev/null +++ b/ts/docs/architecture.md @@ -0,0 +1,230 @@ +# wallet-cli architecture — English summary + +This document is a non-normative English overview. The single, authoritative architecture and +behavior contract is +[typescript-wallet-cli-architecture-source-of-truth.zh-TW.md](./typescript-wallet-cli-architecture-source-of-truth.zh-TW.md). + +## 1. Dependency model + +```mermaid +flowchart LR + BOOTSTRAP[bootstrap
process lifecycle + composition root] --> INBOUND[adapters/inbound
CLI] + BOOTSTRAP --> OUTBOUND[adapters/outbound
filesystem / TRON / Ledger / price] + INBOUND --> APPLICATION[application
contracts / use cases / ports] + OUTBOUND --> APPLICATION + APPLICATION --> DOMAIN[domain
pure rules and values] +``` + +Rules: + +1. `domain` imports neither application, adapters nor bootstrap. +2. Production `application` imports neither adapters nor bootstrap. +3. Inbound and outbound adapters never import each other or bootstrap. +4. `bootstrap/composition.ts` is the only general composition root. +5. Chain-specific adapter and use-case construction belongs to a family plugin. +6. Circular dependencies are forbidden. +7. Type-only dependencies must follow the same conceptual direction even when the dependency + checker would ignore their runtime edge. + +## 2. Package tree + +```text +src/ +├── index.ts +├── bootstrap/ +│ ├── argv.ts +│ ├── runner.ts +│ ├── composition.ts +│ ├── family-registry.ts +│ └── families/ +│ ├── types.ts +│ └── tron.ts +├── domain/ +│ ├── address/ +│ ├── amounts/ +│ ├── derivation/ +│ ├── errors/ +│ ├── family/ +│ ├── resources/ +│ ├── sources/ +│ ├── types/ +│ └── wallet/ +├── application/ +│ ├── contracts/ +│ │ ├── execution-policy.ts +│ │ ├── execution-scope.ts +│ │ └── progress.ts +│ ├── ports/ +│ │ ├── chain/ +│ │ ├── network-registry.ts +│ │ └── prompt.ts +│ ├── services/ +│ │ ├── capability/ +│ │ ├── pipeline/ +│ │ ├── signer/ +│ │ └── target/ +│ └── use-cases/ +│ └── tron/ +└── adapters/ + ├── inbound/cli/ + │ ├── commands/ + │ ├── contracts/ + │ ├── context/ + │ ├── help/ + │ ├── input/ + │ ├── output/ + │ ├── registry/ + │ ├── render/ + │ └── shell/ + └── outbound/ + ├── chain/tron/ + ├── config/ + ├── keystore/ + ├── ledger/ + ├── persistence/ + ├── price/ + └── tokenbook/ +``` + +## 3. Responsibilities + +### Domain + +Contains pure wallet, account, address, amount, derivation, source, family and transaction value +rules. Domain code performs no filesystem, network, device, terminal or process I/O. + +### Application + +Defines what the product does and which external capabilities it requires. + +- `contracts`: adapter-neutral execution policy, scopes and progress events. +- `ports`: capabilities required by application workflows, implemented by inbound or outbound adapters. +- `services`: reusable orchestration such as signer resolution and transaction pipeline. +- `use-cases`: wallet/config and family-specific workflows. + +Application services consume ports such as `WalletRepository`, `ChainGatewayProvider`, +`LedgerDevice`, `TokenRepository`, `PriceProvider`, `NetworkRegistry`, `PromptPort` and +`BackupWriter`. They never construct or import filesystem, TronWeb, Ledger transport, CoinGecko, +Zod, yargs, CLI rendering or output-envelope implementations. + +### Inbound CLI adapter + +Owns yargs, zod command schemas, help/catalog generation, TTY input, stream discipline, output +envelopes and human rendering. A command definition translates CLI input into a use-case call; it +does not implement persistence or provider transport. CLI-only contracts (`CommandDefinition`, +`ExecutionContext`, globals, session and output envelopes) live under `inbound/cli/contracts`. + +### Outbound adapters + +Implement application ports: + +- encrypted file keystore and atomic persistence; +- YAML configuration document; +- secure backup writer; +- TRON gateway and TronGrid history reader; +- Ledger device transport; +- token repository and price provider. + +### Bootstrap + +- `argv.ts`: pre-yargs global scan required to select output and secret channels. +- `composition.ts`: constructs process-scoped adapters and shared services. +- `runner.ts`: executes one invocation and owns the terminal error boundary. +- `family-registry.ts`: enabled plugin list and family-keyed projections. +- `families/*.ts`: family-specific gateway/signing/use-case/command composition. + +## 4. Command flow + +```mermaid +flowchart LR + ARGV[argv] --> SHELL[yargs shell] + SHELL --> DEF[CommandDefinition] + DEF --> TARGET[target + capability gates] + TARGET --> USECASE[application use case] + USECASE --> PORT[application port] + PORT --> ADAPTER[outbound adapter] + USECASE --> RESULT[typed result] + RESULT --> FORMAT[text or JSON formatter] + FORMAT --> STREAM[one terminal frame] +``` + +Zod remains the single source for command input shape, defaults, validation, help fields and JSON +Schema. Global flags remain table-driven. Business execution modes and confirmation behavior have +one implementation in application services, not duplicate command helpers. + +## 5. Transaction flow + +```mermaid +flowchart LR + RESOLVE[resolve signer] --> BUILD[build] + BUILD --> ESTIMATE[estimate] + ESTIMATE --> MODE{mode} + MODE -->|dry-run| PLAN[plan] + MODE -->|sign| SIGN[software or Ledger sign] + SIGN --> BROADCAST{broadcast?} + BROADCAST -->|no| SIGNED[signed transaction] + BROADCAST -->|yes| SUBMIT[submitted receipt] + SUBMIT --> CONFIRM{wait?} + CONFIRM --> FINAL[submitted / confirmed / failed] +``` + +Chain-specific builders and confirmation readers are provided by the family gateway. The shared +pipeline knows only signer and broadcaster ports. + +## 6. Chain-family extension + +A family plugin owns the concrete composition for one chain family: + +The complete EVM implementation order, public discovery requirements, network contract and +acceptance checklist are defined in [evm-development-plan.zh-TW.md](./evm-development-plan.zh-TW.md). + +```ts +interface FamilyPlugin { + meta: FamilyMeta & { family: F } + signStrategy: SignStrategy + createGateway(network: NetworkDescriptor): ChainGatewayMap[F] + createModule(dependencies: FamilyApplicationDependencies): ChainModule +} +``` + +Adding EVM requires: + +1. Add `evm` to `ChainFamily` and `FamilyMeta` facts. +2. Extend the discriminated `NetworkDescriptor` union. +3. Extend `ChainGatewayMap` with an `EvmGateway` port. +4. Implement EVM address codec, gateway and signing strategy. +5. Implement EVM use cases and CLI command module without changing TRON use cases. +6. Add `bootstrap/families/evm.ts` and one registry entry. +7. Add routing, signer, capability, output and contract tests. + +Only genuinely shared capabilities receive shared ports. TRON staking/resource methods and EVM +gas/nonce methods stay in their family gateways; no universal gateway may accumulate unrelated +chain operations. + +## 7. Behavioral invariants + +- JSON success and error use `wallet-cli.result.v1`. +- Usage errors exit 2; execution errors exit 1; success/meta requests exit 0. +- JSON stdout contains exactly one terminal frame. +- Progress and diagnostics use stderr. +- Unknown exceptions are redacted. +- A run consumes stdin through at most one secret channel. +- Secrets never enter logs, envelopes, argv or environment variables. +- Dry-run does not decrypt, sign or broadcast. +- Watch-only accounts never sign. +- Every persistent mutation is locked and atomically replaced. +- Already-broadcast transactions do not become command failures solely because confirmation times + out. + +## 8. Verification gates + +```bash +npm run typecheck +npm run depcruise +npm test +npm run build +npm run test:live:nile +``` + +`test:live:nile` exercises the live surface in an isolated wallet home and emits a raw log without +exposing the test secret. diff --git a/ts/docs/evm-development-plan.zh-TW.md b/ts/docs/evm-development-plan.zh-TW.md new file mode 100644 index 000000000..460b56683 --- /dev/null +++ b/ts/docs/evm-development-plan.zh-TW.md @@ -0,0 +1,591 @@ +# wallet-cli EVM 支援開發計畫 + +> 狀態:待實作 +> 適用專案:`ts-refactor` +> 目的:列出將現行 TRON-only CLI 擴充為 TRON + EVM 時,所有必須修改、增加與驗收的範圍。本文刻意不展開函式與 RPC payload 的實作細節。 + +## 1. 完成定義 + +EVM 支援不能只代表「可以連 Ethereum RPC」。完成後必須同時成立: + +1. `evm` 是正式 `ChainFamily`,不再靠測試中的 type cast 模擬。 +2. 可解析 Ethereum mainnet `evm:1`,以及設定檔新增的任意 `evm:`,例如 `evm:11155111`、`evm:8453`、`evm:31337`。 +3. EVM network 可用 canonical id 或唯一 alias 選取,也可設為 `defaultNetwork`。 +4. seed、private key、watch-only 與 Ledger Ethereum app 都有明確 EVM 行為。 +5. EVM 有自己的 gateway、signing strategy、use cases 與 CLI command module,不把 EVM 方法塞進 TRON gateway。 +6. `wallet-cli --help`、family help、command help、`--json-schema` 與 `wallet-cli networks` 都能讓使用者/agent 發現 EVM。 +7. text 與 JSON 輸出正確呈現 EVM address、chain id、native currency、gas、fee、nonce、transaction hash 與 receipt。 +8. 原有 TRON 命令、輸出、秘密處理與 exit code 契約維持不變。 + +開發依賴順序: + +```text +公開契約 → Domain → Persistence/Config → Application ports → EVM adapters + → EVM use cases → CLI commands → Bootstrap → Help/Output → Tests/Docs +``` + +### 1.1 真正的程式異動分類 + +本文件後面出現的「涉及位置」包含修改、引用與驗證,不代表每個檔案都要改。依目前程式碼,實際分類如下。 + +#### 一定新增 + +- `application/ports/chain/evm-gateway.ts` +- `adapters/outbound/chain/evm/*` +- `application/use-cases/evm/*` +- `adapters/inbound/cli/commands/evm/*` +- `bootstrap/families/evm.ts` +- EVM confirmation、fixtures、unit/integration/live tests +- Explorer/history adapter(只有決定提供 EVM history/ABI metadata 時才新增) + +#### 一定修改 + +- Domain family/address/network/wallet/transaction types +- Config builtins、custom-network validation 與 network 顯示 +- Keystore schema migration 與舊 wallet EVM address backfill +- `ChainGatewayMap` 與 family plugin registry +- Generic gateway registry 的檔案歸屬(目前誤放在 `chain/tron/provider.ts`) +- Outbound Ledger dispatcher(若公開 Ledger EVM) +- Token builtin/normalization 與 CoinGecko network mapping +- Root/family/merged command help、global `--network` 說明與 wallet help examples +- Family renderer、EVM transaction/fee text output +- Dependencies、架構文件、help/golden baselines + +#### 原則上不修改,只新增 EVM 測試驗證可重用性 + +- `application/services/transaction-mode.ts` +- `application/services/pipeline/index.ts` +- `application/services/signer/index.ts` +- `application/services/signer/software.ts` +- `application/services/signer/ledger.ts` +- `application/services/target/index.ts` +- `application/contracts/execution-policy.ts` +- `application/contracts/execution-scope.ts` +- `application/ports/chain/broadcaster.ts` +- `application/ports/ledger-device.ts` +- `application/ports/network-registry.ts` +- `application/ports/token-repository.ts` +- `application/ports/price-provider.ts` +- `adapters/inbound/cli/registry/index.ts` +- `adapters/inbound/cli/shell/index.ts` +- `adapters/inbound/cli/command-id.ts` +- `adapters/inbound/cli/context/index.ts` +- `adapters/inbound/cli/arity/index.ts` +- `adapters/inbound/cli/output/envelope.ts` +- stream、secret、prompt 與 output formatter infrastructure + +#### 視公開決策才修改 + +- `CapabilityRegistry` 與 bootstrap capability composition:只有 history/indexer、legacy/EIP-1559 等要做 per-network gate 時才需改。 +- Help catalog:EVM commands 會由 registry 自動進入 catalog;只有要在 catalog 頂層增加 families/networks 摘要時才需改。 +- `WalletService`:現有 family detection 與 repository delegation 可重用;通常只需測試,migration 應留在 persistence adapter。 +- Shared transaction types/pipeline:types 必須擴充顯示欄位,但 pipeline control flow 原則上不改。 + +若實作過程必須修改上述「原則上不修改」的模組,應先指出目前 abstraction 缺少的 family-neutral 能力;不能只因 EVM adapter 寫法不順就加入 `if (family === "evm")`。 + +## 2. 開發前先鎖定的公開決策 + +以下決策必須先寫入架構契約,否則 help、network schema、command schema 與測試會反覆修改。 + +### 2.1 Network identity + +- EVM canonical network id 固定為 `evm:<十進位 chainId>`。 +- `evm:xxx` 中的 `xxx` 是 EIP-155 chain id,不是名稱;實際值必須為正整數字串。 +- 最低內建網路: + - `evm:1`,aliases 建議為 `eth`、`ethereum`。 + - 一個公開測試網,建議 `evm:11155111`,alias `sepolia`。 +- 其他網路透過 `config.yaml` 新增;是否額外內建 Base、Polygon、BSC 等由產品決策決定。 +- alias 必須全域唯一;重複 alias 要維持 `ambiguous_network_alias` 錯誤。 +- RPC 回報的 chain id 必須和設定值一致,簽名或 broadcast 前不得忽略 mismatch。 +- `defaultNetwork` 仍建議保持 `tron:mainnet`,除非另有產品遷移決策。 + +建議的自訂網路公開形狀: + +```yaml +defaultNetwork: evm:1 +networks: + evm:1: + family: evm + chainId: "1" + aliases: [eth, ethereum] + rpcUrl: https://example.invalid + feeModel: eip1559 + nativeCurrency: + name: Ether + symbol: ETH + decimals: 18 + + evm:31337: + family: evm + chainId: "31337" + aliases: [local] + rpcUrl: http://127.0.0.1:8545 + feeModel: eip1559 + nativeCurrency: + name: Ether + symbol: ETH + decimals: 18 +``` + +### 2.2 第一版命令面 + +同一 logical path 由 `--network` 選擇 TRON 或 EVM implementation。不得為了 EVM 另造一套頂層 `ethereum ...` 執行文法。 + +| 命令群組 | EVM 第一版要求 | 備註 | +| --- | --- | --- | +| wallet lifecycle | 支援 | create/import/derive 後顯示 EVM address;watch 可辨識 `0x...`。 | +| `account balance` | 支援 | 使用 network native currency。 | +| `account info` | 支援 | EVM 語意由 EVM use case 定義,不仿造 TRON resource 欄位。 | +| `account history` | 明確決策 | 標準 JSON-RPC 無 address history;需 explorer/indexer adapter,否則不得在不支援的 network help/capability 中宣稱可用。 | +| `account portfolio` | 支援 | native coin + ERC-20 token book + price provider。 | +| `token add/list/remove/balance/info` | 支援 | EVM token kind 為 `erc20`。 | +| `tx send/broadcast/status/info` | 支援 | native/ERC-20、legacy/EIP-1559、raw signed tx。 | +| `contract call/send/deploy/info` | 支援或明確降級 | `contract info` 若需要 ABI metadata,必須有 explorer source;只有 bytecode 時 help 必須如實描述。 | +| `message sign` | 支援 | software 與 Ledger 行為一致。 | +| `block` | 支援 | latest 或指定 block。 | +| `stake ...` | 不提供 EVM implementation | 保持 TRON-only,root/family help 必須標記。 | + +### 2.3 SDK 與硬體錢包 + +- 選定單一 EVM SDK;目前架構建議使用 `viem`,並加入 production dependencies。 +- 若宣稱完整 Ledger EVM 支援,加入 Ethereum Ledger app adapter 與對應套件(例如 `@ledgerhq/hw-app-eth`)。 +- 如果第一版不做 Ledger EVM,`FAMILIES.evm.ledger`、`import ledger --help` 與 README 都不得顯示 Ethereum app。 + +## 3. 分階段開發清單 + +### Phase 0:契約與測試基線 + +- [ ] 確認第 2 節的 network id、內建 networks、命令矩陣、Ledger 與 history/indexer 決策。 +- [ ] 建立 EVM address、transaction、receipt、legacy fee、EIP-1559 fee、ERC-20、block 與 RPC error fixtures。 +- [ ] 建立新的 multichain help/golden baseline;現有 TRON-only help parity 不可繼續當成整個 root help 的唯一真相。 +- [ ] 定義舊 `wallets.json` 版本升級策略與 rollback 行為。 + +### Phase 1:Domain + +涉及位置: + +- `src/domain/family/index.ts` +- `src/domain/address/index.ts` +- `src/domain/types/network.ts` +- `src/domain/types/tx.ts` +- `src/domain/types/token.ts` +- `src/domain/types/wallet.ts` +- `src/domain/sources/index.ts` +- `src/domain/derivation/index.ts` +- `src/domain/wallet/index.ts` +- `src/domain/errors/index.ts` + +工作清單: + +- [ ] 將 `evm` 加入 `ChainFamily` 與 `FAMILIES`。 +- [ ] 登記 EVM 的 BIP44 coin type `60`、smallest unit `wei`、address codec 與 Ledger metadata。 +- [ ] 新增 EVM address derive、validate、normalize/checksum 規則及測試。 +- [ ] 將 `NetworkDescriptor` 恢復為以 `family` 區分的 discriminated union。 +- [ ] 新增 `EvmNetworkDescriptor`:`rpcUrl`、十進位 chain id、fee model、native currency,以及選配 explorer/history 設定。 +- [ ] 驗證 canonical id 的 family 與 chain id 和 descriptor 一致。 +- [ ] 擴充 transaction view:gas、gas price、max fee、priority fee、effective gas price、nonce、EVM receipt/status 等欄位。 +- [ ] 移除共用 view 中只適用 TRON 的命名假設;保留向後相容的 TRON JSON 欄位時要明確記錄。 +- [ ] 確認 `erc20` token kind 的 family 約束與 contract address normalization。 +- [ ] 更新 seed/private-key address derivation、dedup、account projection 與 family detection 測試。 +- [ ] 補齊 network/chain mismatch、invalid chain id、invalid EVM address 等 typed error。 + +### Phase 2:Wallet persistence 與 migration + +涉及位置: + +- `src/adapters/outbound/keystore/index.ts` +- `src/domain/types/wallet.ts` +- `src/domain/wallet/index.ts` +- `src/application/ports/account-store.ts` +- `src/application/ports/wallet-repository.ts` +- `src/application/use-cases/wallet-service.ts` +- `src/adapters/outbound/persistence/backup-writer.ts` +- wallet/keystore tests 與 migration fixtures + +工作清單: + +- [ ] 提升 `wallets.json` schema version。 +- [ ] 處理既有 seed/private-key wallet 只有 TRON cached address 的資料;不能因 `ChainAddresses` 新增 `evm` 就讓舊檔失效。 +- [ ] 定義 EVM address 的 lazy backfill/顯式 migration 時機,以及需要 master password 時的 UX。 +- [ ] 確保 migration 使用 atomic write、lock,失敗不破壞原檔。 +- [ ] 新建與新匯入的 seed/private-key wallet 同時產生 TRON 與 EVM address。 +- [ ] `derive` 新 account 同時產生兩個 family address。 +- [ ] watch-only 自動辨識 TRON/EVM;EVM address 儲存前正規化。 +- [ ] Ledger/watch 仍為 single-family source。 +- [ ] list/current/use/rename/delete/backup 與 address lookup 支援 EVM address。 +- [ ] backup metadata 同時包含已知的 TRON/EVM addresses,舊 wallet 尚未 backfill 時不得偽造欄位。 +- [ ] 驗證同一 private key、不同 BIP44 seed path、舊資料 migration 與 dedup 行為。 + +### Phase 3:Config 與 NetworkRegistry + +涉及位置: + +- `src/adapters/outbound/config/builtins.ts` +- `src/adapters/outbound/config/index.ts` +- `src/adapters/outbound/config/yaml-config-document.ts` +- `src/application/ports/network-registry.ts` +- `src/application/use-cases/config-service.ts` +- `src/adapters/inbound/cli/commands/config.ts` +- `src/adapters/inbound/cli/commands/network.ts` + +工作清單: + +- [ ] 加入 `evm:1` 與選定測試網 builtin descriptor。 +- [ ] 對使用者自訂 network 做 runtime schema validation,不再直接 cast 成 `NetworkDescriptor`。 +- [ ] 支援任意合法 `evm:`,並拒絕 `family`、id、chain id 不一致。 +- [ ] 驗證 `rpcUrl`、native currency、fee model、aliases 與選配 indexer/explorer 欄位。 +- [ ] `NetworkRegistry.resolve()` 支援 EVM canonical id、case-insensitive alias 與 ambiguity 檢查。 +- [ ] `config defaultNetwork evm:1`、alias 設定與持久化可用。 +- [ ] `wallet-cli networks` 顯示 builtin 與 custom EVM networks、native symbol、RPC/fee model/capabilities 的安全摘要。 +- [ ] 不在一般輸出洩漏 RPC URL 中可能包含的 API key;需要 redaction 規則。 +- [ ] network RPC client 首次使用時驗證遠端 chain id。 + +### Phase 4:Application ports 與共用 services + +涉及位置: + +- `src/application/ports/chain/evm-gateway.ts`(新增) +- `src/application/ports/chain/gateway-provider.ts` +- `src/application/services/evm-confirmation.ts`(新增) +- `src/application/services/capability/index.ts`(只有 per-network capability 需要時修改) + +下列共用模組預期只補 EVM reuse tests,不修改 production code:`Broadcaster`、`LedgerDevice`、 +`TxPipeline`、`SignerResolver`、software/device signer、`TargetResolver`、`transactionMode`。 + +工作清單: + +- [ ] 定義 `EvmGateway`,只放 EVM 所需的 read/build/estimate/broadcast 能力。 +- [ ] 將 `evm` 加入 `ChainGatewayMap`,保持 typed family lookup。 +- [ ] 用 EVM fixtures 驗證共用 `Broadcaster`、`TxPipeline`、`Signer`、`SignStrategy` 可直接承載 EVM transaction;預期不修改 control flow。 +- [ ] 實作 EVM confirmation normalization,保持 `--wait` timeout 後回 submitted receipt 的既有契約。 +- [ ] 支援 legacy 與 EIP-1559 fee model;network-specific trait 不得被 family-wide command capability 誤判為所有 EVM network 都支援。 +- [ ] 調整 capability 註冊方式,區分「family 有這個 command」與「此 network 有 indexer/EIP-1559 等能力」。 +- [ ] 保持 watch-only 禁止簽名、wrong-family account 阻擋、dry-run 不解密私鑰等 invariant。 + +### Phase 5:EVM outbound adapters + +新增位置建議: + +```text +src/adapters/outbound/chain/evm/ +├── index.ts +├── provider.ts +├── evm.ts +├── signing-strategy.ts +├── evm-responses.ts +└── history-reader.ts # 僅在決定支援 history/indexer 時加入 +``` + +工作清單: + +- [ ] 實作 per-network EVM JSON-RPC client/gateway。 +- [ ] 實作 native balance、nonce/code、block、transaction、receipt 與 fee/gas reads。 +- [ ] 實作 native/ERC-20 transfer、contract call/send/deploy、estimate 與 raw transaction broadcast。 +- [ ] 實作 software transaction signing 與 personal-message signing。 +- [ ] 所有 RPC response 先驗證再 normalize,避免將 provider-specific shape 傳入 use case。 +- [ ] 統一處理 revert reason、replacement/nonce、insufficient funds、underpriced fee、chain mismatch、timeout 等錯誤。 +- [ ] 如支援 account history/ABI metadata,新增獨立 explorer/indexer adapter,不假裝標準 JSON-RPC 能提供。 +- [ ] 加入 EVM adapter unit tests,mock transport,不依賴公開 RPC。 + +### Phase 6:Ledger EVM adapter + +涉及位置: + +- `src/adapters/outbound/ledger/index.ts` 或拆成 family-specific device adapters +- `src/application/ports/ledger-device.ts` +- `src/application/services/signer/ledger.ts` +- `src/application/services/ledger-account.ts` +- `src/adapters/inbound/cli/commands/wallet.ts` +- `src/bootstrap/composition.ts` +- package dependencies 與 tsup bundling 設定 + +工作清單: + +- [ ] 加入 Ethereum Ledger app transport、address、transaction signing 與 message signing。 +- [ ] EVM derivation path 使用 coin type 60,並支援 index/path/address scan 的既有流程。 +- [ ] `import ledger --app ethereum` 出現在 schema、help、interactive choices 與 tests。 +- [ ] precheck 比對裝置 address 與 cached address。 +- [ ] 分類 user rejection、wrong app、locked device、wrong seed 與 transport error。 +- [ ] 更新 tsup `noExternal`/native addon 設定與 Ledger emulator/實機驗證。 + +### Phase 7:EVM application use cases + +新增位置建議: + +```text +src/application/use-cases/evm/ +├── account-service.ts +├── token-service.ts +├── transaction-service.ts +├── contract-service.ts +└── block-service.ts +``` + +工作清單: + +- [ ] 實作 EVM account balance/info/portfolio;history 依第 2 節決策處理。 +- [ ] 實作 ERC-20 metadata、balance 與 token book workflows。 +- [ ] 實作 native/ERC-20 send、signed raw tx broadcast、status/info。 +- [ ] 實作 contract call/send/deploy/info 的既定第一版語意。 +- [ ] 實作 EVM block query。 +- [ ] 重用 `MessageService`、`TxPipeline`、`TransactionMode`、token repository 與 price port;不要重用帶 TRON 語意的 use case。 +- [ ] 所有回傳 shape 使用 family-aware、可穩定輸出的 normalized view。 + +### Phase 8:EVM CLI command module + +新增位置建議: + +```text +src/adapters/inbound/cli/commands/evm/ +├── index.ts +├── account.ts +├── token.ts +├── tx.ts +├── contract.ts +├── message.ts +└── block.ts +``` + +涉及的共用位置: + +- `src/adapters/inbound/cli/commands/shared.ts` +- `src/adapters/inbound/cli/schemas/index.ts` +- `src/adapters/inbound/cli/arity/index.ts` +- `src/adapters/inbound/cli/registry/index.ts` +- `src/adapters/inbound/cli/shell/index.ts` +- `src/adapters/inbound/cli/context/index.ts` +- `src/adapters/inbound/cli/command-id.ts` + +工作清單: + +- [ ] 每個 EVM command 登記 `family: "evm"`、logical path、capability、requirements、Zod fields、examples 與 formatter。 +- [ ] EVM address、hash、hex data、ABI、quantity、gas、fee、nonce、block identifier 使用 EVM-specific schema。 +- [ ] `tx send` 同時處理 native 與 ERC-20,但不暴露 TRC10/TRC20 flags。 +- [ ] legacy/EIP-1559 的 command flags、互斥條件與 defaults 由單一 schema 驅動 help 與 JSON Schema。 +- [ ] EVM 不註冊 `stake` commands。 +- [ ] logical routing 由 `--network evm:` 選到 EVM implementation;TRON/EVM 同 path 不互相污染 fields 或 examples。 +- [ ] command id 穩定為 `evm.`,例如 `evm.tx.send`。 + +### Phase 9:Bootstrap 與 family composition + +涉及位置: + +- `src/bootstrap/families/evm.ts`(新增) +- `src/bootstrap/families/types.ts` +- `src/bootstrap/family-registry.ts` +- `src/bootstrap/composition.ts` +- `src/bootstrap/runner.test.ts` +- `src/adapters/outbound/chain/tron/provider.ts`(可改名為 family-neutral gateway registry 位置) + +工作清單: + +- [ ] 建立 `evmFamily` plugin:meta、gateway factory、sign strategy、use cases、command module。 +- [ ] 將 `evmFamily` 加入 `FAMILY_REGISTRY`。 +- [ ] `familyMap()` 對 TRON/EVM factories 與 signing strategies 都完整。 +- [ ] gateway cache 仍以 canonical network id 隔離,不共用不同 chain 的 client。 +- [ ] capability composition 依 family command + per-network traits 正確產生。 +- [ ] bootstrap tests 期望 enabled families 為 `tron`、`evm`,並驗證兩者 command registration。 + +### Phase 10:Help、discovery 與 machine catalog + +這一階段是公開 EVM 支援的必要條件,不可視為文件收尾。 + +涉及位置: + +- `src/adapters/inbound/cli/help/index.ts` +- `src/adapters/inbound/cli/help/catalog.ts` +- `src/adapters/inbound/cli/globals/index.ts` +- `src/adapters/inbound/cli/registry/index.ts` +- `src/adapters/inbound/cli/commands/network.ts` +- help/golden tests 與 baselines + +必須支援並測試: + +- [ ] `wallet-cli --help` + - 顯示支援 families:TRON、EVM。 + - 說明 command implementation 由 `--network` 選擇。 + - 至少有一個 `--network evm:1` 範例。 + - `stake` 明確標示 TRON-only。 +- [ ] `wallet-cli evm --help` + - 顯示 EVM 可用 command tree,不出現 `stake`。 + - 如果 `evm` prefix 只用於 help/catalog discovery,而不能用於一般執行,必須在輸出中明說。 +- [ ] `wallet-cli evm tx send --help` + - 只顯示 EVM fields、EVM examples、fee model 說明與 EVM address 格式。 +- [ ] `wallet-cli tx send --help` + - merged logical help 必須清楚標示 family-specific flags/examples,不能只拿 registry 第一個 family 的 metadata。 +- [ ] `wallet-cli tx send --network evm:1 --help` + - meta parsing 必須正確消耗 `--network` value,並解析成 EVM help;不得把 `evm:1` 當 command positional。 +- [ ] `wallet-cli networks --help` + - 說明 canonical id `evm:`、aliases 與 custom network 來源。 +- [ ] `wallet-cli --json-schema` + - 完整 catalog 包含 `evm.*` commands。 + - catalog 頂層建議增加 enabled families 與可用 networks 摘要。 +- [ ] `wallet-cli evm --json-schema` + - 只輸出 EVM chain commands,schema 與 examples 不含 TRON-only flags。 +- [ ] 每個 EVM leaf 的 `--json-schema` + - input schema、requires、capability、examples 與 stdin flags 正確。 +- [ ] global `--network` description + - 範例至少包含 `tron:nile`、`evm:1`、alias 與 config fallback。 +- [ ] unknown/disabled family、unknown EVM network、family/network mismatch 都輸出明確 usage error 與 exit 2。 + +### Phase 11:Text 與 JSON output + +涉及位置: + +- `src/adapters/inbound/cli/render/index.ts` +- `src/adapters/inbound/cli/render/scalars.ts` +- `src/adapters/inbound/cli/output/envelope.ts` +- `src/adapters/inbound/cli/contracts/envelope.ts` +- formatter/envelope/golden tests + +工作清單: + +- [ ] 在 `FAMILY_RENDER` 加入 EVM hooks。 +- [ ] native amount 依 network `nativeCurrency` 顯示,不假設所有 EVM network 都是 ETH。 +- [ ] EVM transaction info/receipt 顯示 hash、from/to/value、nonce、gas、fee、status、block 與 contract address。 +- [ ] legacy 與 EIP-1559 fee text 都能正確呈現。 +- [ ] wallet/list/current/import/derive 顯示 TRON 與 EVM address,且 address 不被錯誤縮寫或標錯 family。 +- [ ] `networks` text table 顯示 EVM chain id、native symbol 與 fee model。 +- [ ] JSON envelope 保持 `wallet-cli.result.v1`,並輸出: + - `command: "evm...."` + - `chain.family: "evm"` + - `chain.networkId: "evm:"` + - `chain.chainId: ""` +- [ ] 所有 wei、gas、fee、nonce 與 block quantity 避免 JavaScript number precision loss;JSON 中使用穩定字串規則。 +- [ ] error、warning、progress 仍遵守 stdout/stderr 與單一 terminal frame 契約。 + +### Phase 12:Token book 與 price provider + +涉及位置: + +- `src/adapters/outbound/tokenbook/builtins.ts` +- `src/adapters/outbound/tokenbook/index.ts` +- `src/application/ports/token-repository.ts` +- `src/adapters/outbound/price/coingecko.ts` +- `src/application/ports/price-provider.ts` + +工作清單: + +- [ ] 為選定 builtin EVM networks 加入官方 ERC-20 token entries;測試網可維持空清單。 +- [ ] ERC-20 contract id 使用一致的 normalized/checksummed comparison,避免大小寫重複。 +- [ ] 確認 token book scope 仍為 `(networkId, accountRef)`,不同 EVM chain 不共用清單。 +- [ ] CoinGecko native coin id 與 asset platform 不可只用 `evm:` prefix 推導;必須按實際 network mapping/config 決定。 +- [ ] custom EVM network 沒有 price mapping 時回 null/warning,不讓 portfolio command 失敗。 +- [ ] token price lookup、official/user merge、remove protection 與 portfolio tests 涵蓋 EVM。 + +### Phase 13:測試與品質門檻 + +#### Unit tests + +- [ ] EVM address derive/validate/checksum。 +- [ ] BIP44 coin type 60 與 seed/private-key address derivation。 +- [ ] network descriptor validation、canonical id、arbitrary chain id、aliases、RPC chain mismatch。 +- [ ] wallet migration、backfill、dedup、watch/Ledger family pinning。 +- [ ] EVM gateway RPC normalization 與 typed errors。 +- [ ] software/Ledger transaction與 message signing。 +- [ ] legacy/EIP-1559 transaction build、estimate、broadcast、confirmation。 +- [ ] ERC-20、contract、block、account 與 portfolio use cases。 +- [ ] EVM commands、registry routing、target/capability gates、renderers、envelopes。 + +#### CLI/golden tests + +- [ ] root/family/group/leaf `--help`。 +- [ ] root/family/leaf `--json-schema`。 +- [ ] `networks` text + JSON 包含 `evm:1` 與 custom `evm:31337`。 +- [ ] `config defaultNetwork evm:1` 與 alias round trip。 +- [ ] `--network evm:1` 路由成 `evm.*` command id。 +- [ ] 同一 logical command 在 TRON/EVM 下得到不同 schema、client 與 output。 +- [ ] wrong-family account、unknown chain、alias collision、unsupported network trait。 +- [ ] JSON one-frame、exit `0/1/2`、stderr progress、secret redaction。 +- [ ] 舊 TRON golden tests 全部維持通過;root help 的預期輸出改用新的 multichain baseline。 + +#### Integration/live tests + +- [ ] 新增本機 EVM suite,建議使用 Anvil,覆蓋 account、native/ERC-20 send、contract、block、sign-only、broadcast、`--wait`。 +- [ ] 新增公開 EVM testnet smoke suite,使用隔離 wallet home 與秘密來源,不記錄 private key。 +- [ ] 保留 Nile live suite,確認 EVM 變更沒有造成 TRON regression。 +- [ ] 如支援 Ledger EVM,加入 Speculos 或實機 smoke tests。 + +#### Required commands + +```bash +npm run typecheck +npm run depcruise +npm test +npm run build +npm run test:parity:help +npm run test:live:nile +# 新增:EVM local integration suite +# 新增:EVM public-testnet smoke suite +``` + +### Phase 14:使用者文件與發布 + +涉及位置: + +- `README.md` +- `docs/architecture.md` +- network/config 範例文件 +- command/help baselines +- release notes 與 migration notes + +工作清單: + +- [ ] README 改為 TRON + EVM,加入 `evm:1`、custom chain、wallet 與 send 範例。 +- [ ] 架構圖與 family extension 章節標記 EVM 已實作,不再寫成未來項目。 +- [ ] 文件列出 builtin networks、canonical id、aliases、custom network schema 與 defaultNetwork 設定方式。 +- [ ] 說明同一 wallet 的 TRON/EVM derivation path 不同,以及 watch/Ledger 為 single-family。 +- [ ] 說明 EVM history、contract metadata、price、Ledger 等選配能力及其 network requirements。 +- [ ] 提供從舊 wallets schema 升級的行為、備份建議與失敗復原方式。 +- [ ] 發布前以全新 home 與舊版 home 各跑一次 end-to-end 驗收。 + +## 4. 主要檔案異動總表 + +| Layer | 修改 | 新增 | +| --- | --- | --- | +| Domain | family、address、network、wallet、tx、token、errors | EVM codec/types(可依現有模組內聚) | +| Application contracts/ports | gateway map、ledger/transaction contracts、capabilities | `evm-gateway.ts` | +| Application services | signer、pipeline、target、capability | `evm-confirmation.ts` | +| Application use cases | shared message/wallet integration | `use-cases/evm/*` | +| Outbound adapters | config、keystore、ledger、tokenbook、price、gateway registry | `chain/evm/*` | +| Inbound CLI | schemas、shell、registry、help、render、output、wallet/network commands | `commands/evm/*` | +| Bootstrap | family types、registry、composition、tests | `families/evm.ts` | +| Tooling | dependencies、tsup、test scripts、baselines | EVM local/live scripts與 fixtures | +| Docs | README、architecture、network/config docs | migration/release notes | + +## 5. 不可接受的捷徑 + +- 不可只把 `evm` 加入 union,卻不處理舊 wallet address cache migration。 +- 不可在 `ConfigLoader` 對自訂 network 直接 type cast 而不驗證。 +- 不可將 EVM RPC methods 加進 `TronGateway` 或建立包含所有鏈方法的 universal gateway。 +- 不可讓所有 EVM networks 因為 family 有 command 就自動獲得 explorer、history 或 EIP-1559 capability。 +- 不可讓 root help、leaf help、JSON Schema 仍只顯示 TRON examples。 +- 不可硬編碼所有 EVM native currency 為 ETH。 +- 不可將 bigint fee/value 轉成不安全的 JavaScript number。 +- 不可在 RPC URL、error、verbose log、JSON envelope 或 test artifact 洩漏 API key/private key。 +- 不可因新增 EVM 而改壞 TRON command ids、JSON envelope、exit code 或 stdout/stderr discipline。 + +## 6. 最終驗收範例 + +以下行為全部成立,才可宣告 EVM 已公開支援: + +```bash +wallet-cli --help +wallet-cli evm --help +wallet-cli evm tx send --help +wallet-cli tx send --network evm:1 --help +wallet-cli --json-schema +wallet-cli evm --json-schema + +wallet-cli networks +wallet-cli config defaultNetwork evm:1 +wallet-cli account balance --network evm:1 +wallet-cli account balance --network evm:31337 +wallet-cli tx send --network evm:1 --to 0x... --amount 0.01 +wallet-cli token balance --network evm:1 --contract 0x... +wallet-cli contract call --network evm:1 --contract 0x... ... +wallet-cli block --network evm:1 +wallet-cli message sign --network evm:1 --message hello +``` + +其中 `evm:31337` 必須能由使用者 config 提供;不要求每個 chain id 都成為 builtin network。 diff --git a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.zh-TW.md b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.zh-TW.md new file mode 100644 index 000000000..2031001cc --- /dev/null +++ b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.zh-TW.md @@ -0,0 +1,721 @@ +# TypeScript Wallet CLI 架構規格(Source of Truth) + +```mermaid +flowchart LR + USER([User / Agent]):::ext + + subgraph INB["📥 inbound · CLI"] + IN["parse argv ▶
◀ render output"] + end + + subgraph CORE["Core (independently testable)"] + direction TB + APP["🎯 application
use cases · ports"] + DOM["💎 domain
pure rules"] + APP --> DOM + end + + subgraph OUTB["📤 outbound"] + OUT["Keystore · TronWeb
Ledger · CoinGecko"] + end + + USER ==>|drives| IN + IN ==>|calls| APP + APP ==>|"calls port"| OUT + OUT -.->|"implements port (dependency points inward)"| APP + IN ==>|renders result| USER + + classDef ext fill:#e8e8e8,stroke:#888,color:#333 + classDef inb fill:#d4edff,stroke:#1f78b4,color:#0b3d66 + classDef core fill:#d5f5e3,stroke:#27ae60,color:#145a32 + classDef outb fill:#fadbd8,stroke:#c0392b,color:#641e16 + class USER ext + class IN inb + class APP,DOM core + class OUT outb +``` + +> 狀態:現行唯一架構契約 +> 適用版本:`wallet-cli 0.1.x` +> 執行環境:Node.js 20+、ESM、TypeScript +> 現行鏈支援:TRON(mainnet、Nile、Shasta) + +本文件完整定義 TypeScript Wallet CLI 的系統邊界、依賴方向、composition、命令路由、application ports、錢包與交易流程、持久化、輸出及擴充規則。本文件本身即為架構與行為的唯一規格,不依賴其他設計文件才能解讀。 + +若實作與本文件不一致,變更必須同時修正其中一方,不得讓文件長期描述不存在的 abstraction。 + +--- + +## 1. 系統目標與邊界 + +### 1.1 目標 + +1. 對人員與 agent 提供同一套穩定 CLI、JSON envelope、command id 與 exit code。 +2. 以 Domain 與 Application 為核心;外部 I/O 透過 ports 和 adapters 隔離。 +3. inbound CLI 與 outbound infrastructure 是 peers,只能在 Bootstrap 組裝。 +4. chain-family 差異保留在 family plugin、family use case、gateway 與 signing strategy。 +5. 私鑰、mnemonic 與 BIP39 passphrase 加密落盤;Ledger/watch-only 不保存秘密。 +6. stdout 每次執行只產生一個終局結果;進度與診斷走 stderr。 +7. Zod schema 同時驅動驗證、yargs arity、help 與 JSON Schema。 +8. 以 dependency-cruiser、typecheck、contract tests、unit tests 與 build 防止架構和行為退化。 + +### 1.2 現行邊界 + +- 正式 `ChainFamily` 目前只有 `tron`;EVM 是已規劃但尚未公開的 family。 +- Ledger 目前只實作 TRON app。 +- Network transport 是 TRON FullNode HTTP / TronWeb;`httpEndpoint` 不是 Ethereum JSON-RPC 或 gRPC endpoint。 +- `create`、各種 `import`、`delete`、`backup` 可受控互動;其他命令缺參數時 fail fast。 +- 秘密不接受 argv 明文或一般檔案來源;只允許專屬 stdin channel 或 hidden TTY prompt。 + +--- + +## 2. 架構與依賴規則 + +### 2.1 四個架構區域 + +```mermaid +flowchart LR + BOOTSTRAP[bootstrap
程序生命週期與組裝] --> INBOUND[adapters/inbound
驅動 Application] + BOOTSTRAP --> OUTBOUND[adapters/outbound
實作 Application ports] + INBOUND --> APPLICATION[application
用例、協調、ports] + OUTBOUND --> APPLICATION + APPLICATION --> DOMAIN[domain
純規則與值] +``` + +| 區域 | 可以依賴 | 禁止依賴 | +| --- | --- | --- | +| `domain` | Node/第三方純函式庫、同區域 | `application`、`adapters`、`bootstrap` | +| `application` | `domain`、application 內部 contracts/ports | `adapters`、`bootstrap` | +| `adapters/inbound` | `application`、`domain`、inbound 內部 | `adapters/outbound`、`bootstrap` | +| `adapters/outbound` | `application` ports、`domain`、outbound 內部 | `adapters/inbound`、`bootstrap` | +| `bootstrap` | 所有區域 | 無;但只做組裝與程序生命週期 | + +以上是概念依賴規則。即使 type-only import 不一定產生 runtime edge,也必須遵守同一方向。循環依賴一律禁止。 + +下圖是同一套規則的詳細視圖(dependency view)。**實線是執行期呼叫方向(由左至右);虛線是編譯期依賴/實作方向(一律指向核心)**。兩者方向相反正是依賴反轉的具體呈現:application 呼叫 outbound(往右),但 outbound 依賴 application 的 port(往左)。本圖描繪職責與依賴,不是程序執行順序;真實 runtime 入口/出口由 `bootstrap/runner.ts` 包裝(見 §3.1)。 + +```mermaid +flowchart LR + USER([User / Agent]):::ext + + subgraph INB["inbound · CLI (driving side)"] + direction TB + IN_PARSE["Controller
shell · arity · Zod schemas"] + IN_CMD["commands
argv to use case"] + IN_OUT["Presenter
envelope · render · stream"] + IN_PARSE --> IN_CMD --> IN_OUT + end + + subgraph CORE["Core (independently testable)"] + direction TB + subgraph APP["application"] + direction TB + UC["use-cases
TronTransactionService · WalletService"] + SVC["services
TxPipeline · SignerResolver · Target"] + CON["contracts
ExecutionPolicy · TransactionScope"] + PORT{{"ports (owned by application)
WalletRepository · TronGateway · LedgerDevice"}} + UC --> SVC + UC -.in/out.-> CON + UC --> PORT + SVC --> PORT + end + subgraph DOM["domain (pure rules · zero I/O)"] + DM["address · amounts · derivation
wallet · family · errors"] + end + APP --> DOM + end + + subgraph OUTB["outbound (implements ports)"] + direction TB + O_KS["keystore to WalletRepository"] + O_TRON["chain/tron to TronGateway"] + O_PRICE["price to PriceProvider"] + O_LED["ledger to LedgerDevice"] + O_CFG["config · persistence · tokenbook"] + end + + subgraph EXT["Frameworks & Drivers"] + E["TRON nodes · filesystem
Ledger · CoinGecko"] + end + + %% call direction (runtime, solid) — L→R spine + USER ==>|drives| IN_PARSE + IN_CMD ==>|calls| UC + PORT ==>|resolved to adapter| OUTB + OUTB ==>|I/O| EXT + IN_OUT -. result .-> USER + + %% dependency / implements (compile-time, dashed, points inward) + O_KS -.->|implements| PORT + O_TRON -.->|implements| PORT + O_PRICE -.->|implements| PORT + O_LED -.->|implements| PORT + O_CFG -.->|implements| PORT + + %% bootstrap: bottom rail, injects upward + subgraph BOOT["bootstrap (single composition root)"] + direction LR + COMP["composition.ts
new + inject"] + PLUG["family-registry
FamilyPlugin (TRON · EVM later)"] + end + PLUG -.-> COMP + COMP -.->|inject| UC + COMP -.->|inject| O_KS + COMP -.->|inject| O_TRON + COMP -.->|wire| IN_CMD + + classDef ext fill:#e8e8e8,stroke:#888,color:#333 + classDef boot fill:#fff3cd,stroke:#d4a017,color:#5c4500 + classDef inb fill:#d4edff,stroke:#1f78b4,color:#0b3d66 + classDef app fill:#d5f5e3,stroke:#27ae60,color:#145a32 + classDef dom fill:#fdebd0,stroke:#e67e22,color:#7e3f0b + classDef outb fill:#fadbd8,stroke:#c0392b,color:#641e16 + + class USER,E ext + class COMP,PLUG boot + class IN_PARSE,IN_CMD,IN_OUT inb + class UC,SVC,CON,PORT app + class DM dom + class O_KS,O_TRON,O_PRICE,O_LED,O_CFG outb +``` + +### 2.2 為什麼 inbound 與 outbound 不互相依賴 + +CLI command 不應知道 Keystore、TronWeb、CoinGecko 或 Ledger transport;它只呼叫 use case。Outbound adapter 也不應知道 Zod、yargs、CLI envelope 或 renderer;它只實作 application port。兩者只在 `bootstrap/composition.ts` 被注入同一個 object graph。 + +### 2.3 實際目錄責任 + +```text +src/ +├── index.ts # process entry +├── bootstrap/ +│ ├── argv.ts # yargs 前的 global/secret flags scan +│ ├── runner.ts # invocation lifecycle + terminal error funnel +│ ├── composition.ts # 唯一一般 composition root +│ ├── family-registry.ts # enabled family plugins 與 familyMap +│ └── families/ +│ ├── types.ts # FamilyPlugin contract +│ └── tron.ts # TRON gateway/use cases/commands 組裝 +├── domain/ +│ ├── address/ amounts/ derivation/# 純 value rules +│ ├── errors/ # typed errors + exit semantics +│ ├── family/ resources/ sources/ # exhaustive facts registries +│ ├── types/ # domain data shapes +│ └── wallet/ # account refs、address projections、vault codec +├── application/ +│ ├── contracts/ # execution policy/scope/progress +│ ├── ports/ # 所需外部能力 +│ ├── services/ # target/capability/signer/pipeline/confirmation +│ └── use-cases/ # wallet/config/message/TRON workflows +└── adapters/ + ├── inbound/cli/ + │ ├── commands/ # schema + use-case translation + │ ├── contracts/ context/ # CLI-only command/runtime contracts + │ ├── globals/ arity/ schemas/ # flag single source + Zod projections + │ ├── shell/ registry/ help/ # routing and discovery + │ ├── input/ # secret + prompt + │ └── output/ render/ stream/ # terminal presentation + └── outbound/ + ├── chain/tron/ # gateway、history、signing strategy + ├── config/ keystore/ # config 與 wallet persistence + ├── ledger/ # device adapter + ├── persistence/ # crypto、atomic FS、backup writer + ├── tokenbook/ # TokenRepository + └── price/ # PriceProvider +``` + +--- + +## 3. 啟動、組裝與程序生命週期 + +### 3.1 啟動流程 + +```mermaid +flowchart LR + ARGV[process.argv] --> PRE[parseGlobals] + PRE --> COMPOSE[composeCliRuntime] + COMPOSE --> META{help/version/schema
或 bare invocation?} + META -->|yes| HELP[HelpService] + META -->|no| SHELL[buildCli + parseAsync] + HELP --> FUNNEL[Runner terminal boundary] + SHELL --> FUNNEL + FUNNEL --> CLOSE[close Prompter] +``` + +1. `src/index.ts` 只呼叫 `main(process.argv)` 並設定 `process.exitCode`,不呼叫 `process.exit()`。 +2. `bootstrap/argv.ts` 在 yargs 前掃描 globals,因為 output mode 與 secret source 必須先決定。 +3. `composeCliRuntime()` 載入 config,建立 streams、formatter、outbound adapters、application services/use cases、command registry 與 target/capability gates。 +4. `FAMILY_REGISTRY` 將每個 family 的 metadata、sign strategy、gateway factory 與 command module 組成 plugin。 +5. family plugin 建立 family-specific use cases,再把它們注入 inbound `ChainModule`。 +6. command-backed capabilities 從 registry 的 `capability` 欄位推導,與 network traits 合併。 +7. meta request 在建立 yargs execution 前短路,但使用相同 streams 與錯誤輸出規則。 +8. Runner 捕捉所有 typed/unknown errors、正規化、輸出、決定 exit code,最後關閉 `/dev/tty` handle。 + +### 3.2 `FamilyPlugin` 契約 + +```ts +interface FamilyPlugin { + readonly meta: FamilyMeta & { family: F } + readonly signStrategy: SignStrategy + createGateway(network: NetworkDescriptor): ChainGatewayMap[F] + createModule(deps: FamilyApplicationDependencies): ChainModule +} +``` + +`bootstrap/families/tron.ts` 是 TRON 的具體 composition:建立 `TronRpcClient`、TronGrid history reader、TRON use cases 與 `TronModule`。Application 與 adapters 不得反向 import family registry。 + +--- + +## 4. 命令契約與 Dispatch + +### 4.1 `CommandDefinition` + +`CommandDefinition` 是 inbound CLI adapter 的契約,不是 Domain/Application model。 + +| 欄位 | 契約 | +| --- | --- | +| `path` | 中立命令使用完整 path;chain 命令使用跨 family logical path。 | +| `family` | 缺省為中立命令;存在時由 resolved network 選 family implementation。 | +| `stdin` | `privateKey`、`mnemonic`、`tx`、`message` 專屬 stdin channel。 | +| `network` | `none`、`optional`、`required`;現行 optional/required 均可 fallback default network。 | +| `wallet` | `none` 或 `optional`;optional 可用 `--account` 覆寫 active。 | +| `auth` | help/catalog 的解鎖宣告;實際軟體簽名採 lazy decrypt。 | +| `broadcasts` | 控制 help 是否揭露 `--wait`。 | +| `passwordMode` | `establish` 或 `verify`,控制互動式 master password priming。 | +| `interactive` | 只有明確 opt-in 的命令可開啟 TTY prompt。 | +| `capability` | 執行前需要通過的 per-network capability。 | +| `fields` / `input` | Zod field metadata 與完整 validation schema。 | +| `run` | 把 CLI input/context 轉譯成 use-case 呼叫,回傳 structured data。 | +| `formatText` | 可選 text renderer;JSON 不使用它。 | + +Stable command id 由 metadata 推導:中立命令是 `path.join(".")`,例如 `import.mnemonic`;chain 命令是 `family.path`,例如 `tron.tx.send`。 + +### 4.2 兩類命令與路由 + +```mermaid +flowchart LR + PATH[Parsed path] --> KIND{neutral exact match?} + KIND -->|yes| NEUTRAL[resolveNeutral] + KIND -->|no| CAND[resolveCandidates] + CAND --> NET[resolve explicit/default network] + NET --> FAMILY[choose candidate by network.family] + NEUTRAL --> EXEC[common executeCommand] + FAMILY --> EXEC +``` + +- `tron` 不是一般執行命令的 public prefix;`--network` 決定 family。 +- Help/JSON Schema 可用 family prefix 精確定址具體 implementation。 +- 未知 top-level/subcommand/flag 必須回 `unknown_command` 或 `invalid_option`,不可由 yargs 靜默成功。 + +### 4.3 Dispatch 固定順序 + +```mermaid +flowchart LR + ROUTE[Route] --> FLAGS[Reject unknown flags] + FLAGS --> TARGET[Resolve target] + TARGET --> CAP[Capability gate] + CAP --> PASSWORD[Optional password prime] + PASSWORD --> GAP[Optional TTY gap-fill] + GAP --> ZOD[Zod parse] + ZOD --> CTX[Build ExecutionContext] + CTX --> ACCOUNT[Resolve account if wallet-bound] + ACCOUNT --> RUN[Command → use case] + RUN --> FORMAT[Text or JSON] + FORMAT --> RESULT[Stream result exactly once] +``` + +`ExecutionContext` 是 CLI context;Application workflow 只接收較窄的 `ExecutionPolicy`、`ExecutionSelection`、`AccountScope` 或 `TransactionScope`,不依賴 CLI streams/config/envelope 全貌。 + +--- + +## 5. 公開命令面 + +```text +wallet-cli +├── create +├── import mnemonic | private-key | ledger | watch +├── list | use | current | rename | derive | backup | delete +├── config | networks +├── account balance | info | history | portfolio +├── token balance | info | add | list | remove +├── tx send | broadcast | status | info +├── contract call | send | deploy | info +├── stake freeze | unfreeze | withdraw | cancel-unfreeze | delegate | undelegate +├── message sign +└── block [number] +``` + +中立命令不連鏈。Chain commands 目前全部由 TRON plugin 提供。所有建立 transaction 的命令共同支援: + +- `--dry-run`:build + estimate,不解密、不簽名、不廣播。 +- `--sign-only`:build + estimate + sign,回傳 signed transaction。 +- 無 mode flag:sign + broadcast。 +- `--wait`:僅在 broadcast 後等待 confirmation。 + +### 5.1 Global flags + +| Flag | Runtime 語意 | +| --- | --- | +| `--output` / `-o` | `text` 或 `json`;預設取 config。 | +| `--network` | network id/alias;chain command 省略時取 `defaultNetwork`。 | +| `--account` | account ref/label/address;只覆寫本次執行。 | +| `--timeout` | 單次 RPC/device operation timeout。 | +| `--verbose` / `-v` | 額外 diagnostic。 | +| `--wait` | broadcast 後輪詢 confirmation。 | +| `--wait-timeout` | confirmation polling 上限,預設 60000 ms。 | +| `--password-stdin` | master password 從 fd 0 讀取。 | +| `--help` / `--version` / `--json-schema` | meta requests。 | + +Global flags 的唯一登記點是 `adapters/inbound/cli/globals/GLOBAL_FLAG_SPECS`;argv scan、yargs options 與 help/catalog 都由它投影。 + +--- + +## 6. Domain 模型 + +### 6.1 Wallet、Account 與 Source + +```mermaid +flowchart LR + WALLET[Wallet wlt_x] --> SOURCE[One Source] + SOURCE -->|seed| HD[wlt_x.0 / .1 / ...] + SOURCE -->|privateKey| PK[one account wlt_x] + SOURCE -->|ledger| LEDGER[one single-family account] + SOURCE -->|watch| WATCH[one single-family account] +``` + +```ts +type Source = + | { type: "seed"; vaultId: string; addresses: Record } + | { type: "privateKey"; keyId: string; addresses: ChainAddresses } + | { type: "ledger"; family: ChainFamily; path: string; address: string } + | { type: "watch"; family: ChainFamily; address: string } +``` + +| Source | HD | 本地秘密 | Family 範圍 | 簽名 | +| --- | --- | --- | --- | --- | +| seed | 是 | encrypted entropy/passphrase | 所有 enabled families | software | +| privateKey | 否 | encrypted raw key | 所有 enabled families | software | +| ledger | 否 | 無 | 單一 family/path | device | +| watch | 否 | 無 | 單一 family | 禁止 | + +Account 是選擇與操作單位。`--account` 可接受 canonical ref、唯一 label 或唯一 address;多 account seed 若只給 wallet ref,不得猜測 index。 + +### 6.2 Derivation 與地址 + +- BIP39 English wordlist;`create` 產生 128-bit entropy(12 words)。 +- HD path:`m/44'/{coinType}'/{account}'/0/0`;TRON coin type 為 195。 +- secp256k1 使用 uncompressed 65-byte public key 推導地址。 +- Seed vault 保存 encrypted entropy 與 optional BIP39 passphrase,不直接保存 mnemonic 字串。 +- 公開 address cache 位於 wallet metadata;read/build/estimate 不需解密秘密。 +- Domain `family`、`sources`、`resources` registries 必須 exhaustively keyed;新增 union member 時由型別系統迫使相關 facts 補齊。 + +### 6.3 Active account + +- 第一個註冊 account 自動成為 active。 +- `use` 持久修改 `activeAccount`;`--account` 不持久化。 +- 刪除 active account 時選第一個剩餘 account,沒有則設 `null`。 +- `current` 只回持久 active account。 + +--- + +## 7. Application:用例、Services 與 Ports + +### 7.1 Ports + +Application 定義能力,不定義具體技術: + +| Port | 用途 | 現行 adapter | +| --- | --- | --- | +| `WalletRepository` / `AccountStore` | wallet/account query、mutation、decrypt | `Keystore` | +| `BackupWriter` | 安全寫出 plaintext backup | `SecureBackupWriter` | +| `ConfigDocumentRepository` | config document 原子更新 | `YamlConfigDocument` | +| `NetworkRegistry` | network id/alias/default resolution | outbound config registry | +| `LedgerDevice` | address、tx/message signing、app config | `Ledger` | +| `ChainGatewayProvider` | 依 network/family 取得 gateway | `ChainGatewayRegistry` | +| `TronGateway` | TRON reads/build/estimate/broadcast | `TronRpcClient` | +| `TronHistoryReader` | TronGrid transaction history | `TronGridHistoryReader` | +| `TokenRepository` | official/user token book | `TokenBook` | +| `PriceProvider` | best-effort USD price | CoinGecko/Null provider | +| `PromptPort` | application 需要的最小互動能力 | inbound Prompter | + +`PromptPort` 是少數由 inbound adapter 實作、Application 消費的 port;這不改變依賴方向,因為 Application 只擁有 interface。 + +### 7.2 Use cases + +- `WalletService`:create/import/list/use/current/rename/derive/delete/backup,不知道 JSON/Zod/yargs。 +- `ConfigService`:effective config view、key validation、canonical network normalization 與 document update。 +- `MessageService`:依 signer port 簽訊息。 +- TRON use cases:account、token、transaction、contract、stake、block;只使用 TRON gateway 與必要的 shared ports。 + +Inbound command 的責任是把 argv/Zod input 和 `ExecutionContext` 轉成 use-case input,再選擇 stable output view;不得自行做 persistence 或 provider transport。 + +### 7.3 Reusable services + +- `TargetResolver`:network selection 與 single-family account compatibility。 +- `CapabilityRegistry`:per-network feature gate。 +- `SignerResolver`:source → software/device signer。 +- `TxPipeline`:共用 build/estimate/sign/broadcast lifecycle。 +- `transactionMode`:`dryRun`/`signOnly`/broadcast mode 判定。 +- `tronConfirmation`:TRON-specific polling/receipt normalization,未塞入 generic pipeline。 + +--- + +## 8. Network、Gateway 與 Capability + +目前 descriptor: + +```ts +interface TronNetworkDescriptor { + id: string + family: "tron" + chainId: string + aliases: string[] + httpEndpoint?: string + feeModel?: "tron-resource" + capabilities: string[] +} +``` + +| ID | Alias | Endpoint | +| --- | --- | --- | +| `tron:mainnet` | `tron` | `https://api.trongrid.io` | +| `tron:nile` | `nile` | `https://nile.trongrid.io` | +| `tron:shasta` | `shasta` | `https://api.shasta.trongrid.io` | + +解析不分大小寫;ambiguous alias 必須失敗。`network: optional/required` 都會在未明示時採 `config.defaultNetwork`。Ledger/watch pin 單一 family,family mismatch 必須在 RPC 前失敗。 + +`ChainGatewayRegistry` 由 Bootstrap 注入 family factory,以 network id cache client。它的 generic `client()` 只能使用真正共有的最小能力;family use case 透過 guarded `get(net, "tron")` 取得 `TronGateway`。不得把 TRON staking 與未來 EVM gas/nonce 硬塞進 universal gateway。 + +Capabilities 由兩部分組成:registered commands 宣告的 command-backed keys,加上 `NetworkDescriptor.capabilities` 的 network traits。Gate 必須發生在 use case 前。 + +--- + +## 9. Signer 與交易流程 + +### 9.1 Signer resolution + +```mermaid +flowchart LR + REF[account ref] --> SOURCE{source.type} + SOURCE -->|seed| SEED[lazy decrypt vault + derive] + SOURCE -->|privateKey| KEY[lazy decrypt key] + SOURCE -->|ledger| DEVICE[Ledger signer + path] + SOURCE -->|watch| REJECT[watch_only_no_signer] + SEED --> SOFT[SoftwareSigner + family strategy] + KEY --> SOFT +``` + +Software signer 僅在實際 `sign()` 時取 key;dry-run 不觸發解密。Ledger signer 在簽名前驗證 app/address,cached address 與裝置不符回 `wrong_device_seed`。 + +### 9.2 Pipeline + +```mermaid +flowchart LR + RESOLVE[resolve signer] --> BUILD[build + timeout] + BUILD --> EST[estimate + timeout] + EST --> MODE{mode} + MODE -->|dry-run| PLAN[plan] + MODE -->|sign| SIGN[software / Ledger sign] + SIGN --> CAST{broadcast?} + CAST -->|no| SIGNED[signed] + CAST -->|yes| SEND[broadcast] + SEND --> WAIT{--wait?} + WAIT -->|no| SUB[submitted] + WAIT -->|yes| CONFIRM[family confirmation] + CONFIRM --> FINAL[confirmed / failed
或 timeout → submitted] +``` + +Pipeline 只知道 signer 與 `Broadcaster` port;family use case 提供 build、estimate、confirm callbacks。`timeoutMs` 限制單次 operation;`waitTimeoutMs` 限制 confirmation polling。交易已廣播後,polling error/timeout 不得把命令改判為未廣播,而是回 `submitted`。 + +### 9.3 Ledger + +- `SPECULOS_PORT` 存在時使用 Speculos HTTP;否則 USB/HID。 +- transports 與 `hw-app-trx` lazy import,每次 operation 後關閉。 +- Ledger path 傳給 app 前移除 `m/`。 +- APDU `0x6985` → `signing_rejected`;裝置/app/transport 不可用 → `auth_required`。 + +--- + +## 10. 持久化與密碼學 + +### 10.1 Root 與檔案 + +Root 依序使用非空 `WALLET_CLI_HOME`,否則 `$HOME/.wallet-cli`。 + +```text +/ +├── config.yaml +├── wallets.json +├── tokens.json +├── verifier.json +├── vaults/vlt_.json +├── keys/key_.json +└── backups/-.json +``` + +`AtomicFileStore` writes 使用同目錄 unique temp file、mode `0600`、atomic rename。Mutation 以 `.lock` + `O_EXCL` 序列化;死 PID/stale lock 可回收。 + +### 10.2 `wallets.json` + +```json +{ + "version": 1, + "activeAccount": "wlt_abcd1234.0", + "wallets": [{ + "id": "wlt_abcd1234", + "source": { + "type": "seed", + "vaultId": "vlt_efgh5678", + "addresses": { "0": { "tron": "T..." } } + } + }], + "labels": { "wlt_abcd1234.0": "main" } +} +``` + +IDs 是隨機 5-byte Crockford base32 小寫字串。Labels case-insensitive unique 且不可用 `wlt_` 開頭。Seed known indices 等於 `addresses` keys;Ledger/watch 依 source identity dedup,不與相同地址的 software wallet 合併。 + +### 10.3 Token 與 Config + +`tokens.json` 的 user entries 以 `|` 分區;effective list 是 official 先、user-only 後,依 `(kind,id)` 去重。Official entries 不可刪除/覆蓋。 + +`config.yaml` 與 builtin config shallow merge。可寫 keys 只有 `defaultNetwork`、`defaultOutput`、`timeoutMs`;`networks` 是 CLI read-only view。Runtime globals 不寫回 config。 + +### 10.4 Encrypted blobs + +`verifier.json`、vaults 與 keys 使用 scrypt(N=262144、r=8、p=1、dkLen=32)、AES-128-CTR 與 `keccak256(derivedKey[16..31] + ciphertext)` MAC。每個 blob 有獨立 32-byte salt 與 16-byte IV,但共用 keystore master password。MAC 不符回 `auth_failed`;密碼永不落盤。 + +Backup 只允許 seed/private-key,plaintext secret file 必須為 `0600` 且不覆寫既有檔案;terminal/envelope 只回 metadata,不回秘密。 + +--- + +## 11. Secret 與互動政策 + +```mermaid +flowchart LR + NEED[Secret needed] --> SOURCE{source} + SOURCE -->|--kind-stdin| STDIN[fd 0 once] + SOURCE -->|interactive TTY| HIDDEN[hidden prompt] + SOURCE -->|none| ERROR[missing_option / auth_required] +``` + +- Handler 不得直接讀 `process.stdin`;`StreamManager.readStdinOnce()` 每次執行最多一次。 +- 一次 invocation 只能由一種 `--*-stdin` channel 使用 fd 0。 +- 不支援 secret argv、`MASTER_PASSWORD`、`--*-file` 或一般 env secret。 +- Secret 不得進 log、diagnostic、error details、result envelope。 +- Interactive allowlist:create、四種 import、delete、backup;順序為 password → field gap-fill/account selection → command confirm。 + +--- + +## 12. 輸出、Stream 與錯誤 + +| 資料 | Text mode | JSON mode | +| --- | --- | --- | +| 成功終局結果 | stdout 一次 | stdout 一個 result envelope | +| 失敗終局結果 | stderr 一次 | stdout 一個 error envelope | +| Progress | stderr | stderr JSON event | +| Warning | stderr/收集 | `meta.warnings` | +| Debug | verbose stderr | verbose stderr | + +JSON schema 固定為 `wallet-cli.result.v1`。Chain command envelope 包含 family、network id/name、chain id;中立命令省略 chain。`bigint` 轉十進位字串,`Uint8Array` 轉 hex。第二次 terminal result 必須拋 `internal_error`。 + +Exit code:成功/meta = 0;execution error = 1;usage error = 2。未知 exception 正規化成 redacted `internal_error`,第三方錯誤原文不可進 public envelope。 + +--- + +## 13. Help 與機器可讀自省 + +支援 root/group/leaf help、version、全 catalog JSON Schema 與單一命令 JSON Schema。資料流: + +```mermaid +flowchart LR + ZOD[fields + input] --> ARITY[yargs arity] + ZOD --> HELP[help flags] + ZOD --> SCHEMA[JSON Schema] + DEF[CommandDefinition] --> HELP + DEF --> CATALOG[machine catalog] + GLOBAL[GLOBAL_FLAG_SPECS] --> ARITY & HELP & CATALOG +``` + +不得另建手工 command flag table。公開 help/output 是穩定契約;變更時必須以自動化測試驗證 root、group、leaf、JSON Schema 與 functional scenarios。 + +--- + +## 14. 新增功能的規則 + +### 14.1 新增命令 + +1. 決定 neutral 或 family logical command。 +2. Application 先建立/擴充 use case 與需要的 port。 +3. Outbound 能力用 adapter 實作 port,不讓 use case import adapter。 +4. Inbound command 定義 Zod fields/input、policy metadata、use-case translation 與 renderer。 +5. 註冊到 neutral registrar 或 family `ChainModule`。 +6. 加入 use-case、adapter、registry/dispatch、output/help tests,並更新本文件 inventory。 + +禁止 command 直接建立 TronWeb/Keystore、寫 process stdout、做 filesystem mutation,或把 provider wire response 當成 renderer 的業務模型。 + +### 14.2 新增 chain family + +```mermaid +flowchart LR + DOMAIN[1 Domain family/address/network] --> PORT[2 family gateway port] + PORT --> ADAPTER[3 gateway + signing adapters] + ADAPTER --> USECASE[4 family use cases] + USECASE --> CLI[5 family CLI module] + CLI --> PLUGIN[6 bootstrap plugin + registry] + PLUGIN --> TEST[7 routing/output/contract tests] +``` + +新增 family 必須擴充 `ChainFamily`/`FAMILIES`、discriminated network/address types、`ChainGatewayMap`、sign strategy、gateway、use cases、commands、family plugin、networks/render/tests。只有真正相同的 intent 與 I/O shape 才抽 shared port;TRON resource model 與 EVM gas/nonce 必須分離。 + +### 14.3 新增 wallet source + +同步更新 `Source` union、`SOURCE_KINDS`、import workflow、repository persistence/migration、dedup、signer resolution、cleanup、descriptor rendering 與 tests。未知 source 不可落入 silent default。 + +--- + +## 15. 必須維持的不變量 + +### 15.1 架構 + +- Domain 無外部 I/O 且不依賴上層。 +- Production Application 不 import adapters/bootstrap。 +- Inbound/Outbound adapters 互不 import。 +- `bootstrap/composition.ts` 是唯一一般 composition root;family-specific composition 在 plugins。 +- Application 擁有 ports;adapter 實作 ports。 +- 不允許循環依賴或以 type-only import 規避概念邊界。 + +### 15.2 行為與安全 + +- JSON stdout 恰好一個 terminal frame,schema 為 `wallet-cli.result.v1`。 +- Usage/execution/success exit codes 固定為 2/1/0。 +- Secret 不進 argv/env/log/envelope;stdin 每次最多一個 channel、讀一次。 +- Watch-only 永不簽名;dry-run 永不解密、簽名或廣播。 +- 所有 persistent mutation 鎖定,所有替換寫入 atomic rename。 +- 已廣播交易不因 confirmation timeout 變成 command failure。 +- Unknown exception 對使用者 redacted。 + +### 15.3 驗證門檻 + +```bash +npm run typecheck +npm run depcruise +npm test +npm run build +``` + +涉及真實 TRON behavior 時另執行隔離 wallet home 的 `npm run test:live:nile`;不得記錄或複製測試秘密。架構變更至少必須通過 typecheck、dependency-cruiser、unit tests 與 build。 + +--- + +## 16. 架構判斷準則 + +遇到歸屬爭議時依序判斷: + +1. 不含 I/O、描述業務值與不變量:Domain。 +2. 描述產品要做什麼或需要何種外部能力:Application use case/service/port。 +3. 將 terminal/argv/Zod 轉為 application input:Inbound CLI adapter。 +4. 實作 filesystem、HTTP、device、price 等 port:Outbound adapter。 +5. 選擇具體 implementation 並連接 object graph:Bootstrap。 + +若一個模組同時 parsing argv、呼叫 provider、寫檔與 render output,代表責任尚未拆開。核心標準不是目錄名稱,而是依賴是否由外向內、外部細節是否可替換、use case 是否能只靠 ports 測試。 diff --git a/ts/package-lock.json b/ts/package-lock.json new file mode 100644 index 000000000..d01fc0be0 --- /dev/null +++ b/ts/package-lock.json @@ -0,0 +1,5143 @@ +{ + "name": "wallet-cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wallet-cli", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@ledgerhq/hw-app-trx": "^6.36.3", + "@ledgerhq/hw-transport-node-hid": "^6.33.4", + "@ledgerhq/hw-transport-node-speculos-http": "^6.36.4", + "@noble/ciphers": "^2.2.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@scure/base": "^2.2.0", + "@scure/bip32": "^2.2.0", + "@scure/bip39": "^2.2.0", + "tronweb": "^6.3.0", + "yaml": "^2.9.0", + "yargs": "^18.0.0", + "zod": "^4.4.3" + }, + "bin": { + "wallet-cli": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@types/yargs": "^17.0.35", + "dependency-cruiser": "^17.4.3", + "tsup": "^8.5.1", + "tsx": "^4.22.4", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, + "node_modules/@babel/runtime": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", + "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@ledgerhq/devices": { + "version": "8.15.1", + "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-8.15.1.tgz", + "integrity": "sha512-o4XEHiwPytnZxYUnOC5JoHX5TPDJpeMXPfXjwhsXVJ9LAbahxQWzC37Iuzk8ZY/oC2yDptzqjs/qNP2y9D9vCA==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/errors": "^6.36.0", + "@ledgerhq/logs": "^6.17.0", + "rxjs": "7.8.2", + "semver": "7.7.3" + } + }, + "node_modules/@ledgerhq/errors": { + "version": "6.36.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-6.36.0.tgz", + "integrity": "sha512-o2Q5hNvf2TzAzlH8ORAozppRbzixRPYDfmSQrP7FOcM997OEH7qDleXgp/uMpvRdxR/t3CJCG+n0i+bU/oYMKA==", + "license": "Apache-2.0" + }, + "node_modules/@ledgerhq/hw-app-trx": { + "version": "6.36.3", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-app-trx/-/hw-app-trx-6.36.3.tgz", + "integrity": "sha512-MOVRjIqX/kwjFcwy7trk99OcKDCyB70sjmqynmGV5rgZKoG+J9pBsn3q8g2FS4DSsxO1o7piCw6KXjBi3fiyDQ==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/hw-transport": "6.35.4" + } + }, + "node_modules/@ledgerhq/hw-transport": { + "version": "6.35.4", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-6.35.4.tgz", + "integrity": "sha512-FMVxiniQp0pgSIEBX9CjXYBuIAH9BTm3OcrN+/2LqkB8QBeFZdiwmO4BeN9nc2aWspe9z6kxN3SuUyouedNokA==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/devices": "8.15.1", + "@ledgerhq/errors": "^6.36.0", + "@ledgerhq/logs": "^6.17.0", + "events": "^3.3.0" + } + }, + "node_modules/@ledgerhq/hw-transport-node-hid": { + "version": "6.33.4", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid/-/hw-transport-node-hid-6.33.4.tgz", + "integrity": "sha512-/k0rH+wTpTmUifdxWuOER/dM3vPxW7RQIF0tByGC6noszVC3SGOZVcskBqJZ6xwIs1TvAHvZlEFvZWbnUJMBiw==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/devices": "8.15.1", + "@ledgerhq/errors": "^6.36.0", + "@ledgerhq/hw-transport": "6.35.4", + "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", + "@ledgerhq/logs": "^6.17.0", + "lodash": "^4.17.21", + "node-hid": "2.1.2", + "usb": "2.9.0" + } + }, + "node_modules/@ledgerhq/hw-transport-node-hid-noevents": { + "version": "6.35.4", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-6.35.4.tgz", + "integrity": "sha512-187670Uuuek9ECcT92NQm1PnDfFHT6gBmaJinatnueMLV9lk3q4IrpFZqTotr+LhPNub+YdGQFjYJImcXvYWtw==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/devices": "8.15.1", + "@ledgerhq/errors": "^6.36.0", + "@ledgerhq/hw-transport": "6.35.4", + "@ledgerhq/logs": "^6.17.0", + "node-hid": "2.1.2" + } + }, + "node_modules/@ledgerhq/hw-transport-node-speculos-http": { + "version": "6.36.4", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-speculos-http/-/hw-transport-node-speculos-http-6.36.4.tgz", + "integrity": "sha512-Ae3Z5mCZcZ3hhOhSRB2us69pjlI0ghLuIydfGR6PdxxxscPqcGXqB7RbcnABAC5cKC/0Wj4NXllssH3hcI9GCA==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/errors": "^6.36.0", + "@ledgerhq/hw-transport": "6.35.4", + "@ledgerhq/logs": "^6.17.0", + "axios": "1.13.5", + "rxjs": "7.8.2" + } + }, + "node_modules/@ledgerhq/logs": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/logs/-/logs-6.17.0.tgz", + "integrity": "sha512-yra33g5q/AU7+PwAws+GaVpQGUuxnDREjVBnviJjcaJLVKuLzI4pnj8Bd3nY3fypM5k1yZEYKEXfUuGFUjP2+w==", + "license": "Apache-2.0" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz", + "integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.2.0", + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", + "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/w3c-web-usb": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", + "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-jsx-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/acorn-jsx-walk/-/acorn-jsx-walk-2.0.0.tgz", + "integrity": "sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn-loose": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", + "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dependency-cruiser": { + "version": "17.4.3", + "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-17.4.3.tgz", + "integrity": "sha512-L4GLuAvmXevWnPCIaFfOz6eD92c+yY+pDgVqgufrLDnW3xYA799CSZQlly2r2N13nhAlnZY6VzY7Rx5pHNvk2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "8.16.0", + "acorn-jsx": "5.3.2", + "acorn-jsx-walk": "2.0.0", + "acorn-loose": "8.5.2", + "acorn-walk": "8.3.5", + "commander": "14.0.3", + "enhanced-resolve": "5.22.1", + "ignore": "7.0.5", + "interpret": "3.1.1", + "is-installed-globally": "1.0.0", + "json5": "2.2.3", + "picomatch": "4.0.4", + "prompts": "2.4.2", + "rechoir": "0.8.0", + "safe-regex": "2.1.1", + "semver": "7.8.1", + "tsconfig-paths-webpack-plugin": "4.2.0", + "watskeburt": "5.0.3" + }, + "bin": { + "depcruise": "bin/dependency-cruise.mjs", + "depcruise-baseline": "bin/depcruise-baseline.mjs", + "depcruise-fmt": "bin/depcruise-fmt.mjs", + "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs", + "dependency-cruise": "bin/dependency-cruise.mjs", + "dependency-cruiser": "bin/dependency-cruise.mjs" + }, + "engines": { + "node": "^20.12||^22||>=24" + } + }, + "node_modules/dependency-cruiser/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ethereum-cryptography": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz", + "integrity": "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "1.4.2", + "@noble/hashes": "1.4.0", + "@scure/bip32": "1.4.0", + "@scure/bip39": "1.3.0" + } + }, + "node_modules/ethereum-cryptography/node_modules/@noble/curves": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz", + "integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethereum-cryptography/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethereum-cryptography/node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethereum-cryptography/node_modules/@scure/bip32": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz", + "integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.4.0", + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethereum-cryptography/node_modules/@scure/bip39": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.3.0.tgz", + "integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers": { + "version": "6.13.5", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.13.5.tgz", + "integrity": "sha512-+knKNieu5EKRThQJWwqaJ10a6HE9sSehGeqWN65//wE7j47ZpFhKAnHB/JJFibwwg61I/koxaPsXbXpD/skNOQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-protobuf": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz", + "integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-installed-globally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-hid": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/node-hid/-/node-hid-2.1.2.tgz", + "integrity": "sha512-qhCyQqrPpP93F/6Wc/xUR7L8mAJW0Z6R7HMQV8jCHHksAxNDe/4z4Un/H9CpLOT+5K39OPyt9tIQlavxWES3lg==", + "hasInstallScript": true, + "license": "(MIT OR X11)", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^3.0.2", + "prebuild-install": "^7.1.1" + }, + "bin": { + "hid-showdevices": "src/show-devices.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tronweb": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tronweb/-/tronweb-6.3.0.tgz", + "integrity": "sha512-5CAjDO4/KfymgjKFgnXgfKKQp0xgOn8otCBYjgYAEIpLZDKNAk14Z0dDeg0UqYuceCiyMHjW7a19Rsz8EmhAOw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "7.26.10", + "axios": "1.15.0", + "bignumber.js": "9.1.2", + "ethereum-cryptography": "2.2.1", + "ethers": "6.13.5", + "eventemitter3": "5.0.1", + "google-protobuf": "3.21.4", + "semver": "7.7.1", + "validator": "13.15.23" + } + }, + "node_modules/tronweb/node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/tronweb/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/tronweb/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/usb": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/usb/-/usb-2.9.0.tgz", + "integrity": "sha512-G0I/fPgfHUzWH8xo2KkDxTTFruUWfppgSFJ+bQxz/kVY2x15EQ/XDB7dqD1G432G4gBG4jYQuF3U7j/orSs5nw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@types/w3c-web-usb": "^1.0.6", + "node-addon-api": "^6.0.0", + "node-gyp-build": "^4.5.0" + }, + "engines": { + "node": ">=10.20.0 <11.x || >=12.17.0 <13.0 || >=14.0.0" + } + }, + "node_modules/usb/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/validator": { + "version": "13.15.23", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", + "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/watskeburt": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-5.0.3.tgz", + "integrity": "sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA==", + "dev": true, + "license": "MIT", + "bin": { + "watskeburt": "dist/run-cli.js" + }, + "engines": { + "node": "^20.12||^22.13||>=24.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/ts/package.json b/ts/package.json new file mode 100644 index 000000000..c186a4462 --- /dev/null +++ b/ts/package.json @@ -0,0 +1,48 @@ +{ + "name": "wallet-cli", + "version": "0.1.0", + "description": "Multichain standard CLI wallet (TypeScript)", + "type": "module", + "bin": { + "wallet-cli": "./dist/index.js" + }, + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsup", + "dev": "tsx src/index.ts", + "typecheck": "tsc --noEmit", + "depcruise": "depcruise src", + "test": "vitest run", + "test:parity:help": "node scripts/compare-help-parity.mjs", + "test:live:nile": "npm run build && node scripts/nile-live-suite.mjs", + "test:parity:live-report": "node scripts/compare-live-report.mjs", + "test:watch": "vitest" + }, + "license": "MIT", + "dependencies": { + "@ledgerhq/hw-app-trx": "^6.36.3", + "@ledgerhq/hw-transport-node-hid": "^6.33.4", + "@ledgerhq/hw-transport-node-speculos-http": "^6.36.4", + "@noble/ciphers": "^2.2.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@scure/base": "^2.2.0", + "@scure/bip32": "^2.2.0", + "@scure/bip39": "^2.2.0", + "tronweb": "^6.3.0", + "yaml": "^2.9.0", + "yargs": "^18.0.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@types/yargs": "^17.0.35", + "dependency-cruiser": "^17.4.3", + "tsup": "^8.5.1", + "tsx": "^4.22.4", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/ts/scripts/compare-help-parity.mjs b/ts/scripts/compare-help-parity.mjs new file mode 100644 index 000000000..9e66b29b6 --- /dev/null +++ b/ts/scripts/compare-help-parity.mjs @@ -0,0 +1,63 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const baseline = join(root, "docs/baselines/nile-full-command-test-2026-06-29-run2-rawlogs.md"); +const originalEntry = resolve(root, "../ts/src/index.ts"); +const refactorEntry = join(root, "src/index.ts"); +const tsx = join(root, "node_modules/.bin/tsx"); + +const helpSection = readFileSync(baseline, "utf8").split("## 1. Wallet & account management")[0]; +const cases = [...helpSection.matchAll( + /```\n\$ wallet-cli (.*--help)\n([\s\S]*?)\n# exit=(\d+)\n```/g, +)].map((match) => ({ + invocation: match[1].trim(), + expectedStdout: `${match[2]}\n`, + expectedStatus: Number(match[3]), +})); + +if (cases.length === 0) throw new Error("baseline contains no help invocations"); + +function execute(entry, args) { + const home = mkdtempSync(join(tmpdir(), "wallet-cli-parity-")); + const result = spawnSync(tsx, [entry, ...args], { + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1", WALLET_CLI_HOME: home }, + timeout: 20_000, + }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +const failures = []; +for (const { invocation, expectedStdout, expectedStatus } of cases) { + const args = invocation.split(/\s+/); + const original = execute(originalEntry, args); + const refactor = execute(refactorEntry, args); + if ( + original.status !== refactor.status || + original.stdout !== refactor.stdout || + original.stderr !== refactor.stderr || + refactor.status !== expectedStatus || + refactor.stdout !== expectedStdout + ) { + failures.push({ invocation, original, refactor, expectedStdout, expectedStatus }); + } +} + +if (failures.length > 0) { + for (const failure of failures) { + process.stderr.write(`parity mismatch: wallet-cli ${failure.invocation}\n`); + process.stderr.write(` original exit=${failure.original.status}\n`); + process.stderr.write(` refactor exit=${failure.refactor.status}\n`); + if (failure.original.stdout !== failure.refactor.stdout) process.stderr.write(" stdout differs\n"); + if (failure.original.stderr !== failure.refactor.stderr) process.stderr.write(" stderr differs\n"); + if (failure.refactor.status !== failure.expectedStatus) process.stderr.write(" baseline exit differs\n"); + if (failure.refactor.stdout !== failure.expectedStdout) process.stderr.write(" baseline stdout differs\n"); + } + process.exitCode = 1; +} else { + process.stdout.write(`help parity passed: ${cases.length} invocations (original + raw-log baseline)\n`); +} diff --git a/ts/scripts/compare-live-report.mjs b/ts/scripts/compare-live-report.mjs new file mode 100644 index 000000000..f04d6e27a --- /dev/null +++ b/ts/scripts/compare-live-report.mjs @@ -0,0 +1,116 @@ +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const baselinePath = join(root, "docs/baselines/nile-full-command-test-2026-06-29-run2-rawlogs.md"); +const reportPath = join(root, "docs/nile-full-command-test-2026-06-29-run2-rawlogs.md"); +const privateEnvPath = resolve(root, "../ts/.private/.env.test"); + +function blocks(path) { + const markdown = readFileSync(path, "utf8"); + return [...markdown.matchAll(/```\n\$ ([^\n]+)\n([\s\S]*?)\n# exit=(\d+)\n```/g)] + .map((match) => ({ command: match[1], output: match[2], exit: Number(match[3]) })); +} + +function envelope(block) { + for (const line of block.output.split(/\r?\n/)) { + if (!line.startsWith("{")) continue; + try { + const value = JSON.parse(line); + if (value?.schema === "wallet-cli.result.v1") return value; + } catch { + // The historical baseline contains one intentionally truncated sign-only JSON line. + } + } +} + +function byCommand(items) { + const result = new Map(); + for (const block of items) { + const value = envelope(block); + if (!value) continue; + const list = result.get(value.command) ?? []; + list.push(value); + result.set(value.command, list); + } + return result; +} + +function dataKeys(values) { + return new Set(values.flatMap((value) => { + const data = value.data; + if (Array.isArray(data)) return data.length > 0 && typeof data[0] === "object" + ? Object.keys(data[0]) + : []; + return data && typeof data === "object" ? Object.keys(data) : []; + })); +} + +const baseline = blocks(baselinePath); +const actual = blocks(reportPath); +const baselineEnvelopes = byCommand(baseline); +const actualEnvelopes = byCommand(actual); +const failures = []; + +for (const [command, expected] of baselineEnvelopes) { + const observed = actualEnvelopes.get(command); + if (!observed) { + failures.push(`missing JSON command envelope: ${command}`); + continue; + } + const expectedSuccess = expected.some((value) => value.success === true); + if (expectedSuccess && !observed.some((value) => value.success === true)) { + failures.push(`no successful JSON envelope for ${command}`); + } + const expectedKeys = dataKeys(expected.filter((value) => value.success)); + const observedKeys = dataKeys(observed.filter((value) => value.success)); + for (const key of expectedKeys) { + if (!observedKeys.has(key)) failures.push(`${command} is missing baseline data key: ${key}`); + } + for (const value of observed) { + if (value.schema !== "wallet-cli.result.v1") failures.push(`${command} schema changed`); + if (!value.meta || typeof value.meta.durationMs !== "number" || !Array.isArray(value.meta.warnings)) { + failures.push(`${command} meta contract changed`); + } + if (command.startsWith("tron.") && !value.chain) failures.push(`${command} omitted chain`); + if (!command.startsWith("tron.") && value.chain) failures.push(`${command} unexpectedly has chain`); + } +} + +const expectedErrors = new Set(baseline.flatMap((block) => { + const value = envelope(block); + return value?.success === false ? [value.error?.code] : []; +}).filter(Boolean)); +const observedErrors = new Set(actual.flatMap((block) => { + const value = envelope(block); + if (value?.success === false) return [value.error?.code]; + const match = /^error \[([^\]]+)\]:/m.exec(block.output); + return match ? [match[1]] : []; +}).filter(Boolean)); +for (const code of expectedErrors) { + if (!observedErrors.has(code)) failures.push(`missing baseline error code: ${code}`); +} + +const privateValues = readFileSync(privateEnvPath, "utf8").split(/\r?\n/).flatMap((line) => { + const at = line.indexOf("="); + if (at < 1) return []; + return [line.slice(at + 1).trim().replace(/^(['"])(.*)\1$/, "$2")]; +}).filter(Boolean); +const report = readFileSync(reportPath, "utf8"); +if (privateValues.some((value) => report.includes(value))) failures.push("private test material leaked into report"); + +if (actual.length < baseline.length) { + failures.push(`live coverage regressed: baseline=${baseline.length}, actual=${actual.length}`); +} + +if (failures.length > 0) { + for (const failure of failures) process.stderr.write(`- ${failure}\n`); + process.exitCode = 1; +} else { + process.stdout.write( + `live report parity passed: ${actual.length} blocks, ${actualEnvelopes.size} JSON command contracts, ` + + `${observedErrors.size} error codes\n`, + ); +} + diff --git a/ts/scripts/import-wallet.exp b/ts/scripts/import-wallet.exp new file mode 100644 index 000000000..54e9bca6e --- /dev/null +++ b/ts/scripts/import-wallet.exp @@ -0,0 +1,29 @@ +#!/usr/bin/expect -f + +set timeout 30 +log_user 1 + +spawn node $env(CLI_ENTRY) import $env(IMPORT_KIND) --label $env(IMPORT_LABEL) + +expect { + -re {Set master password} { + send -- "$env(TEST_MASTER_PASSWORD)\r" + expect -re {Confirm master password} + send -- "$env(TEST_MASTER_PASSWORD)\r" + } + -re {Master password} { + send -- "$env(TEST_MASTER_PASSWORD)\r" + } + timeout { exit 124 } +} + +expect { + -re {Paste private key} { send -- "$env(TEST_IMPORT_SECRET)\r" } + -re {Paste recovery phrase} { send -- "$env(TEST_IMPORT_SECRET)\r" } + timeout { exit 124 } +} + +expect eof +set result [wait] +exit [lindex $result 3] + diff --git a/ts/scripts/nile-live-suite.mjs b/ts/scripts/nile-live-suite.mjs new file mode 100644 index 000000000..edf586947 --- /dev/null +++ b/ts/scripts/nile-live-suite.mjs @@ -0,0 +1,311 @@ +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const entry = join(root, "dist/index.js"); +const expectScript = join(root, "scripts/import-wallet.exp"); +const privateEnvPath = resolve(root, "../ts/.private/.env.test"); +const baselinePath = join(root, "docs/baselines/nile-full-command-test-2026-06-29-run2-rawlogs.md"); +const reportPath = join(root, "docs/nile-full-command-test-2026-06-29-run2-rawlogs.md"); +const walletHome = mkdtempSync(join(tmpdir(), "wallet-cli-refactor-nile-")); +const password = `Aa1!${randomBytes(12).toString("hex")}`; + +const fixtures = parseEnv(readFileSync(privateEnvPath, "utf8")); +const privateKey = required(fixtures, "TEST_TRON_PRIVATE_KEY"); +const mnemonic = required(fixtures, "TEST_TRON_MNEMONIC"); +const USDT = process.env.TEST_TRC20_CONTRACT ?? "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"; +const TRC10 = process.env.TEST_TRC10_ASSET_ID ?? "1005416"; +const WATCH_ADDRESS = process.env.TEST_WATCH_ADDRESS ?? "TEWBqkD8YMn8FbZjzQifQhQLHiYjcT2zvG"; +const FIXED_BLOCK = process.env.TEST_TRON_BLOCK ?? "68723818"; + +const baseEnv = { + ...process.env, + NO_COLOR: "1", + WALLET_CLI_HOME: walletHome, +}; + +const sections = []; +const stats = { total: 0, exit0: 0, exit1: 0, exit2: 0, other: 0 }; + +function parseEnv(raw) { + return Object.fromEntries(raw.split(/\r?\n/).flatMap((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return []; + const at = trimmed.indexOf("="); + if (at < 1) return []; + const value = trimmed.slice(at + 1).trim().replace(/^(['"])(.*)\1$/, "$2"); + return [[trimmed.slice(0, at).trim(), value]]; + })); +} + +function required(values, key) { + const value = values[key]; + if (!value) throw new Error(`missing ${key} in ${privateEnvPath}`); + return value; +} + +function shellDisplay(args, inputLabel) { + const quoted = args.map((value) => /[\s'"(){}[\]]/.test(value) + ? `'${value.replaceAll("'", "'\\''")}'` + : value); + return `${inputLabel ? `${inputLabel} | ` : ""}wallet-cli ${quoted.join(" ")}`; +} + +function record(display, result) { + const status = result.status ?? 1; + stats.total++; + if (status === 0) stats.exit0++; + else if (status === 1) stats.exit1++; + else if (status === 2) stats.exit2++; + else stats.other++; + const output = `${result.stderr ?? ""}${result.stdout ?? ""}`.replace(/\n$/, ""); + sections.push(`\n\`\`\`\n$ ${display}\n${output}${output ? "\n" : ""}# exit=${status}\n\`\`\`\n`); +} + +function run(args, options = {}) { + const result = spawnSync("node", [entry, ...args], { + encoding: "utf8", + env: baseEnv, + input: options.input, + timeout: options.timeout ?? 90_000, + }); + if (result.error) throw result.error; + if (options.record !== false) { + record(options.display ?? shellDisplay(args, options.inputLabel), result); + } + return result; +} + +function runJson(args, options = {}) { + const result = run(["-o", "json", ...args], options); + try { + return { result, envelope: JSON.parse(result.stdout) }; + } catch { + throw new Error(`expected JSON from: ${args.join(" ")}\n${result.stdout}\n${result.stderr}`); + } +} + +function runPassword(args, options = {}) { + return run([...args, "--password-stdin"], { + ...options, + input: `${options.password ?? password}\n`, + inputLabel: options.inputLabel ?? "printf ", + }); +} + +function runInteractive(kind, label, secret) { + const result = spawnSync("expect", [expectScript], { + encoding: "utf8", + env: { + ...baseEnv, + CLI_ENTRY: entry, + IMPORT_KIND: kind, + IMPORT_LABEL: label, + TEST_MASTER_PASSWORD: password, + TEST_IMPORT_SECRET: secret, + }, + timeout: 90_000, + }); + if (result.error) throw result.error; + record(`pty: wallet-cli import ${kind} --label ${label}`, result); + return result; +} + +function heading(title) { + sections.push(`\n## ${title}\n`); +} + +function txId(envelope) { + return envelope?.data?.txId ?? envelope?.data?.hash; +} + +async function waitForConfirmation(id) { + if (!id) return; + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + const { envelope } = runJson(["tx", "status", "--txid", id], { record: false }); + if (envelope?.data?.confirmed) return; + await new Promise((resolve) => setTimeout(resolve, 3000)); + } +} + +function assertSuccess(result, label) { + if (result.status !== 0) { + throw new Error(`${label} failed with ${result.status}\n${result.stdout}\n${result.stderr}`); + } +} + +mkdirSync(dirname(reportPath), { recursive: true }); +sections.push( + "# wallet-cli refactor — full live command test on Nile — RAW LOGS\n", + `\nGenerated: ${new Date().toISOString()} \n`, + "Network: `tron:nile` \n", + "Wallet home: isolated temporary directory \n", + "Secrets: loaded from `../ts/.private/.env.test`, never printed or copied.\n", +); + +heading("0. Help surface (raw-log-derived groups and leaves)"); +const baselineHelp = readFileSync(baselinePath, "utf8").split("## 1. Wallet & account management")[0]; +const helpCommands = [...baselineHelp.matchAll(/^\$ wallet-cli (.*--help)$/gm)] + .map((match) => match[1].trim()); +for (const command of helpCommands) run(command.split(/\s+/)); + +heading("1. Wallet and account management"); +assertSuccess(runInteractive("private-key", "funded", privateKey), "private-key import"); +assertSuccess(run(["config", "defaultNetwork", "nile"]), "default network"); +assertSuccess(runPassword(["create", "--label", "recipient"]), "recipient create"); +const recipient2 = runJson(["create", "--label", "recipient2", "--password-stdin"], { + input: `${password}\n`, + inputLabel: "printf ", +}); +assertSuccess(recipient2.result, "recipient2 create"); +assertSuccess(runInteractive("mnemonic", "funded-again", mnemonic), "mnemonic import"); +assertSuccess(run(["import", "watch", "--address", WATCH_ADDRESS, "--label", "watched"]), "watch import"); +run(["list"]); +const listed = runJson(["list"]); +const funded = listed.envelope.data.find((account) => account.label === "funded"); +const recipient = listed.envelope.data.find((account) => account.label === "recipient"); +if (!funded?.addresses?.tron || !recipient?.addresses?.tron) throw new Error("missing funded/recipient account"); +const fundedAddress = funded.addresses.tron; +const recipientAddress = recipient.addresses.tron; +run(["current"]); +run(["use", "recipient"]); +run(["current"]); +run(["use", "funded"]); +run(["rename", "recipient", "--label", "recipient"]); +runPassword(["derive", "--account", "recipient"]); +runPassword(["-o", "json", "derive", "--account", "recipient", "--index", "5"]); +const backupPath = join(walletHome, "backup.json"); +runPassword(["backup", "recipient2", "--out", backupPath]); +run(["delete", "recipient2", "--yes"]); +run(["networks"]); +runJson(["networks"]); +run(["config"]); +run(["config", "defaultNetwork"]); +runJson(["config"]); + +heading("2. Account queries"); +run(["account", "balance"]); +run(["account", "balance", "--network", "nile"]); +run(["account", "balance", "--network", "shasta"]); +run(["account", "balance", "--network", "tron:nile"]); +runJson(["account", "balance"]); +run(["account", "balance", "--account", "recipient"]); +run(["account", "balance", "--account", fundedAddress]); +run(["account", "info"]); +runJson(["account", "info"]); +run(["account", "portfolio"]); +runJson(["account", "portfolio"]); +run(["account", "history", "--limit", "5"]); +runJson(["account", "history", "--limit", "3"]); + +heading("3. Token commands"); +run(["token", "list"]); +runJson(["token", "list"]); +run(["token", "info", "--contract", USDT]); +runJson(["token", "info", "--contract", USDT]); +run(["token", "info", "--asset-id", TRC10]); +run(["token", "balance", "--contract", USDT]); +runJson(["token", "balance", "--contract", USDT]); +run(["token", "balance", "--asset-id", TRC10]); +run(["token", "add", "--contract", USDT]); +run(["token", "list"]); +runJson(["token", "list"]); + +heading("4. Transactions"); +runPassword(["tx", "send", "--to", recipientAddress, "--amount", "1", "--dry-run"]); +runPassword(["-o", "json", "tx", "send", "--to", recipientAddress, "--amount", "1", "--dry-run"]); +const signed = runJson(["tx", "send", "--to", recipientAddress, "--amount", "1", "--sign-only", "--password-stdin"], { + input: `${password}\n`, + inputLabel: "printf ", +}); +assertSuccess(signed.result, "sign-only transfer"); +const broadcast = runJson(["tx", "broadcast", "--tx-stdin"], { + input: `${JSON.stringify(signed.envelope.data.signed)}\n`, + inputLabel: "printf ", +}); +const liveTrx = runJson(["tx", "send", "--to", recipientAddress, "--amount", "1", "--password-stdin"], { + input: `${password}\n`, + inputLabel: "printf ", +}); +runPassword(["tx", "send", "--to", recipientAddress, "--contract", USDT, "--amount", "0.001", "--dry-run"]); +const liveUsdt = runJson(["tx", "send", "--to", recipientAddress, "--contract", USDT, "--amount", "0.001", "--password-stdin"], { + input: `${password}\n`, + inputLabel: "printf ", +}); +runPassword(["tx", "send", "--to", recipientAddress, "--asset-id", TRC10, "--amount", "1", "--dry-run"]); +const liveTrc10 = runJson(["tx", "send", "--to", recipientAddress, "--asset-id", TRC10, "--amount", "1", "--password-stdin"], { + input: `${password}\n`, + inputLabel: "printf ", +}); +const trxId = txId(liveTrx.envelope); +const usdtId = txId(liveUsdt.envelope); +await waitForConfirmation(trxId); +await waitForConfirmation(usdtId); +await waitForConfirmation(txId(liveTrc10.envelope)); +await waitForConfirmation(txId(broadcast.envelope)); +run(["tx", "status", "--txid", trxId]); +runJson(["tx", "status", "--txid", trxId]); +run(["tx", "info", "--txid", trxId]); +runJson(["tx", "info", "--txid", usdtId]); + +heading("5. Contracts"); +run(["contract", "info", "--contract", USDT]); +runJson(["contract", "info", "--contract", USDT]); +const balanceParams = JSON.stringify([{ type: "address", value: fundedAddress }]); +run(["contract", "call", "--contract", USDT, "--method", "balanceOf(address)", "--params", balanceParams]); +runJson(["contract", "call", "--contract", USDT, "--method", "balanceOf(address)", "--params", balanceParams]); +runJson(["contract", "call", "--contract", USDT, "--method", "decimals()"]); + +heading("6. Stake and message signing"); +runPassword(["stake", "freeze", "--amount-sun", "1000000", "--resource", "energy"]); +runPassword(["stake", "freeze", "--amount-sun", "1000000", "--resource", "bandwidth", "--dry-run"]); +runPassword(["stake", "delegate", "--amount-sun", "1000000", "--receiver", recipientAddress, "--resource", "energy", "--dry-run"]); +runPassword(["stake", "undelegate", "--amount-sun", "1000000", "--receiver", recipientAddress, "--resource", "energy", "--dry-run"]); +runPassword(["stake", "unfreeze", "--amount-sun", "1000000", "--resource", "energy", "--dry-run"]); +runPassword(["stake", "withdraw"]); +runPassword(["stake", "cancel-unfreeze", "--dry-run"]); +runPassword(["message", "sign", "--message", "hello tron"]); +runPassword(["-o", "json", "message", "sign", "--message", "hello tron"]); + +heading("7. Blocks"); +run(["block"]); +runJson(["block"]); +run(["block", "--number", FIXED_BLOCK]); +run(["block", FIXED_BLOCK]); + +heading("8. Error and exit-code surface"); +run(["--version"]); +run(["frobnicate"]); +run(["account", "resources"]); +run(["stake", "prices"]); +run(["account", "balance", "--network", "mainnet-eth"]); +run(["account", "balance", "--network", "ethereum"]); +runJson(["account", "balance", "--network", "bogus"]); +run(["import", "ledger", "--app", "ethereum", "--label", "l"]); +runPassword(["tx", "send", "--amount", "1"]); +runPassword(["tx", "send", "--to", "GARBAGE", "--amount", "1"]); +runPassword(["tx", "send", "--to", recipientAddress, "--amount", "1", "--raw-amount", "1000000"]); +runPassword(["tx", "send", "--to", recipientAddress, "--amount", "1", "--dry-run"], { + password: "Wrong1!Password", +}); +const unknownTx = "00".repeat(32); +run(["tx", "status", "--txid", unknownTx]); +runJson(["tx", "info", "--txid", unknownTx]); +run(["account", "balance", "--account", "no-such-wallet"]); +runPassword(["tx", "send", "--account", "watched", "--to", recipientAddress, "--amount", "1"]); +runPassword(["tx", "send", "--to", recipientAddress, "--amount", "1", "--sign-only"], { + password: "Wrong1!Password", +}); + +sections.push( + `\n## Summary\n\nInvocations: ${stats.total}; exit 0: ${stats.exit0}; exit 1: ${stats.exit1}; exit 2: ${stats.exit2}; other: ${stats.other}.\n`, +); +writeFileSync(reportPath, sections.join("")); +process.stdout.write(`Nile live suite complete: ${reportPath}\n`); +process.stdout.write(`invocations=${stats.total} exit0=${stats.exit0} exit1=${stats.exit1} exit2=${stats.exit2} other=${stats.other}\n`); + diff --git a/ts/src/adapters/inbound/cli/arity/arity.test.ts b/ts/src/adapters/inbound/cli/arity/arity.test.ts new file mode 100644 index 000000000..a7c6ee69a --- /dev/null +++ b/ts/src/adapters/inbound/cli/arity/arity.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { ciEnum, enumOptions, introspectFields } from "./index.js"; + +describe("enumOptions", () => { + it("returns the literals of an enum field (through optional/default wrappers)", () => { + expect(enumOptions(z.enum(["tron", "ethereum"]))).toEqual(["tron", "ethereum"]); + expect(enumOptions(z.enum(["a", "b"]).optional())).toEqual(["a", "b"]); + }); + it("returns undefined for non-enum fields", () => { + expect(enumOptions(z.string())).toBeUndefined(); + }); + it("descends ciEnum's preprocess pipe to find the literals (through default/optional)", () => { + expect(enumOptions(ciEnum(["energy", "bandwidth"]))).toEqual(["energy", "bandwidth"]); + expect(enumOptions(ciEnum(["energy", "bandwidth"]).default("bandwidth"))).toEqual(["energy", "bandwidth"]); + expect(enumOptions(ciEnum(["native", "token"]).optional())).toEqual(["native", "token"]); + }); +}); + +describe("ciEnum", () => { + const schema = ciEnum(["energy", "bandwidth"]); + it("accepts the canonical lowercase literal", () => { + expect(schema.parse("energy")).toBe("energy"); + }); + it("accepts upper/mixed case and normalizes to the lowercase literal", () => { + expect(schema.parse("ENERGY")).toBe("energy"); + expect(schema.parse("BandWidth")).toBe("bandwidth"); + }); + it("still rejects values outside the literal set", () => { + expect(schema.safeParse("cpu").success).toBe(false); + }); +}); + +describe("introspectFields — defaults & choices", () => { + const fields = introspectFields( + z.object({ + to: z.string().describe("recipient"), + feeLimit: z.coerce.number().int().positive().default(100_000_000).describe("fee cap"), + resource: z.enum(["energy", "bandwidth"]).default("bandwidth").describe("resource type"), + only: z.enum(["native", "token"]).optional().describe("filter"), + flag: z.boolean().default(false).describe("a switch"), + }), + ); + const by = (name: string) => fields.find((f) => f.name === name)!; + + it("captures the default value of a defaulted field", () => { + expect(by("feeLimit").defaultValue).toBe(100_000_000); + expect(by("resource").defaultValue).toBe("bandwidth"); + expect(by("flag").defaultValue).toBe(false); + }); + + it("leaves defaultValue undefined for non-defaulted fields", () => { + expect(by("to").defaultValue).toBeUndefined(); + expect(by("only").defaultValue).toBeUndefined(); + }); + + it("captures enum choices (through default/optional wrappers)", () => { + expect(by("resource").choices).toEqual(["energy", "bandwidth"]); + expect(by("only").choices).toEqual(["native", "token"]); + expect(by("to").choices).toBeUndefined(); + }); +}); diff --git a/ts/src/adapters/inbound/cli/arity/index.ts b/ts/src/adapters/inbound/cli/arity/index.ts new file mode 100644 index 000000000..c9291e4b8 --- /dev/null +++ b/ts/src/adapters/inbound/cli/arity/index.ts @@ -0,0 +1,112 @@ +/** + * Arity adapter — derive the minimal arity hints yargs needs from a command's zod `fields` + * (boolean→switch, else takes a value). Validation/types/defaults/cross-field checks stay in + * zod only — single source of truth. [replaces FlagSpecRegistry] + */ +import type { Argv } from "yargs"; +import { z, type ZodObject, type ZodRawShape, type ZodType } from "zod"; + +// ── account-ref brand ───────────────────────────────────────────────────────── +// A `string` field that names an existing account (accountId/label/address). Branded so the +// TTY gap-fill can offer an arrow-select of existing accounts instead of free text. The brand +// lives on the FINAL schema instance (zod methods clone), so accountRef applies min+describe +// itself and must be the terminal call — no further chaining. +const ACCOUNT_REF = new WeakSet(); +export function accountRef(describe: string): ZodType { + const s = z.string().min(1).describe(describe); + ACCOUNT_REF.add(s); + return s; +} +export function isAccountRef(schema: unknown): boolean { + return typeof schema === "object" && schema !== null && ACCOUNT_REF.has(schema); +} + +// ── case-insensitive enum ────────────────────────────────────────────────────── +// TRON brands its resources/networks in canonical case (ENERGY/BANDWIDTH, Nile, TRON), so an +// exact-match z.enum rejects the casing users most naturally type. ciEnum lower-cases the input +// before matching; `values` must therefore be the lowercase literals. --help still shows those +// literals — enumOptions() descends the preprocess pipe to find them. +export function ciEnum(values: T) { + return z.preprocess((v) => (typeof v === "string" ? v.toLowerCase() : v), z.enum(values)); +} + +export interface FieldInfo { + name: string; + kebab: string; + baseType: string; + optional: boolean; + hasDefault: boolean; + /** the default value (when hasDefault) — surfaced verbatim in --help. */ + defaultValue?: unknown; + /** literal options when the field is an enum — surfaced as `` in --help. */ + choices?: string[]; + description?: string; +} + +export function camelToKebab(s: string): string { + return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); +} + +function unwrap(schema: ZodType): { base: ZodType; optional: boolean; hasDefault: boolean; defaultValue?: unknown; description?: string } { + let s: any = schema; + let optional = false; + let hasDefault = false; + let defaultValue: unknown; + let description: string | undefined = s?.description; + while (s?.def && (s.def.type === "optional" || s.def.type === "default" || s.def.type === "nullable")) { + if (s.def.type === "optional" || s.def.type === "nullable") optional = true; + if (s.def.type === "default") { + hasDefault = true; + defaultValue = s.def.defaultValue; // zod v4: plain value + } + description ??= s.description; + s = s.def.innerType; + } + description ??= s?.description; + return { base: s, optional, hasDefault, defaultValue, description }; +} + +export function introspectFields(fields: ZodObject): FieldInfo[] { + const shape = fields.shape; + return Object.entries(shape).map(([name, schema]) => { + const { base, optional, hasDefault, defaultValue, description } = unwrap(schema as ZodType); + return { + name, + kebab: camelToKebab(name), + baseType: (base as any)?.def?.type ?? "unknown", + optional, + hasDefault, + defaultValue, + choices: enumOptions(schema as ZodType), + description, + }; + }); +} + +/** literal options of an enum field (after unwrapping optional/default), else undefined. */ +export function enumOptions(schema: ZodType): string[] | undefined { + const { base } = unwrap(schema as ZodType); + let def = (base as unknown as { def?: { type?: string; entries?: Record; out?: { def?: any } } }).def; + // ciEnum() wraps the enum in a preprocess pipe; the literals live on the pipe's output side. + if (def?.type === "pipe") def = def.out?.def; + if (def?.type !== "enum" || !def.entries) return undefined; + return Object.values(def.entries); +} + +/** apply one command's zod fields as yargs options (arity only; requiredness stays in zod). */ +function applyArity(y: Argv, fields: ZodObject): Argv { + for (const f of introspectFields(fields)) { + y.option(f.kebab, { + type: f.baseType === "boolean" ? "boolean" : "string", + describe: f.description, + demandOption: false, // requiredness is enforced by zod, not yargs + }); + } + return y; +} + +/** union the arity hints of every command in a namespace group (single source = zod). */ +export function applyCommands(y: Argv, fields: ZodObject[]): Argv { + for (const f of fields) applyArity(y, f); + return y; +} diff --git a/ts/src/adapters/inbound/cli/command-id.ts b/ts/src/adapters/inbound/cli/command-id.ts new file mode 100644 index 000000000..22d1a9aa6 --- /dev/null +++ b/ts/src/adapters/inbound/cli/command-id.ts @@ -0,0 +1,12 @@ +/** + * Canonical command identifier — derived purely from `family` + `path`, never stored. + * This is the value surfaced as the `command` field in every result/error envelope, and the + * stable handle agents key on. Chain commands are family-qualified so the same logical path + * (e.g. tx send) yields a per-chain id (tron.tx.send); neutral commands + * are just their path (create, import.mnemonic, config.get, networks). + */ +import type { ChainFamily } from "../../../domain/types/index.js"; + +export function commandId(cmd: { family?: ChainFamily; path: string[] }): string { + return cmd.family ? [cmd.family, ...cmd.path].join(".") : cmd.path.join("."); +} diff --git a/ts/src/adapters/inbound/cli/commands/config.ts b/ts/src/adapters/inbound/cli/commands/config.ts new file mode 100644 index 000000000..f209173d3 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/config.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../contracts/index.js"; +import { + CONFIG_KEYS, + type ConfigService, +} from "../../../../application/use-cases/config-service.js"; +import { CommandRegistry } from "../registry/index.js"; +import { TextFormatters } from "../render/index.js"; + +export function registerConfigCommands(registry: CommandRegistry, service: ConfigService): void { + const fields = z.object({ + key: z.enum(CONFIG_KEYS).optional() + .describe("config key to read or set; omit to show the whole effective config"), + value: z.string().min(1).optional().describe("new value; omit to read the key"), + }); + + registry.add({ + path: ["config"], + network: "none", + wallet: "none", + auth: "none", + summary: "show/get/set configuration values", + fields, + input: fields, + examples: [ + { cmd: "wallet-cli config" }, + { cmd: "wallet-cli config defaultNetwork" }, + { cmd: "wallet-cli config defaultNetwork nile" }, + ], + formatText: TextFormatters.config, + run: async (ctx, _network, input) => service.execute(input, ctx.config, ctx.networkRegistry), + } satisfies CommandDefinition); +} diff --git a/ts/src/adapters/inbound/cli/commands/network.ts b/ts/src/adapters/inbound/cli/commands/network.ts new file mode 100644 index 000000000..1a5ad0b09 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/network.ts @@ -0,0 +1,23 @@ +/** + * Network command — list known networks. Neutral and not bound to one family. + */ +import { z } from "zod"; +import type { CommandDefinition } from "../contracts/index.js"; +import { CommandRegistry } from "../registry/index.js"; +import { TextFormatters } from "../render/index.js"; + +export function registerNetworkCommands(reg: CommandRegistry): void { + const empty = z.object({}); + + // ── networks ──────────────────────────────────────────────────────────────── + reg.add({ + path: ["networks"], network: "none", wallet: "none", auth: "none", + summary: "list known networks", fields: empty, input: empty, + examples: [{ cmd: "wallet-cli networks" }], + formatText: TextFormatters.networks, + run: async (ctx) => + ctx.networkRegistry.all().map((n) => ({ + id: n.id, family: n.family, chainId: n.chainId, aliases: n.aliases, feeModel: n.feeModel, + })), + } satisfies CommandDefinition); +} diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts new file mode 100644 index 000000000..0233b7663 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -0,0 +1,60 @@ +/** + * Shared chain-command factories — only for commands whose intent and input shape are + * identical across families. Divergent commands (for example send-native, + * with chain-specific amount units + build/estimate) live explicitly in each chain module. + */ +import { z } from "zod"; +import type { ChainFamily } from "../../../../domain/types/index.js"; +import type { CommandDefinition } from "../contracts/index.js"; +import { Schemas } from "../schemas/index.js"; +import { TextFormatters } from "../render/index.js"; +import type { MessageService } from "../../../../application/use-cases/message-service.js"; + +// ── execution-mode flags shared by every signing command ───────────────────────── +/** dry-run / sign-only fields; default (no flag) = sign AND broadcast on-chain. */ +export const txModeFields = { + dryRun: z.boolean().default(false).describe("build and estimate only, with no signature and no broadcast; mutually exclusive with --sign-only"), + signOnly: z.boolean().default(false).describe("sign and output the transaction without broadcasting; mutually exclusive with --dry-run; broadcast later with tx broadcast"), +}; +// ── unified --amount / --raw-amount selector (shared by every chain's `tx send`) ──── +const decimalAmount = z.string().regex(/^\d+(\.\d+)?$/, "must be a non-negative decimal string"); + +/** the `--amount`/`--raw-amount` field pair; descriptions vary per chain (units differ). */ +export function unifiedAmountFields(amountDesc: string, rawDesc: string) { + return { + amount: decimalAmount.optional().describe(amountDesc), + rawAmount: Schemas.uintString().optional().describe(rawDesc), + }; +} + +/** superRefine: exactly one of --amount or --raw-amount must be present. */ +export function amountSelector(v: { amount?: string; rawAmount?: string }, ctx: z.RefinementCtx): void { + const n = [v.amount !== undefined, v.rawAmount !== undefined].filter(Boolean).length; + if (n !== 1) ctx.addIssue({ code: "custom", path: ["amount"], message: "provide exactly one of --amount or --raw-amount" }); +} + +/** message sign — direct SignerResolver path (no node, no TxPipeline). */ +export function messageSignCommand(family: ChainFamily, service: MessageService): CommandDefinition { + // --message OR --message-stdin (the latter is a global data channel via SecretResolver). + const fields = z.object({ + message: z.string().min(1).optional().describe("message text to sign; provide this OR --message-stdin; exactly one is required"), + }); + return { + path: ["message", "sign"], + stdin: "message", + family, + network: "optional", + wallet: "optional", + auth: "required", + capability: "message.sign", + summary: "sign an arbitrary message (TIP-191/V2 · EIP-191)", + fields, + input: fields, + examples: [{ cmd: `wallet-cli message sign --network ${family === "tron" ? "nile" : "eth"} --message "hello"` }], + formatText: TextFormatters.messageSign, + run: async (ctx, _net, input) => { + const message = ctx.secrets.pick(input.message, "message", "message"); + return service.sign(family, ctx.activeAccount, message); + }, + }; +} diff --git a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts new file mode 100644 index 000000000..8f2c8b630 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import type { TronUseCases } from "./tron/index.js"; +import { CommandRegistry } from "../registry/index.js"; +import { registerWalletCommands } from "./wallet.js"; +import { registerConfigCommands } from "./config.js"; +import { registerNetworkCommands } from "./network.js"; +import { TronModule } from "./tron/index.js"; +import { commandId } from "../command-id.js"; +import { TextFormatters } from "../render/index.js"; +import type { TextRenderContext } from "../contracts/index.js"; +import type { ConfigService } from "../../../../application/use-cases/config-service.js"; + +const ctx = (over: Partial = {}): TextRenderContext => ({ command: "x", ...over }); + +describe("text formatters", () => { + it("every registered command has a command-owned text formatter", () => { + const services = {} as TronUseCases; + const registry = new CommandRegistry(); + registerWalletCommands(registry, {} as Parameters[1]); + registerConfigCommands(registry, {} as ConfigService); + registerNetworkCommands(registry); + new TronModule(services).registerCommands(registry); + + const missing = registry.all() + .filter((cmd) => typeof cmd.formatText !== "function") + .map(commandId) + .sort(); + + expect(missing).toEqual([]); + }); +}); + +describe("tokenBalance formatter", () => { + it("formats balance with decimals and symbol when metadata is present", () => { + const out = TextFormatters.tokenBalance({ address: "TXaddress", token: "TR7token", balance: "1204560000", symbol: "USDT", decimals: 6 }, ctx()); + expect(out).toContain("1204.56"); + expect(out).toContain("USDT"); + }); + it("falls back to raw scalar balance when metadata is missing", () => { + const out = TextFormatters.tokenBalance({ address: "TXaddress", token: "TR7token", balance: "1204560000" }, ctx()); + expect(out).toContain("1204560000"); + }); + it("prefers the account label over the address when present", () => { + const out = TextFormatters.tokenBalance({ address: "TXaddress", token: "t", balance: "1" }, ctx({ accountLabel: "main" })); + expect(out).toContain("main"); + expect(out).not.toContain("TXaddress"); + }); +}); + +describe("txReceipt formatter (typed kind, narrowed — no command-id matching)", () => { + it("tx send submitted (default): pending receipt with txid + track hint, no fee/energy", () => { + const out = TextFormatters.txReceipt({ kind: "send", family: "tron", stage: "submitted", txId: "abc123", rawAmount: "5000000", token: "USDT", decimals: 6, to: "TrecipientAddress" }); + expect(out).toContain("⏳"); + expect(out).toContain("Sent 5 USDT"); + expect(out).toContain("TrecipientAddress"); + expect(out).toContain("abc123"); + expect(out).toContain("pending — not yet on-chain"); + expect(out).toContain("Track it: wallet-cli tx info --txid abc123"); + expect(out).not.toContain("Fee"); + }); + it("tx send TRC20 via --contract --raw-amount (no symbol): never mislabels as TRX", () => { + const out = TextFormatters.txReceipt({ kind: "send", family: "tron", stage: "submitted", txId: "t20", rawAmount: "10000", contract: "TXYZtokenContract", to: "Tdest" }); + expect(out).toContain("Sent 10000 TXYZtokenContract"); + expect(out).not.toContain("TRX"); + }); + it("tx send TRC10 via --asset-id --raw-amount (no symbol): labels by asset id, not TRX", () => { + const out = TextFormatters.txReceipt({ kind: "send", family: "tron", stage: "submitted", txId: "t10", rawAmount: "500000", assetId: "1005416", to: "Tdest" }); + expect(out).toContain("Sent 500000 asset 1005416"); + expect(out).not.toContain("TRX"); + }); + it("tx send confirmed (--wait): success receipt with real block + fee", () => { + const out = TextFormatters.txReceipt({ kind: "send", family: "tron", stage: "confirmed", txId: "abc", rawAmount: "1000000", to: "Tdest", blockNumber: 66000000, feeSun: "268000" }); + expect(out).toContain("✅"); + expect(out).toContain("Sent 1 TRX"); + expect(out).toContain("#66,000,000"); + expect(out).toContain("0.268 TRX"); + expect(out).toContain("success"); + }); + it("contract send failed (--wait): failure receipt with reason", () => { + const out = TextFormatters.txReceipt({ kind: "contract-send", family: "tron", stage: "failed", txId: "abc", method: "transfer(address,uint256)", contract: "TR7contract", result: "OUT_OF_ENERGY", blockNumber: 1, failed: true }); + expect(out).toContain("❌"); + expect(out).toContain("Called transfer"); + expect(out).toContain("TR7contract"); + expect(out).toContain("OUT_OF_ENERGY"); + }); + it("dry-run with an energy estimate (TRC20/contract): renders energy, never [object Object]", () => { + const out = TextFormatters.txReceipt({ + kind: "send", family: "tron", mode: "dry-run", + fee: { feeModel: "tron-resource", energy: 29650, availableEnergy: 133440569 } as any, + tx: { txID: "deadbeef" } as any, rawAmount: "10000", contract: "TXYZtoken", to: "Tdest", + } as any); + expect(out).toContain("Dry run"); + expect(out).not.toContain("[object Object]"); + expect(out).toContain("29,650 energy"); + expect(out).toContain("covered by staked energy"); // availableEnergy >= energy + }); + it("dry-run energy estimate with insufficient available energy: no 'covered' note", () => { + const out = TextFormatters.txReceipt({ + kind: "send", family: "tron", mode: "dry-run", + fee: { feeModel: "tron-resource", energy: 29650, availableEnergy: 100 } as any, + tx: { txID: "deadbeef" } as any, rawAmount: "10000", contract: "TXYZtoken", to: "Tdest", + } as any); + expect(out).toContain("29,650 energy"); + expect(out).not.toContain("covered by staked energy"); + }); + it("stake freeze submitted: renders staked amount and resource", () => { + const out = TextFormatters.txReceipt({ kind: "stake-freeze", family: "tron", stage: "submitted", txId: "abc", amountSun: "2000000", resource: "energy" }); + expect(out).toContain("Staked"); + expect(out).toContain("2 TRX"); + expect(out).toContain("energy"); + }); +}); + +describe("txStatus formatter (family-agnostic; command supplies `failed`)", () => { + it("tron: confirmed when not failed", () => { + const out = TextFormatters.txStatus({ family: "tron", txid: "abc", confirmed: true, failed: false, blockNumber: 123 }); + expect(out).toContain("confirmed"); + expect(out).toContain("#123"); + }); + it("tron: failed when command flags it", () => { + const out = TextFormatters.txStatus({ family: "tron", txid: "abc", confirmed: true, failed: true, blockNumber: 1 }); + expect(out).toContain("failed"); + }); + it("pending when not yet confirmed", () => { + const out = TextFormatters.txStatus({ family: "tron", txid: "abc", confirmed: false, failed: false }); + expect(out).toContain("pending"); + }); +}); + +describe("txInfo formatter (per-family, narrowed on family)", () => { + it("tron: shows TRX amount, energy and fee in TRX", () => { + const out = TextFormatters.txInfo({ + family: "tron", txid: "abc", from: "Tfrom", to: "Tto", amount: "1.5", symbol: "TRX", + status: "SUCCESS", blockNumber: 66000000, energyUsed: 28000, feeSun: 268000, transaction: {}, info: {}, + }); + expect(out).toContain("1.5 TRX"); + expect(out).toContain("#66,000,000"); + expect(out).toContain("28,000"); + expect(out).toContain("0.268 TRX"); + expect(out).toContain("SUCCESS"); + }); +}); + +describe("contractInfo formatter", () => { + it("uses normalized methods + count", () => { + const out = TextFormatters.contractInfo({ address: "TR7c", name: "Foo", methods: ["a", "b"], functionCount: 2 }); + expect(out).toContain("Foo"); + expect(out).toContain("Methods"); + expect(out).toContain("2 (a / b)"); + }); + it("falls back to raw contract/info ABI shape", () => { + const out = TextFormatters.contractInfo({ address: "TR7c", contract: { name: "Bar", abi: { entrys: [{ type: "Function", name: "x" }] } } }); + expect(out).toContain("Bar"); + expect(out).toContain("1 (x)"); + }); +}); + +describe("accountHistory formatter", () => { + it("renders normalized rows", () => { + const out = TextFormatters.accountHistory({ + address: "TXaddr", + records: [{ time: 1700000000000, type: "Transfer", amount: "1000000", symbol: "TRX", counterparty: "Tother", status: "ok" }], + }, ctx()); + expect(out).toContain("Transfer"); + expect(out).toContain("Tother"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/tron/account.ts b/ts/src/adapters/inbound/cli/commands/tron/account.ts new file mode 100644 index 000000000..21329cd45 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/tron/account.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../../contracts/index.js"; +import type { TronAccountService } from "../../../../../application/use-cases/tron/account-service.js"; +import { ciEnum } from "../../arity/index.js"; +import { TextFormatters } from "../../render/index.js"; + +export function accountCommands(service: TronAccountService): CommandDefinition[] { + return [balance(service), info(service), history(service), portfolio(service)]; +} + +function balance(service: TronAccountService): CommandDefinition { + const fields = z.object({}); + return { + path: ["account", "balance"], family: "tron", + network: "optional", wallet: "optional", auth: "none", + capability: "account.balance.native", + summary: "get native sun balance", + fields, + input: fields, + examples: [{ cmd: "wallet-cli account balance --network nile" }], + formatText: TextFormatters.accountBalance, + run: async (ctx, network) => service.balance(ctx, network!, "tron"), + }; +} + +function info(service: TronAccountService): CommandDefinition { + const fields = z.object({}); + return { + path: ["account", "info"], family: "tron", + network: "optional", wallet: "optional", auth: "none", + summary: "raw account + bandwidth/energy (getAccount + getAccountResources)", + fields, + input: fields, + examples: [{ cmd: "wallet-cli account info --network nile" }], + formatText: TextFormatters.accountInfo, + run: async (ctx, network) => service.info(ctx, network!), + }; +} + +function history(service: TronAccountService): CommandDefinition { + const fields = z.object({ + limit: z.coerce.number().int().positive().max(200).default(20) + .describe("maximum records to return, in records; range: 1-200"), + only: ciEnum(["native", "token"]).optional() + .describe("filter history by transfer type; omit to show all transfer types"), + }); + return { + path: ["account", "history"], family: "tron", + network: "optional", wallet: "optional", auth: "none", + summary: "transaction history (requires a TronGrid-compatible httpEndpoint)", + fields, + input: fields, + examples: [{ cmd: "wallet-cli account history --network nile --limit 10" }], + formatText: TextFormatters.accountHistory, + run: async (ctx, network, input) => service.historyFor(ctx, network!, input), + }; +} + +function portfolio(service: TronAccountService): CommandDefinition { + const fields = z.object({}); + return { + path: ["account", "portfolio"], family: "tron", + network: "optional", wallet: "optional", auth: "none", + capability: "account.portfolio", + summary: "native TRX + address-book token balances with best-effort USD valuation", + fields, + input: fields, + examples: [{ cmd: "wallet-cli account portfolio --network nile" }], + formatText: TextFormatters.accountPortfolio, + run: async (ctx, network) => service.portfolio(ctx, network!), + }; +} diff --git a/ts/src/adapters/inbound/cli/commands/tron/block.ts b/ts/src/adapters/inbound/cli/commands/tron/block.ts new file mode 100644 index 000000000..5c59fe10e --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/tron/block.ts @@ -0,0 +1,26 @@ +/** + * TRON block group — block lookup. + */ +import { z } from "zod"; +import type { CommandDefinition } from "../../contracts/index.js"; +import type { TronBlockService } from "../../../../../application/use-cases/tron/block-service.js"; +import { TextFormatters } from "../../render/index.js"; + +function blockGet(service: TronBlockService): CommandDefinition { + const fields = z.object({ number: z.coerce.number().int().nonnegative().optional().describe("block number to fetch, in block height; omit to fetch the latest block") }); + return { + path: ["block"], family: "tron", + network: "optional", wallet: "none", auth: "none", + summary: "get a block (latest if omitted)", fields, input: fields, + examples: [ + { cmd: "wallet-cli block --network nile" }, + { cmd: "wallet-cli block 12345 --network nile" }, + ], + formatText: TextFormatters.block, + run: async (_ctx, net, input) => service.get(net!, input.number), + }; +} + +export function blockCommands(service: TronBlockService): CommandDefinition[] { + return [blockGet(service)]; +} diff --git a/ts/src/adapters/inbound/cli/commands/tron/contract.ts b/ts/src/adapters/inbound/cli/commands/tron/contract.ts new file mode 100644 index 000000000..35be8abb0 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/tron/contract.ts @@ -0,0 +1,134 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../../contracts/index.js"; +import { UsageError } from "../../../../../domain/errors/index.js"; +import type { TronContractService } from "../../../../../application/use-cases/tron/contract-service.js"; +import type { TronContractParameter } from "../../../../../application/ports/chain/tron-gateway.js"; +import { Schemas } from "../../schemas/index.js"; +import { txModeFields } from "../shared.js"; +import { TextFormatters } from "../../render/index.js"; + +function jsonArray(raw: string | undefined, flag = "--params"): unknown[] { + if (!raw) return []; + try { + const value = JSON.parse(raw); + if (Array.isArray(value)) return value; + } catch { + // Fall through to the stable usage error. + } + throw new UsageError("invalid_value", `${flag} must be a JSON array`); +} + +export function contractCommands(service: TronContractService): CommandDefinition[] { + return [call(service), send(service), deploy(service), info(service)]; +} + +function call(service: TronContractService): CommandDefinition { + const fields = z.object({ + contract: Schemas.addressFor("tron").describe("TRON contract address"), + method: z.string().min(1).describe("function signature, e.g. balanceOf(address)"), + params: z.string().optional() + .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), + }); + return { + path: ["contract", "call"], family: "tron", + network: "optional", wallet: "none", auth: "none", + capability: "contract.call", + summary: "read-only call (triggerConstantContract)", + fields, + input: fields, + examples: [{ + cmd: `wallet-cli contract call --network nile --contract TR7... --method "balanceOf(address)" --params '[{"type":"address","value":"T..."}]'`, + }], + formatText: TextFormatters.contractCall, + run: async (_ctx, network, input) => service.call( + network!, input.contract, input.method, jsonArray(input.params) as TronContractParameter[], + ), + }; +} + +function send(service: TronContractService): CommandDefinition { + const fields = z.object({ + contract: Schemas.addressFor("tron").describe("TRON contract address"), + method: z.string().min(1).describe("function signature, e.g. transfer(address,uint256)"), + params: z.string().optional() + .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), + callValueSun: z.coerce.number().int().nonnegative().default(0) + .describe("native TRX attached to the call, in SUN"), + feeLimit: z.coerce.number().int().positive().default(100_000_000) + .describe("maximum energy fee to burn, in SUN"), + ...txModeFields, + }); + return { + path: ["contract", "send"], family: "tron", + network: "required", wallet: "optional", auth: "required", + broadcasts: true, + capability: "contract.call", + summary: "state-changing call (triggerSmartContract)", + fields, + input: fields, + examples: [{ + cmd: `wallet-cli contract send --network nile --contract TR7... --method "transfer(address,uint256)" --params '[...]'`, + }], + formatText: TextFormatters.txReceipt, + run: async (ctx, network, input) => service.send(ctx, network!, { + ...input, + parameters: jsonArray(input.params) as TronContractParameter[], + }), + }; +} + +function deploy(service: TronContractService): CommandDefinition { + const fields = z.object({ + abi: z.string().min(1).describe("contract ABI as a JSON array string"), + bytecode: z.string().min(1).describe("compiled contract bytecode as hex, 0x-prefixed or bare"), + feeLimit: z.coerce.number().int().positive().describe("maximum energy fee to burn, in SUN"), + constructorSig: z.string().optional() + .describe("constructor signature, e.g. constructor(uint256); omit when the contract has no constructor args"), + params: z.string().optional() + .describe("constructor args as a JSON array of {type,value}; omit to pass no constructor args"), + ...txModeFields, + }); + return { + path: ["contract", "deploy"], family: "tron", + network: "required", wallet: "optional", auth: "required", + broadcasts: true, + capability: "contract.deploy", + summary: "deploy a smart contract", + fields, + input: fields, + examples: [{ + cmd: "wallet-cli contract deploy --network nile --abi '[...]' --bytecode 60... --fee-limit 1000000000", + }], + formatText: TextFormatters.txReceipt, + run: async (ctx, network, input) => { + let abi: unknown; + try { + abi = JSON.parse(input.abi); + } catch { + throw new UsageError("invalid_value", "--abi must be valid JSON"); + } + return service.deploy(ctx, network!, { + ...input, + abi, + parameters: jsonArray(input.params), + }); + }, + }; +} + +function info(service: TronContractService): CommandDefinition { + const fields = z.object({ + contract: Schemas.addressFor("tron").describe("TRON contract address"), + }); + return { + path: ["contract", "info"], family: "tron", + network: "optional", wallet: "none", auth: "none", + capability: "contract.call", + summary: "contract ABI + metadata (getContract + getContractInfo)", + fields, + input: fields, + examples: [{ cmd: "wallet-cli contract info --network nile --contract TR7..." }], + formatText: TextFormatters.contractInfo, + run: async (_ctx, network, input) => service.info(network!, input.contract), + }; +} diff --git a/ts/src/adapters/inbound/cli/commands/tron/index.ts b/ts/src/adapters/inbound/cli/commands/tron/index.ts new file mode 100644 index 000000000..6d7244b9b --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/tron/index.ts @@ -0,0 +1,48 @@ +/** + * TronModule — TRON's own command surface. No universal + * provider: TRON-specific build/estimate/codecs live in the per-group files; only infra + * (TxPipeline, SignerResolver, RpcProvider) is shared. Implements the ChainModule contract. + */ +import type { ChainModule, CommandRegistryLike } from "../../contracts/index.js"; +import type { TronAccountService } from "../../../../../application/use-cases/tron/account-service.js"; +import type { TronTokenService } from "../../../../../application/use-cases/tron/token-service.js"; +import type { TronTransactionService } from "../../../../../application/use-cases/tron/transaction-service.js"; +import type { TronStakeService } from "../../../../../application/use-cases/tron/stake-service.js"; +import type { TronBlockService } from "../../../../../application/use-cases/tron/block-service.js"; +import type { TronContractService } from "../../../../../application/use-cases/tron/contract-service.js"; +import type { MessageService } from "../../../../../application/use-cases/message-service.js"; +import { accountCommands } from "./account.js"; +import { tokenCommands } from "./token.js"; +import { txCommands } from "./tx.js"; +import { stakeCommands } from "./stake.js"; +import { blockCommands } from "./block.js"; +import { contractCommands } from "./contract.js"; +import { messageCommands } from "./message.js"; + +export interface TronUseCases { + tronAccount: TronAccountService; + tronToken: TronTokenService; + tronTransaction: TronTransactionService; + tronStake: TronStakeService; + tronBlock: TronBlockService; + tronContract: TronContractService; + message: MessageService; +} + +export class TronModule implements ChainModule { + readonly family = "tron" as const; + constructor(private readonly services: TronUseCases) {} + + registerCommands(reg: CommandRegistryLike): void { + const groups = [ + accountCommands(this.services.tronAccount), + tokenCommands(this.services.tronToken), + txCommands(this.services.tronTransaction), + stakeCommands(this.services.tronStake), + blockCommands(this.services.tronBlock), + contractCommands(this.services.tronContract), + messageCommands(this.services.message), + ]; + for (const cmds of groups) for (const cmd of cmds) reg.add(cmd); + } +} diff --git a/ts/src/adapters/inbound/cli/commands/tron/message.ts b/ts/src/adapters/inbound/cli/commands/tron/message.ts new file mode 100644 index 000000000..afffe2e6c --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/tron/message.ts @@ -0,0 +1,11 @@ +/** + * TRON message group — message signing (TIP-191/V2). The command itself comes from the + * shared (family-agnostic) factory; this file just scopes it to TRON. + */ +import type { CommandDefinition } from "../../contracts/index.js"; +import type { MessageService } from "../../../../../application/use-cases/message-service.js"; +import { messageSignCommand } from "../shared.js"; + +export function messageCommands(service: MessageService): CommandDefinition[] { + return [messageSignCommand("tron", service)]; +} diff --git a/ts/src/adapters/inbound/cli/commands/tron/shared.ts b/ts/src/adapters/inbound/cli/commands/tron/shared.ts new file mode 100644 index 000000000..a95b44f7e --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/tron/shared.ts @@ -0,0 +1,18 @@ +import type { z } from "zod"; + +/** TRC20 contract XOR TRC10 asset id; exactly one selector is required. */ +export function tokenSelector( + value: { contract?: string; assetId?: string }, + context: z.RefinementCtx, +): void { + const count = [value.contract, value.assetId] + .filter((candidate) => candidate !== undefined).length; + if (count !== 1) { + context.addIssue({ + code: "custom", + path: ["contract"], + message: "exactly one of --contract (TRC20) or --asset-id (TRC10) is required", + }); + } +} + diff --git a/ts/src/adapters/inbound/cli/commands/tron/stake.ts b/ts/src/adapters/inbound/cli/commands/tron/stake.ts new file mode 100644 index 000000000..aef452167 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/tron/stake.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; +import type { NetworkDescriptor } from "../../../../../domain/types/index.js"; +import type { + CommandDefinition, + ExecutionContext, +} from "../../contracts/index.js"; +import type { TronStakeService } from "../../../../../application/use-cases/tron/stake-service.js"; +import { RESOURCES } from "../../../../../domain/resources/index.js"; +import { Schemas } from "../../schemas/index.js"; +import { ciEnum } from "../../arity/index.js"; +import { txModeFields } from "../shared.js"; +import { TextFormatters } from "../../render/index.js"; + +const resourceField = (description: string) => + ciEnum(RESOURCES).default("bandwidth").describe(description); + +interface StakeCommandOptions { + capability?: string; + refine?: (value: any, context: z.RefinementCtx) => void; +} + +type StakeExecutor = ( + context: ExecutionContext, + network: NetworkDescriptor, + input: any, +) => Promise; + +function command( + action: string, + summary: string, + execute: StakeExecutor, + extra: z.ZodRawShape = {}, + options: StakeCommandOptions = {}, +): CommandDefinition { + const fields = z.object({ ...extra, ...txModeFields }); + return { + path: ["stake", action], family: "tron", + network: "required", wallet: "optional", auth: "required", + broadcasts: true, + capability: options.capability ?? "staking.freeze", + summary, + fields, + input: options.refine ? fields.superRefine(options.refine) : fields, + examples: [{ cmd: `wallet-cli stake ${action} --network nile` }], + formatText: TextFormatters.txReceipt, + run: async (context, network, input) => execute(context, network!, input), + }; +} + +export function stakeCommands(service: TronStakeService): CommandDefinition[] { + return [ + command( + "freeze", + "stake TRX for energy/bandwidth (FreezeBalanceV2)", + (context, network, input) => service.freeze(context, network, input), + { + amountSun: Schemas.uintString().describe("amount to freeze as staked TRX, in SUN"), + resource: resourceField("resource type to obtain"), + }, + ), + command( + "unfreeze", + "unstake TRX (UnfreezeBalanceV2)", + (context, network, input) => service.unfreeze(context, network, input), + { + amountSun: Schemas.uintString().describe("amount to unfreeze as staked TRX, in SUN"), + resource: resourceField("resource type to release"), + }, + ), + command( + "withdraw", + "withdraw expired unfrozen TRX", + (context, network, input) => service.withdraw(context, network, input), + ), + command( + "cancel-unfreeze", + "cancel all pending unstakes (roll back to frozen)", + (context, network, input) => service.cancelUnfreeze(context, network, input), + ), + command( + "delegate", + "delegate frozen resource to another address (DelegateResourceV2)", + (context, network, input) => service.delegate(context, network, input), + { + amountSun: Schemas.uintString() + .describe("staked-TRX amount backing the delegated resource, in SUN"), + receiver: Schemas.addressFor("tron") + .describe("TRON address receiving the delegated resource"), + resource: resourceField("resource type to delegate or reclaim"), + lock: z.boolean().default(false) + .describe("lock the delegation and prevent early undelegation"), + lockPeriod: z.coerce.number().int().positive().optional() + .describe("lock duration in blocks, approximately 3 seconds per block; requires --lock"), + }, + { + capability: "staking.delegate", + refine: (value, context) => { + if (value.lockPeriod !== undefined && !value.lock) { + context.addIssue({ + code: "custom", + message: "--lock-period requires --lock", + path: ["lockPeriod"], + }); + } + }, + }, + ), + command( + "undelegate", + "reclaim delegated resource (UnDelegateResourceV2)", + (context, network, input) => service.undelegate(context, network, input), + { + amountSun: Schemas.uintString() + .describe("staked-TRX amount backing the resource to reclaim, in SUN"), + receiver: Schemas.addressFor("tron") + .describe("TRON address that previously received the delegated resource"), + resource: resourceField("resource type to delegate or reclaim"), + }, + { capability: "staking.delegate" }, + ), + ]; +} diff --git a/ts/src/adapters/inbound/cli/commands/tron/token.ts b/ts/src/adapters/inbound/cli/commands/tron/token.ts new file mode 100644 index 000000000..0811965b0 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/tron/token.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../../contracts/index.js"; +import type { TronTokenService } from "../../../../../application/use-cases/tron/token-service.js"; +import { Schemas } from "../../schemas/index.js"; +import { TextFormatters } from "../../render/index.js"; +import { tokenSelector } from "./shared.js"; + +const selectorFields = z.object({ + contract: Schemas.addressFor("tron").optional() + .describe("TRC20 contract address; provide exactly one of --contract or --asset-id"), + assetId: z.string().regex(/^\d+$/).optional() + .describe("TRC10 numeric asset id; provide exactly one of --asset-id or --contract"), +}); + +export function tokenCommands(service: TronTokenService): CommandDefinition[] { + return [balance(service), info(service), add(service), list(service), remove(service)]; +} + +function balance(service: TronTokenService): CommandDefinition { + return { + path: ["token", "balance"], family: "tron", + network: "optional", wallet: "optional", auth: "none", + capability: "account.balance.token", + summary: "single token balance (TRC20 via --contract, TRC10 via --asset-id)", + fields: selectorFields, + input: selectorFields.superRefine(tokenSelector), + examples: [{ cmd: "wallet-cli token balance --network nile --contract TR7..." }], + formatText: TextFormatters.tokenBalance, + run: async (ctx, network, input) => service.balance(ctx, network!, input), + }; +} + +function info(service: TronTokenService): CommandDefinition { + return { + path: ["token", "info"], family: "tron", + network: "optional", wallet: "none", auth: "none", + capability: "account.balance.token", + summary: "token metadata (name/symbol/decimals/totalSupply)", + fields: selectorFields, + input: selectorFields.superRefine(tokenSelector), + examples: [{ cmd: "wallet-cli token info --network nile --contract TR7..." }], + formatText: TextFormatters.tokenInfo, + run: async (_ctx, network, input) => service.info(network!, input), + }; +} + +function add(service: TronTokenService): CommandDefinition { + return { + path: ["token", "add"], family: "tron", + network: "optional", wallet: "optional", auth: "none", + capability: "token.tokenbook", + summary: "add a custom token to the address book (fetches symbol/decimals)", + fields: selectorFields, + input: selectorFields.superRefine(tokenSelector), + examples: [{ cmd: "wallet-cli token add --network nile --contract TR7..." }], + formatText: TextFormatters.tokenBookAdd, + run: async (ctx, network, input) => service.add(ctx, network!, input), + }; +} + +function list(service: TronTokenService): CommandDefinition { + const fields = z.object({}); + return { + path: ["token", "list"], family: "tron", + network: "optional", wallet: "optional", auth: "none", + capability: "token.tokenbook", + summary: "list the token address-book (official + user; no balances)", + fields, + input: fields, + examples: [{ cmd: "wallet-cli token list --network nile" }], + formatText: TextFormatters.tokenBookList, + run: async (ctx, network) => service.list(ctx, network!), + }; +} + +function remove(service: TronTokenService): CommandDefinition { + return { + path: ["token", "remove"], family: "tron", + network: "optional", wallet: "optional", auth: "none", + capability: "token.tokenbook", + summary: "remove a user-added token from the address book", + fields: selectorFields, + input: selectorFields.superRefine(tokenSelector), + examples: [{ cmd: "wallet-cli token remove --network nile --contract TR7..." }], + formatText: TextFormatters.tokenBookRemove, + run: async (ctx, network, input) => service.remove(ctx, network!, input), + }; +} diff --git a/ts/src/adapters/inbound/cli/commands/tron/tx.ts b/ts/src/adapters/inbound/cli/commands/tron/tx.ts new file mode 100644 index 000000000..c0a35fbea --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/tron/tx.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../../contracts/index.js"; +import { UsageError } from "../../../../../domain/errors/index.js"; +import type { TronTransactionService } from "../../../../../application/use-cases/tron/transaction-service.js"; +import { Schemas } from "../../schemas/index.js"; +import { + amountSelector, + txModeFields, + unifiedAmountFields, +} from "../shared.js"; +import { TextFormatters } from "../../render/index.js"; + +export function txCommands(service: TronTransactionService): CommandDefinition[] { + return [send(service), broadcast(service), status(service), info(service)]; +} + +function send(service: TronTransactionService): CommandDefinition { + const fields = z.object({ + to: Schemas.addressFor("tron").describe("recipient TRON base58 address"), + token: z.string().min(1).optional() + .describe("token symbol from the address book; mutually exclusive with --contract and --asset-id"), + contract: Schemas.addressFor("tron").optional() + .describe("TRC20 contract address; omit with --asset-id for native TRX"), + assetId: z.string().regex(/^\d+$/).optional() + .describe("TRC10 numeric asset id; omit with --contract for native TRX"), + feeLimit: z.coerce.number().int().positive().default(100_000_000) + .describe("maximum TRX energy fee to burn for TRC20 transfers, in SUN"), + ...unifiedAmountFields( + "human amount: TRX for native, token units for TRC20/TRC10; mutually exclusive with --raw-amount", + "raw integer amount in SUN or token base units; mutually exclusive with --amount", + ), + ...txModeFields, + }); + return { + path: ["tx", "send"], family: "tron", + network: "optional", wallet: "optional", auth: "required", + broadcasts: true, + capability: "tx.send", + summary: "send native TRX or TRC20/TRC10 tokens with human --amount", + fields, + input: fields.superRefine(amountSelector).superRefine(tokenOptional), + examples: [ + { cmd: "wallet-cli tx send --network nile --to T... --amount 1" }, + { cmd: "wallet-cli tx send --network tron --to T... --token USDT --amount 5" }, + { cmd: "wallet-cli tx send --network nile --to T... --contract TR7... --amount 5" }, + { cmd: "wallet-cli tx send --network nile --to T... --asset-id 1002000 --raw-amount 1000000" }, + ], + formatText: TextFormatters.txReceipt, + run: async (ctx, network, input) => service.send(ctx, network!, input), + }; +} + +function broadcast(service: TronTransactionService): CommandDefinition { + const fields = z.object({ + transaction: z.string().optional() + .describe("signed TRON transaction JSON; provide this OR --tx-stdin; exactly one is required"), + }); + return { + path: ["tx", "broadcast"], stdin: "tx", family: "tron", + network: "required", wallet: "none", auth: "none", + broadcasts: true, + capability: "tx.broadcast", + summary: "broadcast a presigned transaction", + fields, + input: fields, + examples: [{ cmd: "wallet-cli tx broadcast --network nile --tx-stdin < signed.json" }], + formatText: TextFormatters.txReceipt, + run: async (ctx, network, input) => { + const raw = ctx.secrets.pick(input.transaction, "tx", "transaction"); + try { + return service.broadcast(ctx, network!, JSON.parse(raw)); + } catch (error) { + if (error instanceof SyntaxError) { + throw new UsageError("invalid_value", "TRON presigned tx must be JSON"); + } + throw error; + } + }, + }; +} + +function status(service: TronTransactionService): CommandDefinition { + const fields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") }); + return { + path: ["tx", "status"], family: "tron", + network: "optional", wallet: "none", auth: "none", + summary: "confirmation status of a transaction", + fields, + input: fields, + examples: [{ cmd: "wallet-cli tx status --network nile --txid abc123" }], + formatText: TextFormatters.txStatus, + run: async (_ctx, network, input) => service.status(network!, input.txid), + }; +} + +function info(service: TronTransactionService): CommandDefinition { + const fields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") }); + return { + path: ["tx", "info"], family: "tron", + network: "optional", wallet: "none", auth: "none", + summary: "full transaction detail + receipt", + fields, + input: fields, + examples: [{ cmd: "wallet-cli tx info --network nile --txid abc123" }], + formatText: TextFormatters.txInfo, + run: async (_ctx, network, input) => service.info(network!, input.txid), + }; +} + +function tokenOptional( + value: { token?: string; contract?: string; assetId?: string }, + context: z.RefinementCtx, +): void { + const count = [value.token, value.contract, value.assetId] + .filter((candidate) => candidate !== undefined).length; + if (count > 1) { + context.addIssue({ + code: "custom", + path: ["token"], + message: "choose at most one of --token, --contract or --asset-id", + }); + } +} diff --git a/ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts new file mode 100644 index 000000000..1ee465995 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/wallet.import-ledger.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { walletImportLedgerInput } from "./wallet.js"; + +const ok = (v: unknown) => walletImportLedgerInput.safeParse(v).success; + +describe("wallet import-ledger contract", () => { + it("requires --app", () => { + expect(ok({ index: 0 })).toBe(false); + }); + + it("accepts exactly one locator", () => { + expect(ok({ app: "tron", index: 0 })).toBe(true); + expect(ok({ app: "tron", path: "m/44'/195'/0'/0/0" })).toBe(true); + expect(ok({ app: "tron", address: "Tabc", scanLimit: 30 })).toBe(true); + }); + + it("accepts --app with no locator (defaults to index 0 downstream)", () => { + expect(ok({ app: "tron" })).toBe(true); + }); + + it("rejects more than one locator (mutually exclusive)", () => { + expect(ok({ app: "tron", index: 0, path: "m/44'/195'/0'/0/0" })).toBe(false); + expect(ok({ app: "tron", index: 0, address: "Tabc" })).toBe(false); + }); + + it("coerces --index and --scan-limit from strings", () => { + const r = walletImportLedgerInput.safeParse({ app: "tron", index: "2", scanLimit: "30" }); + expect(r.success && (r.data as { index?: number }).index).toBe(2); + }); + + it("rejects a hidden-family app (EVM is not currently exposed)", () => { + expect(ok({ app: "ethereum", index: 0 })).toBe(false); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.test.ts new file mode 100644 index 000000000..9dcaa960c --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/wallet.test.ts @@ -0,0 +1,355 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import { Keystore } from "../../../outbound/keystore/index.js"; +import { AtomicFileStore } from "../../../outbound/persistence/fs/index.js"; +import { SecretResolver } from "../input/secret/index.js"; +import { StreamManager } from "../stream/index.js"; +import { Prompter } from "../input/prompt/index.js"; +import { ConfigLoader, NetworkRegistry } from "../../../outbound/config/index.js"; +import { buildExecutionContext, RuntimeDeps } from "../context/index.js"; +import { createOutputFormatter } from "../output/index.js"; +import { registerWalletCommands, walletImportLedgerFields, walletImportLedgerInput } from "./wallet.js"; +import { CommandRegistry } from "../registry/index.js"; +import { commandId } from "../command-id.js"; +import type { Globals } from "../contracts/index.js"; +import { Derivation } from "../../../../domain/derivation/index.js"; +import { WalletService } from "../../../../application/use-cases/wallet-service.js"; + +// ── test constants ───────────────────────────────────────────────────────────── +const VALID_MNEMONIC = "test test test test test test test test test test test junk"; +const VALID_PRIVATE_KEY = "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; +const VALID_PASSWORD = "Abcdef1!"; + +// ── helpers ──────────────────────────────────────────────────────────────────── + +interface FakePromptOpts { + tty?: boolean; + hiddenAnswers?: string[]; + confirmResult?: boolean; + confirmAnswer?: string; +} + +function makeFakeBackend(opts: FakePromptOpts = {}): ConstructorParameters[0] { + const { tty = true, hiddenAnswers = [], confirmResult = true, confirmAnswer } = opts; + let hiddenIdx = 0; + + return { + isTTY: () => tty, + async question(_prompt: string, hidden: boolean) { + if (hidden) { + return hiddenAnswers[hiddenIdx++] ?? VALID_PASSWORD; + } + // confirm prompts are not hidden + return confirmAnswer ?? ""; + }, + async readKey() { return { name: "return" }; }, + write(_s: string) {}, + beginRaw() {}, + endRaw() {}, + }; +} + +function buildTestDeps(opts: FakePromptOpts & { root?: string } = {}): { + deps: RuntimeDeps; + ks: Keystore; + prompter: Prompter; + streams: StreamManager; + secrets: SecretResolver; +} { + const root = opts.root ?? mkdtempSync(join(tmpdir(), "wallet-test-")); + const store = new AtomicFileStore(); + const streams = new StreamManager("text", false); + const prompter = new Prompter(makeFakeBackend(opts)); + // password: "-" means "read from stdin"; we prime the stdin via streams + // Actually: use no paths for password, rely on primePassword via prompter + const secrets = new SecretResolver(streams, {}, prompter); + const ks = new Keystore(root, store, () => secrets.masterPassword()); + const config = ConfigLoader.load(); + const networkRegistry = new NetworkRegistry(config); + const formatter = createOutputFormatter("text", streams, Date.now()); + const deps: RuntimeDeps = { config, networkRegistry, streams, secrets, keystore: ks, prompter, formatter }; + return { deps, ks, prompter, streams, secrets }; +} + +function buildGlobals(): Globals { + return { output: "text", verbose: false }; +} + +function buildServices(ks: Keystore) { + const ledger = {} as any; + return { + walletService: new WalletService(ks, ledger, { + write: () => ({ out: "unused", fileMode: "0600", bytes: 0 }), + }), + ledger, + tokenBook: {} as any, + priceProvider: {} as any, + rpc: {} as any, + signerResolver: {} as any, + txPipeline: {} as any, + capabilityRegistry: {} as any, + }; +} + +/** Resolve a command by its derived canonical id (e.g. "create", "import.mnemonic"). */ +function getCmd(registry: CommandRegistry, id: string): ReturnType { + const cmd = registry.all().find((c) => commandId(c) === id) ?? null; + if (!cmd) throw new Error(`command not found: ${id}`); + return cmd; +} + +async function runCmd( + cmdId: string, + input: Record, + opts: FakePromptOpts & { root?: string } = {}, +) { + const { deps, ks } = buildTestDeps(opts); + // Prime the password before command runs (simulating what dispatch does for passwordMode) + await deps.secrets.primePassword({ mode: "set" }); + const ctx = buildExecutionContext(buildGlobals(), deps); + const registry = new CommandRegistry(); + registerWalletCommands(registry, buildServices(ks)); + const cmd = getCmd(registry, cmdId)!; + const result = await cmd.run(ctx, undefined as any, input as any); + return { result, ks }; +} + +// ── wallet create ────────────────────────────────────────────────────────────── + +describe("wallet create", () => { + it("creates an account with a tron address", async () => { + const { result, ks } = await runCmd("create", {}, { + tty: true, + hiddenAnswers: [VALID_PASSWORD, VALID_PASSWORD], + }); + expect(result).toBeDefined(); + expect(result.accountId).toMatch(/^wlt_/); + expect(result.addresses?.tron?.startsWith("T")).toBe(true); + const accounts = ks.list(); + expect(accounts).toHaveLength(1); + }); + + it("createFields schema does NOT have a words key", async () => { + // We verify the schema at runtime by checking the registered command's fields + const { deps, ks } = buildTestDeps({ tty: true, hiddenAnswers: [VALID_PASSWORD, VALID_PASSWORD] }); + const registry = new CommandRegistry(); + registerWalletCommands(registry, buildServices(ks)); + const cmd = getCmd(registry, "create")!; + const shape = (cmd.fields as any).shape as Record; + expect(Object.keys(shape)).not.toContain("words"); + expect(Object.keys(shape)).toContain("label"); + }); + + it("generates a 12-word mnemonic", async () => { + // create, then backup to read mnemonic word count + const root = mkdtempSync(join(tmpdir(), "wallet-create-test-")); + const { deps, ks, secrets } = buildTestDeps({ + root, + tty: true, + hiddenAnswers: [VALID_PASSWORD, VALID_PASSWORD], + }); + // prime password for create + await secrets.primePassword({ mode: "set" }); + const ctx = buildExecutionContext(buildGlobals(), deps); + const registry = new CommandRegistry(); + registerWalletCommands(registry, buildServices(ks)); + + const createCmd = getCmd(registry, "create")!; + const createResult = await createCmd.run(ctx, undefined as any, {} as any); + + // verify the created account has a seed source + const accountList = ks.list(); + expect(accountList).toHaveLength(1); + const wallet = ks.resolveAccount(createResult.accountId).wallet; + expect(wallet.source.type).toBe("seed"); + + // reveal the mnemonic to check word count + const vaultId = (wallet.source as any).vaultId as string; + const revealed = ks.revealMnemonic(vaultId); + const words = revealed.mnemonic.split(" ").filter(Boolean); + expect(words).toHaveLength(12); + }); +}); + +// ── wallet import-mnemonic ───────────────────────────────────────────────────── + +describe("wallet import-mnemonic", () => { + it("creates an account from a mnemonic provided via interactive prompt", async () => { + const root = mkdtempSync(join(tmpdir(), "wallet-import-mnemonic-test-")); + const { deps, ks, secrets } = buildTestDeps({ + root, + tty: true, + // order: set password, confirm password, then mnemonic + hiddenAnswers: [VALID_PASSWORD, VALID_PASSWORD, VALID_MNEMONIC], + }); + + await secrets.primePassword({ mode: "set" }); + const ctx = buildExecutionContext(buildGlobals(), deps); + const registry = new CommandRegistry(); + registerWalletCommands(registry, buildServices(ks)); + + const cmd = getCmd(registry, "import.mnemonic")!; + const result = await cmd.run(ctx, undefined as any, {} as any); + + expect(result.accountId).toMatch(/^wlt_/); + expect(result.addresses?.tron?.startsWith("T")).toBe(true); + expect(ks.list()).toHaveLength(1); + }); +}); + +// ── wallet import-private-key ────────────────────────────────────────────────── + +describe("wallet import-private-key", () => { + it("creates an account from a private key provided via interactive prompt", async () => { + const root = mkdtempSync(join(tmpdir(), "wallet-import-pk-test-")); + const { deps, ks, secrets } = buildTestDeps({ + root, + tty: true, + // order: set password, confirm password, then private key + hiddenAnswers: [VALID_PASSWORD, VALID_PASSWORD, VALID_PRIVATE_KEY], + }); + + await secrets.primePassword({ mode: "set" }); + const ctx = buildExecutionContext(buildGlobals(), deps); + const registry = new CommandRegistry(); + registerWalletCommands(registry, buildServices(ks)); + + const cmd = getCmd(registry, "import.private-key")!; + const result = await cmd.run(ctx, undefined as any, {} as any); + + expect(result.accountId).toMatch(/^wlt_/); + expect(result.addresses?.tron?.startsWith("T")).toBe(true); + expect(ks.list()).toHaveLength(1); + }); +}); + +// ── wallet delete ───────────────────────────────────────────────────────────── + +describe("wallet delete", () => { + async function setupAccountForDelete(root: string, opts: FakePromptOpts) { + const { deps, ks, secrets } = buildTestDeps({ root, ...opts }); + await secrets.primePassword({ mode: "set" }); + const ctx = buildExecutionContext(buildGlobals(), deps); + const registry = new CommandRegistry(); + registerWalletCommands(registry, buildServices(ks)); + + // import an account first + const importCmd = getCmd(registry, "import.mnemonic")!; + const importResult = await importCmd.run(ctx, undefined as any, {} as any); + return { ctx, registry, ks, accountId: importResult.accountId }; + } + + it("deletes an account when --yes is true", async () => { + const root = mkdtempSync(join(tmpdir(), "wallet-delete-test-")); + // hiddenAnswers for set+confirm password, then mnemonic for import + const { ctx, registry, ks, accountId } = await setupAccountForDelete(root, { + tty: true, + hiddenAnswers: [VALID_PASSWORD, VALID_PASSWORD, VALID_MNEMONIC], + }); + + expect(ks.list()).toHaveLength(1); + + const deleteCmd = getCmd(registry, "delete")!; + await deleteCmd.run(ctx, undefined as any, { account: accountId, yes: true } as any); + + expect(ks.list()).toHaveLength(0); + }); + + it("confirms deletion by exact label when a label is available", async () => { + const root = mkdtempSync(join(tmpdir(), "wallet-delete-label-test-")); + const { ctx, registry, ks, accountId } = await setupAccountForDelete(root, { + tty: true, + hiddenAnswers: [VALID_PASSWORD, VALID_PASSWORD, VALID_MNEMONIC], + confirmAnswer: "wallet-1", + }); + + expect(ks.describe(accountId).label).toBe("wallet-1"); + + const deleteCmd = getCmd(registry, "delete")!; + await deleteCmd.run(ctx, undefined as any, { account: accountId } as any); + + expect(ks.list()).toHaveLength(0); + }); + + it("throws aborted when --yes is false and confirm returns wrong string", async () => { + const root = mkdtempSync(join(tmpdir(), "wallet-delete-abort-test-")); + const { ctx, registry, ks, accountId } = await setupAccountForDelete(root, { + tty: true, + hiddenAnswers: [VALID_PASSWORD, VALID_PASSWORD, VALID_MNEMONIC], + confirmAnswer: "wrong-ref", + }); + + expect(ks.list()).toHaveLength(1); + + const deleteCmd = getCmd(registry, "delete")!; + await expect( + deleteCmd.run(ctx, undefined as any, { account: accountId } as any), + ).rejects.toMatchObject({ code: "aborted" }); + + // account should still exist + expect(ks.list()).toHaveLength(1); + }); + + it("throws tty_required when --yes is omitted and not a TTY", async () => { + const root = mkdtempSync(join(tmpdir(), "wallet-delete-notty-test-")); + const { deps, ks, secrets } = buildTestDeps({ + root, + tty: false, + }); + // for non-TTY, secrets need password via stdin path + const storeNonTty = new AtomicFileStore(); + // Use a separate TTY keystore just to create the account + const root2 = mkdtempSync(join(tmpdir(), "wallet-delete-notty2-test-")); + const streams2 = new StreamManager("text", false); + const fakeBackend2 = { + isTTY: () => true, + async question(_p: string, hidden: boolean) { return VALID_PASSWORD; }, + async readKey() { return { name: "return" }; }, + write(_s: string) {}, + beginRaw() {}, + endRaw() {}, + }; + const prompter2 = new Prompter(fakeBackend2); + const secrets2 = new SecretResolver(streams2, {}, prompter2); + const ks2 = new Keystore(root2, storeNonTty, () => secrets2.masterPassword()); + await secrets2.primePassword({ mode: "set" }); + const { accountId } = ks2.import({ secret: VALID_MNEMONIC, type: "seed" }); + + // Now set up a non-TTY context pointing to the same root + const storeNonTty2 = new AtomicFileStore(); + const streamsNT = new StreamManager("text", false); + const fakeBackendNT = { + isTTY: () => false, + async question(_p: string, _hidden: boolean) { return ""; }, + async readKey() { return { name: "return" }; }, + write(_s: string) {}, + beginRaw() {}, + endRaw() {}, + }; + const prompterNT = new Prompter(fakeBackendNT); + // prime password via stdin path by priming it directly + const secretsNT = new SecretResolver(streamsNT, {}, prompterNT); + // manually prime the password so ks.delete can proceed if needed + // Actually we just need the delete to fail at the TTY check, before touching ks + const ksNT = new Keystore(root2, storeNonTty2, () => secrets2.masterPassword()); + + const config = ConfigLoader.load(); + const networkRegistry = new NetworkRegistry(config); + const formatter = createOutputFormatter("text", streamsNT, Date.now()); + const depsNT: RuntimeDeps = { + config, networkRegistry, streams: streamsNT, secrets: secretsNT, + keystore: ksNT, prompter: prompterNT, formatter, + }; + const ctxNT = buildExecutionContext(buildGlobals(), depsNT); + const registryNT = new CommandRegistry(); + registerWalletCommands(registryNT, buildServices(ksNT)); + + const deleteCmd = getCmd(registryNT, "delete")!; + await expect( + deleteCmd.run(ctxNT, undefined as any, { account: accountId } as any), + ).rejects.toMatchObject({ code: "tty_required" }); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts new file mode 100644 index 000000000..040ace00f --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -0,0 +1,239 @@ +/** + * Wallet root commands — create/import/list/current/use/backup. Not chain-bound; no --network. + * Calls WalletService rather than the transaction pipeline. + */ +import { z } from "zod"; +import type { CommandDefinition } from "../contracts/index.js"; +import { Schemas } from "../schemas/index.js"; +import { CommandRegistry } from "../registry/index.js"; +import { accountRef, ciEnum } from "../arity/index.js"; +import type { LedgerDevice } from "../../../../application/ports/ledger-device.js"; +import type { WalletService } from "../../../../application/use-cases/wallet-service.js"; +import { resolveLedgerPath, selectLedgerPath } from "../../../../application/services/ledger-account.js"; +import { ChainFamily, CHAIN_FAMILIES, FAMILIES } from "../../../../domain/family/index.js"; +import { UsageError } from "../../../../domain/errors/index.js"; +import { TextFormatters } from "../render/index.js"; + +// ── wallet import-ledger contract (module scope so it can be unit-tested) ─────── +// The selectable Ledger apps are the families with a wired Ledger app (FAMILIES[f].ledger); +// the enum drives both --help and the interactive prompt. +const LEDGER_APP_BY_FAMILY: Partial> = Object.fromEntries( + CHAIN_FAMILIES.flatMap((f) => (FAMILIES[f].ledger ? [[f, FAMILIES[f].ledger!.app]] : [])), +); +const FAMILY_BY_LEDGER_APP: Record = Object.fromEntries( + (Object.entries(LEDGER_APP_BY_FAMILY) as [ChainFamily, string][]).map(([f, app]) => [app, f]), +); +const LEDGER_APPS = CHAIN_FAMILIES + .map((f) => LEDGER_APP_BY_FAMILY[f]) + .filter((a): a is string => a !== undefined) as [string, ...string[]]; +export const walletImportLedgerFields = z.object({ + app: ciEnum(LEDGER_APPS).describe("Ledger app to open on the device, selecting the address-derivation scheme"), + index: z.coerce.number().int().nonnegative().optional().describe("HD account index to import; omit with no --path/--address to use index 0; mutually exclusive with --path and --address"), + path: z.string().optional().describe("explicit BIP32 derivation path, e.g. m/44'/195'/0'/0/0 for TRON; mutually exclusive with --index and --address"), + address: z.string().optional().describe("known address to locate by bounded scan; mutually exclusive with --index and --path"), + scanLimit: z.coerce.number().int().positive().optional().describe("number of account indexes to scan when using --address, in indexes; omit to scan 20 indexes"), + label: Schemas.label().optional().describe("human-friendly unique account label, 1-64 chars; omit to auto-generate"), +}); +/** --index / --path / --address are mutually exclusive (at most one locator). */ +export const walletImportLedgerInput = walletImportLedgerFields.superRefine((v, c) => { + const locators = [v.index !== undefined, v.path !== undefined, v.address !== undefined].filter(Boolean).length; + if (locators > 1) c.addIssue({ code: "custom", path: ["index"], message: "--index, --path and --address are mutually exclusive" }); +}); + +export function registerWalletCommands( + reg: CommandRegistry, + services: { walletService: WalletService; ledger: LedgerDevice }, +): void { + const wallets = services.walletService; + const empty = z.object({}); + + // ── create ─────────────────────────────────────────────────────────────── + const createFields = z.object({ + label: Schemas.label().optional().describe("human-friendly unique account label, 1-64 chars; omit to auto-generate"), + }); + reg.add({ + path: ["create"], network: "none", wallet: "none", auth: "required", passwordMode: "establish", + interactive: true, promptHints: { label: "default-label" }, + summary: "create a new HD wallet (BIP39 seed)", fields: createFields, input: createFields, + examples: [{ cmd: "wallet-cli create --label main" }], + formatText: TextFormatters.walletCreated("Created", [ + "Recovery phrase is encrypted locally and was not printed.", + "Run `backup` soon and store the file offline.", + ]), + run: async (_ctx, _net, input) => { + return wallets.create(input.label); + }, + } satisfies CommandDefinition); + + // ── import mnemonic ─────────────────────────────────────────────────────── + // BIP39 passphrase intentionally NOT exposed in phase 1 ; plumbing stays. + const importMnemonicFields = z.object({ label: Schemas.label().optional().describe("human-friendly unique account label, 1-64 chars; omit to auto-generate; mnemonic comes from --mnemonic-stdin or interactive prompt") }); + reg.add({ + path: ["import", "mnemonic"], stdin: "mnemonic", network: "none", wallet: "none", auth: "required", passwordMode: "establish", + interactive: true, promptHints: { label: "default-label" }, + summary: "import an existing BIP39 mnemonic (encrypted at rest)", fields: importMnemonicFields, input: importMnemonicFields, + examples: [{ cmd: "wallet-cli import mnemonic --label main" }], + formatText: TextFormatters.walletCreated("Imported", [ + "Recovery phrase was read from hidden input and was not printed.", + ]), + run: async (ctx, _net, input) => { + const secret = await ctx.secrets.resolveSecret("mnemonic"); + return wallets.importMnemonic(secret, input.label); + }, + } satisfies CommandDefinition); + + // ── import private-key ──────────────────────────────────────────────────── + const importPrivateKeyFields = z.object({ label: Schemas.label().optional().describe("human-friendly unique account label, 1-64 chars; omit to auto-generate; private key comes from --private-key-stdin or interactive prompt") }); + reg.add({ + path: ["import", "private-key"], stdin: "privateKey", network: "none", wallet: "none", auth: "required", passwordMode: "establish", + interactive: true, promptHints: { label: "default-label" }, + summary: "import an existing private key (encrypted at rest)", fields: importPrivateKeyFields, input: importPrivateKeyFields, + examples: [{ cmd: "wallet-cli import private-key --label hot" }], + formatText: TextFormatters.walletCreated("Imported", [ + "Private key was read from hidden input and was not printed.", + ]), + run: async (ctx, _net, input) => { + const secret = await ctx.secrets.resolveSecret("privateKey"); + return wallets.importPrivateKey(secret, input.label); + }, + } satisfies CommandDefinition); + + // ── import ledger ───────────────────────────────────────────────────────── + reg.add({ + path: ["import", "ledger"], network: "none", wallet: "none", auth: "none", + interactive: true, promptHints: { label: "default-label", index: "skip", path: "skip", address: "skip", scanLimit: "skip" }, + summary: "register a Ledger account (watch-only; signs on the device)", fields: walletImportLedgerFields, input: walletImportLedgerInput, + examples: [{ cmd: "wallet-cli import ledger --app tron --index 0 --label cold" }], + formatText: TextFormatters.walletLedger, + run: async (ctx, _net, input) => { + const family: ChainFamily = FAMILY_BY_LEDGER_APP[input.app]!; + const hasLocator = input.index !== undefined || input.path !== undefined || input.address !== undefined; + const path = hasLocator || !ctx.prompt.isTTY() + ? await resolveLedgerPath(services.ledger, family, input) + : await selectLedgerPath(services.ledger, family, ctx.prompt); + ctx.emit({ type: "awaiting_device", reason: "verify_address" }); + return wallets.importLedger(family, path, input.label); + }, + } satisfies CommandDefinition); + + // ── import watch ────────────────────────────────────────────────────────── + const importWatchFields = z.object({ + address: z.string().min(1).describe("watch-only address to track; format: TRON base58 T...; family is auto-detected"), + label: Schemas.label().optional().describe("human-friendly unique account label, 1-64 chars; omit to auto-generate"), + }); + reg.add({ + path: ["import", "watch"], network: "none", wallet: "none", auth: "none", + interactive: true, + summary: "register a watch-only address (no secret)", fields: importWatchFields, input: importWatchFields, + examples: [{ cmd: "wallet-cli import watch --address T... --label team-vault" }], + formatText: TextFormatters.walletWatch, + run: async (_ctx, _net, input) => { + return wallets.importWatch(input.address, input.label); + }, + } satisfies CommandDefinition); + + // ── list ───────────────────────────────────────────────────────────────── + reg.add({ + path: ["list"], network: "none", wallet: "none", auth: "none", + summary: "list wallets/accounts (no unlock needed)", fields: empty, input: empty, + examples: [{ cmd: "wallet-cli list --output json" }], + formatText: TextFormatters.walletList, + run: async () => wallets.list(), + } satisfies CommandDefinition); + + // ── use ────────────────────────────────────────────────────────────────── + const setActiveFields = z.object({ account: z.string().min(1).describe("accountId, label, or address to make active for future commands") }); + reg.add({ + path: ["use"], network: "none", wallet: "none", auth: "none", positionalAccount: true, + summary: "set the active account", fields: setActiveFields, input: setActiveFields, + examples: [{ cmd: "wallet-cli use main" }], + formatText: TextFormatters.walletUse, + run: async (_ctx, _net, input) => { + return wallets.use(input.account); + }, + } satisfies CommandDefinition); + + // ── current ─────────────────────────────────────────────────────────────── + // Read-only: always reports the persisted active account; ignores --account + // (use `use` to change it). wallet:"none" keeps help from + // advertising an --account override here. + reg.add({ + path: ["current"], network: "none", wallet: "none", auth: "none", + summary: "show the current active account", fields: empty, input: empty, + examples: [{ cmd: "wallet-cli current" }], + formatText: TextFormatters.walletCurrent, + run: async () => wallets.current(), + } satisfies CommandDefinition); + + // ── rename ──────────────────────────────────────────────────────────────── + const renameFields = z.object({ + account: z.string().min(1).describe("accountId, current label, or address to rename"), + label: Schemas.label().describe("new unique label, 1-64 chars"), + }); + reg.add({ + path: ["rename"], network: "none", wallet: "none", auth: "none", positionalAccount: true, + summary: "rename an account label", fields: renameFields, input: renameFields, + examples: [{ cmd: "wallet-cli rename main --label primary" }], + formatText: TextFormatters.walletRename, + run: async (_ctx, _net, input) => { + return wallets.rename(input.account, input.label); + }, + } satisfies CommandDefinition); + + // ── derive ──────────────────────────────────────────────────────────────── + const addAccountFields = z.object({ + account: z.string().min(1).describe("seed wallet accountId, label, or address to derive from"), + index: z.coerce.number().int().nonnegative().optional().describe("explicit HD account index, in account index; omit to use the next free index"), + label: Schemas.label().optional().describe("label for the new derived account, 1-64 chars; omit to auto-generate"), + }); + reg.add({ + path: ["derive"], network: "none", wallet: "none", auth: "required", + summary: "derive an HD account in a seed wallet (next free, or --index)", fields: addAccountFields, input: addAccountFields, + examples: [{ cmd: "wallet-cli derive --account main --index 3" }], + formatText: TextFormatters.walletDerive, + run: async (_ctx, _net, input) => { + return wallets.derive(input.account, input.index, input.label); + }, + } satisfies CommandDefinition); + + // ── delete ──────────────────────────────────────────────────────────────── + const deleteFields = z.object({ + account: accountRef("account or wallet to delete, addressed by accountId, label, or address"), + yes: z.boolean().default(false).describe("skip the interactive confirmation; required for non-TTY deletion"), + }); + reg.add({ + path: ["delete"], network: "none", wallet: "none", auth: "none", interactive: true, positionalAccount: true, + summary: "delete a wallet/account and clean orphan labels", fields: deleteFields, input: deleteFields, + examples: [{ cmd: "wallet-cli delete old --yes" }], + formatText: TextFormatters.walletDelete, + run: async (ctx, _net, input) => { + if (!input.yes) { + if (!ctx.prompt.isTTY()) { + throw new UsageError("tty_required", "deletion needs confirmation: pass --yes or run in a terminal"); + } + const d = wallets.describe(input.account); + const expect = d.label ?? d.accountId; + const kind = d.label ? "label" : "ref"; + const ok = await ctx.prompt.confirm({ label: `Delete ${expect}? Type the exact ${kind} to confirm`, expect }); + if (!ok) throw new UsageError("aborted", "deletion not confirmed"); + } + return wallets.delete(input.account); + }, + } satisfies CommandDefinition); + + // ── backup ──────────────────────────────────────────────────────────────── + // Writes the secret + metadata to a 0600 FILE (never stdout/envelope): the secret stays off + // screen, logs and AI context. stdout returns only metadata + the written path. + // master password via dispatch prime (passwordMode: "verify"); --password-stdin is the non-interactive source. + const backupFields = z.object({ + account: accountRef("account or wallet to export, addressed by accountId, label, or address"), + out: z.string().optional().describe("output file path; omit to write /backups/-.json; file is created with mode 0600 and never overwritten"), + }); + reg.add({ + path: ["backup"], network: "none", wallet: "none", auth: "required", passwordMode: "verify", interactive: true, positionalAccount: true, + summary: "export an account's secret + metadata to a 0600 file", fields: backupFields, input: backupFields, + examples: [{ cmd: "wallet-cli backup main --out ~/main-backup.json --password-stdin" }], + formatText: TextFormatters.walletBackup, + run: async (_ctx, _net, input) => wallets.backup(input.account, input.out), + } satisfies CommandDefinition); +} diff --git a/ts/src/adapters/inbound/cli/context/context.test.ts b/ts/src/adapters/inbound/cli/context/context.test.ts new file mode 100644 index 000000000..7e97044cb --- /dev/null +++ b/ts/src/adapters/inbound/cli/context/context.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { buildExecutionContext, RuntimeDeps } from "./index.js"; +import { StreamManager } from "../stream/index.js"; +import { createOutputFormatter } from "../output/index.js"; +import type { Globals } from "../contracts/index.js"; + +function ctxWith(output: "text" | "json") { + const out: string[] = []; + const err: string[] = []; + const sm = new StreamManager(output, false, (s) => out.push(s), (s) => err.push(s)); + const formatter = createOutputFormatter(output, sm, 0); + const globals = { output, verbose: false } as Globals; + // only streams + formatter are exercised by emit(); the rest is lazily used elsewhere. + const deps = { config: { timeoutMs: 1 }, streams: sm, formatter } as unknown as RuntimeDeps; + return { ctx: buildExecutionContext(globals, deps), out, err }; +} + +describe("ExecutionContext.emit (progress events)", () => { + it("routes a json event through formatter+streams to stderr, never stdout", () => { + const { ctx, out, err } = ctxWith("json"); + ctx.emit({ type: "awaiting_device", reason: "sign" }); + expect(out).toEqual([]); + expect(JSON.parse(err[0]!)).toEqual({ type: "awaiting_device", reason: "sign" }); + }); + + it("renders a human line in text mode", () => { + const { ctx, err } = ctxWith("text"); + ctx.emit({ type: "broadcasting" }); + expect(err[0]).toContain("broadcasting"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/context/index.ts b/ts/src/adapters/inbound/cli/context/index.ts new file mode 100644 index 000000000..87fe6726e --- /dev/null +++ b/ts/src/adapters/inbound/cli/context/index.ts @@ -0,0 +1,94 @@ +/** + * ExecutionContext — assemble runtime context from config, environment, and flags. Selection is + * account-level: activeAccount is resolved lazily from --account/--wallet or wallets.json. + * Build is side-effect-free; secrets never enter the serializable surface. + */ +import type { AccountRef, ChainFamily, Config, OutputMode } from "../../../../domain/types/index.js"; +import type { ProgressEvent } from "../../../../application/contracts/index.js"; +import type { NetworkRegistry } from "../../../../application/ports/network-registry.js"; +import type { ExecutionContext, Globals, SecretResolver, StreamManager } from "../contracts/index.js"; +import type { OutputFormatter } from "../output/index.js"; +import type { Prompter } from "../input/prompt/index.js"; +import type { AccountStore } from "../../../../application/ports/account-store.js"; +import { accountRef, walletAddress } from "../../../../domain/wallet/index.js"; +import { WalletError } from "../../../../domain/errors/index.js"; +import { SOURCE_KINDS } from "../../../../domain/sources/index.js"; + +export interface RuntimeDeps { + config: Config; + networkRegistry: NetworkRegistry; + streams: StreamManager; + secrets: SecretResolver; + keystore: AccountStore; + prompter: Prompter; + formatter: OutputFormatter; +} + +class ExecutionContextImpl implements ExecutionContext { + output: OutputMode; + timeoutMs: number; + wait: boolean; + waitTimeoutMs: number; + #activeRef?: AccountRef; + + constructor( + private readonly globals: Globals, + private readonly deps: RuntimeDeps, + ) { + this.output = globals.output ?? deps.config.defaultOutput; + this.timeoutMs = globals.timeoutMs ?? deps.config.timeoutMs; + this.wait = globals.wait ?? false; + this.waitTimeoutMs = globals.waitTimeoutMs ?? 60_000; + } + + get config(): Config { + return this.deps.config; + } + get networkRegistry(): NetworkRegistry { + return this.deps.networkRegistry; + } + get streams(): StreamManager { + return this.deps.streams; + } + get secrets(): SecretResolver { + return this.deps.secrets; + } + get prompt(): Prompter { + return this.deps.prompter; + } + get network(): string | undefined { + return this.globals.network; + } + + get activeAccount(): AccountRef { + if (this.#activeRef) return this.#activeRef; + const ks = this.deps.keystore; + let ref: AccountRef | null; + if (this.globals.account) { + const { wallet, index } = ks.resolveAccount(this.globals.account); + ref = accountRef(wallet.id, SOURCE_KINDS[wallet.source.type].isHD ? index : null); + } else { + ref = ks.activeAccount(); + } + if (!ref) { + throw new WalletError("missing_wallet_address", "no active account; import one or pass --account"); + } + this.#activeRef = ref; + return ref; + } + + resolveAddress(family: ChainFamily): string { + const { wallet, index } = this.deps.keystore.resolveAccount(this.activeAccount); + const address = walletAddress(wallet, family, index); + if (!address) throw new WalletError("missing_wallet_address", `active account has no ${family} address`); + return address; + } + + emit(e: ProgressEvent): void { + this.deps.streams.event(this.deps.formatter.event(e)); + } +} + +export function buildExecutionContext(globals: Globals, deps: RuntimeDeps): ExecutionContext { + return new ExecutionContextImpl(globals, deps); +} diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts new file mode 100644 index 000000000..8f92102d9 --- /dev/null +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -0,0 +1,74 @@ +/** CLI command metadata, validation, rendering and registration contracts. */ +import type { ZodObject, ZodRawShape, ZodType } from "zod"; +import type { ChainFamily } from "../../../../domain/family/index.js"; +import type { NetworkDescriptor } from "../../../../domain/types/network.js"; +import type { NetworkRequirement, WalletRequirement } from "../../../../application/contracts/index.js"; +import type { ExecutionContext } from "./execution-context.js"; + +export interface Example { + cmd: string; + note?: string; +} + +// "optional" = the command operates on an account; --account is optional and falls back to the +// active account (errors only if no account exists at all). "none" = never touches an account. +// (No "required": no command forces --account — active is always a valid default. cf. network.) +// "required" = unlocks the master password (sign / read secrets / encrypt); +// "none" = never unlocks. (No middle state — a command either needs the password or it doesn't.) +export type AuthRequirement = "none" | "required"; + +/** secret/payload channel a command reads from stdin; documents the matching --*-stdin flag. */ +export type StdinChannel = "privateKey" | "mnemonic" | "tx" | "message"; + +export interface TextRenderContext { + command: string; + net?: NetworkDescriptor; + /** label of the resolved active account, injected centrally; absent for wallet:"none" commands. */ + accountLabel?: string; +} + +export type TextFormatter = (data: O, ctx: TextRenderContext) => string | null; + +export interface CommandDefinition { + /** full typed path. Neutral commands carry their complete path (e.g. ["import","mnemonic"], + * ["config","get"], ["create"]); chain commands carry the logical path (e.g. ["tx","send"]) + * shared across families. The only routing discriminator is `family` present/absent. + * The stable identity (envelope `command` field) is derived from command metadata, not stored. */ + path: string[]; + family?: ChainFamily; + /** declares the command reads from a *-stdin channel; drives help/catalog input-flag docs. */ + stdin?: StdinChannel; + network: NetworkRequirement; + wallet: WalletRequirement; + auth: AuthRequirement; + /** broadcasts a transaction on-chain (✍️); enables the --wait global flag in help projection. */ + broadcasts?: boolean; + /** opt-in interactive master-password handling: "establish" = set on first wallet else verify; "verify" = require existing. Commands without this keep the lazy hasMasterPassword guard. */ + passwordMode?: "establish" | "verify"; + /** bind an `[account]` positional on the CLI surface (account-targeting neutral leaves: use/rename/backup/delete). */ + positionalAccount?: boolean; + /** allow interactive TTY prompts (master password, secret, gap-fill, confirm). Absent ⇒ fail fast — safer for scripts/agents. */ + interactive?: boolean; + /** gap-fill prompt hints, by field name: "skip" = never prompt this optional field; "default-label" = offer a generated default. */ + promptHints?: Record; + capability?: string; + summary?: string; + /** per-field zod object; feeds the arity adapter + HelpService. */ + fields: ZodObject; + /** full validation schema (often fields.superRefine), used in dispatch. */ + input: ZodType; + examples: Example[]; + run(ctx: ExecutionContext, net: NetworkDescriptor | undefined, input: I): Promise; + /** Optional command-specific renderer for text mode. JSON mode always uses the envelope. */ + formatText?: TextFormatter; +} + +export interface ChainModule { + family: ChainFamily; + registerCommands(reg: CommandRegistryLike): void; +} + +/** structural view of CommandRegistry needed by ChainModule.registerCommands. */ +export interface CommandRegistryLike { + add(cmd: CommandDefinition): void; +} diff --git a/ts/src/adapters/inbound/cli/contracts/envelope.ts b/ts/src/adapters/inbound/cli/contracts/envelope.ts new file mode 100644 index 000000000..721c30b20 --- /dev/null +++ b/ts/src/adapters/inbound/cli/contracts/envelope.ts @@ -0,0 +1,46 @@ +/** + * SharedTypes — output contract (result/error envelopes + progress events) + * and the global runtime flags parsed off argv. + */ +import type { ChainFamily } from "../../../../domain/family/index.js"; +import type { NetworkId } from "../../../../domain/types/network.js"; +import type { OutputMode } from "../../../../domain/types/primitives.js"; + +export interface ChainView { + family: ChainFamily; + networkId: NetworkId; + network: string; + chainId: string; +} +export interface Meta { + durationMs: number; + warnings: string[]; +} +export interface ResultEnvelope { + schema: "wallet-cli.result.v1"; + success: true; + command: string; + chain?: ChainView; + data: unknown; + meta: Meta; +} +export interface ErrorEnvelope { + schema: "wallet-cli.result.v1"; + success: false; + command: string; + chain?: ChainView; + error: { code: string; message: string; details?: object }; + meta: Meta; +} + +// ═══════════════ global runtime flags parsed off argv ═════════════════════ +export interface Globals { + /** absent until the config default is resolved (runner bootstrap / buildExecutionContext). */ + output?: OutputMode; + network?: string; + account?: string; + timeoutMs?: number; + verbose: boolean; + wait?: boolean; + waitTimeoutMs?: number; +} diff --git a/ts/src/adapters/inbound/cli/contracts/execution-context.ts b/ts/src/adapters/inbound/cli/contracts/execution-context.ts new file mode 100644 index 000000000..86c33c94f --- /dev/null +++ b/ts/src/adapters/inbound/cli/contracts/execution-context.ts @@ -0,0 +1,16 @@ +import type { Config, OutputMode } from "../../../../domain/types/index.js"; +import type { TransactionScope } from "../../../../application/contracts/index.js"; +import type { NetworkRegistry } from "../../../../application/ports/network-registry.js"; +import type { PromptPort } from "../../../../application/ports/prompt.js"; +import type { SecretResolver, StreamManager } from "./runtime.js"; + +/** CLI command context; application workflows receive only narrower execution scopes. */ +export interface ExecutionContext extends TransactionScope { + readonly config: Config; + readonly networkRegistry: NetworkRegistry; + readonly streams: StreamManager; + readonly secrets: SecretResolver; + readonly prompt: PromptPort; + readonly output: OutputMode; + readonly network?: string; +} diff --git a/ts/src/adapters/inbound/cli/contracts/index.ts b/ts/src/adapters/inbound/cli/contracts/index.ts new file mode 100644 index 000000000..5b7ce27aa --- /dev/null +++ b/ts/src/adapters/inbound/cli/contracts/index.ts @@ -0,0 +1,4 @@ +export * from "./command.js"; +export * from "./envelope.js"; +export * from "./execution-context.js"; +export * from "./runtime.js"; diff --git a/ts/src/adapters/inbound/cli/contracts/runtime.ts b/ts/src/adapters/inbound/cli/contracts/runtime.ts new file mode 100644 index 000000000..06c6788de --- /dev/null +++ b/ts/src/adapters/inbound/cli/contracts/runtime.ts @@ -0,0 +1,40 @@ +/** CLI runtime seams implemented by stream and secret input adapters. */ +import type { NetworkDescriptor } from "../../../../domain/types/network.js"; + +export type DiagnosticLevel = "info" | "debug" | "warn"; + +export interface StreamManager { + result(text: string): void; + diagnostic(level: DiagnosticLevel, msg: string): void; + /** always-on stderr line. */ + errorLine(msg: string): void; + /** intermediate progress frame → stderr plain line; null is skipped (StreamManager). */ + event(frame: string | null): void; + readStdinOnce(): string; + /** warnings accumulated for the JSON envelope's meta.warnings. */ + warnings(): string[]; +} + +export type SecretKind = "password" | "privateKey" | "mnemonic" | "tx" | "message"; +export interface SecretResolver { + masterPassword(): string; + /** whether a master-password source exists, WITHOUT consuming stdin. */ + hasMasterPassword(): boolean; + /** whether a source for `kind` is configured, WITHOUT consuming it. */ + has(kind: SecretKind): boolean; + read(kind: SecretKind): string; + /** read a required source; missing → missing_option (usage), not secret_source_error. */ + require(kind: SecretKind): string; + /** exactly-one selector: inline value XOR the file/stdin source for `kind`. */ + pick(inline: string | undefined, kind: SecretKind, inlineFlag: string): string; + /** resolve a non-password secret: stdin source → hidden prompt → missing_option. */ + resolveSecret(kind: "mnemonic" | "privateKey"): Promise; + /** establish/verify the master password before synchronous keystore use. */ + primePassword(plan: { mode: "set" | "verify"; verify?: (pw: string) => boolean }): Promise; +} + +/** Mutable dispatch contract: CliShell records the in-flight command (+ resolved network) here so the + * runner's single terminal catch can attach commandId/net to the error envelope across yargs. */ +export interface SessionRef { + current?: { commandId: string; net?: NetworkDescriptor }; +} diff --git a/ts/src/adapters/inbound/cli/globals/index.ts b/ts/src/adapters/inbound/cli/globals/index.ts new file mode 100644 index 000000000..63b9c14eb --- /dev/null +++ b/ts/src/adapters/inbound/cli/globals/index.ts @@ -0,0 +1,132 @@ +/** + * Single source of truth for GLOBAL (kubectl-style) flags — the flag list, arity (kinds, aliases, + * yargs scalar types, choices), per-flag COERCION, and the DOCUMENTATION facts. Everything that + * otherwise drifts across a flag's touch-points derives from GLOBAL_FLAG_SPECS: + * - bootstrap/runner's pre-yargs scan (token maps via globalTokenMaps + coerceGlobalValue), + * - cli/shell's yargs `.options()` declaration (globalYargsOptions), and + * - cli/help/catalog's documentation projection (globalFlagDoc over description/defaultValue). + * Add a global flag HERE only; every layer is a projection. + * + * Array order is the documented display order (it's what cli/help renders). Other projections are + * order-independent. + */ +import type { SecretKind } from "../contracts/index.js"; + +export type GlobalFlagKind = "value" | "boolean" | "secret-stdin"; + +export interface GlobalFlagSpec { + /** kebab flag name, no leading dashes (e.g. "wait-timeout", "password-stdin"). */ + name: string; + /** single-char alias, no dash (e.g. "o", "v"). */ + alias?: string; + kind: GlobalFlagKind; + /** value flags only: yargs scalar type. */ + valueType?: "string" | "number"; + /** value flags only: restrict accepted values (yargs `choices`). */ + choices?: readonly string[]; + /** secret-stdin flags only: which secret kind this `--` binds. */ + secretKey?: SecretKind; + /** override the derived camelCase field name when the runtime Globals key differs from the flag + * (e.g. `--timeout` → `timeoutMs`); defaults to globalFlagField(name). */ + field?: string; + /** human-readable flag description (cli/help text + --json-schema catalog). */ + description: string; + /** documented default; absent → rendered as plain "[optional]". */ + defaultValue?: string | number | boolean; + /** secret-stdin only: documented under each owning command, not in the global flag list. */ + commandScoped?: boolean; +} + +export const GLOBAL_FLAG_SPECS: readonly GlobalFlagSpec[] = [ + { name: "output", alias: "o", kind: "value", valueType: "string", choices: ["text", "json"], + description: "result format", defaultValue: "config.defaultOutput (built-in: text)" }, + { name: "network", kind: "value", valueType: "string", + description: "network id or alias, e.g. tron:mainnet, tron, nile, shasta; chain commands fall back to config.defaultNetwork when omitted" }, + { name: "account", kind: "value", valueType: "string", + description: "accountId, label, or address for wallet-bound commands; falls back to the active account set by use" }, + { name: "timeout", kind: "value", valueType: "number", field: "timeoutMs", + description: "per RPC/device call timeout, in milliseconds", defaultValue: "config.timeoutMs (built-in: 60000)" }, + { name: "verbose", alias: "v", kind: "boolean", + description: "show extra diagnostic output", defaultValue: false }, + { name: "wait", kind: "boolean", + description: "after broadcast, poll until the tx is confirmed/failed before returning; default returns the submitted txid without blocking", defaultValue: false }, + { name: "wait-timeout", kind: "value", valueType: "number", field: "waitTimeoutMs", + description: "--wait polling cap, in milliseconds; on timeout return the submitted receipt", defaultValue: 60000 }, + { name: "password-stdin", kind: "secret-stdin", secretKey: "password", + description: "read the master password from stdin (fd 0); only one *-stdin flag can consume stdin per run" }, + { name: "private-key-stdin", kind: "secret-stdin", secretKey: "privateKey", commandScoped: true, + description: "read the private key from stdin (fd 0)" }, + { name: "mnemonic-stdin", kind: "secret-stdin", secretKey: "mnemonic", commandScoped: true, + description: "read the BIP39 mnemonic from stdin (fd 0)" }, + { name: "tx-stdin", kind: "secret-stdin", secretKey: "tx", commandScoped: true, + description: "read the signed transaction JSON from stdin (fd 0)" }, + { name: "message-stdin", kind: "secret-stdin", secretKey: "message", commandScoped: true, + description: "read the message bytes/text from stdin (fd 0)" }, +]; + +/** kebab → camel default; the `field` override wins when the runtime Globals key differs from the flag. */ +export const globalFlagField = (name: string): string => name.replace(/-([a-z0-9])/g, (_m, c) => c.toUpperCase()); +const specField = (f: GlobalFlagSpec): string => f.field ?? globalFlagField(f.name); + +/** value-flag spec keyed by its runtime Globals field, for coercion. */ +const VALUE_SPEC_BY_FIELD: Record = Object.fromEntries( + GLOBAL_FLAG_SPECS.filter((f) => f.kind === "value").map((f) => [specField(f), f]), +); + +/** + * Coerce a raw value-flag string per its spec; `undefined` = invalid (caller falls back to default). + * Derives entirely from valueType/choices: number flags accept a non-negative finite value (true of + * every number global today — add a per-flag `min` if that ever changes), choice flags must match, + * everything else passes through as a string. + */ +export function coerceGlobalValue(field: string, raw: string): string | number | undefined { + const spec = VALUE_SPEC_BY_FIELD[field]; + if (!spec) return raw; + if (spec.valueType === "number") { + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? n : undefined; + } + if (spec.choices) return spec.choices.includes(raw) ? raw : undefined; + return raw; +} + +interface YargsOption { + type: "string" | "number" | "boolean"; + choices?: readonly string[]; + alias?: string; +} + +/** yargs `.options()` shape, keyed by kebab flag name. secret-stdin + boolean flags are presence flags. */ +export function globalYargsOptions(): Record { + const out: Record = {}; + for (const f of GLOBAL_FLAG_SPECS) { + const o: YargsOption = { type: f.kind === "value" ? f.valueType! : "boolean" }; + if (f.choices) o.choices = f.choices; + if (f.alias) o.alias = f.alias; + out[f.name] = o; + } + return out; +} + +/** Token-keyed lookup maps for the pre-yargs scan (bootstrap/runner). Sibling projection to + * globalYargsOptions — the other layer that derives from GLOBAL_FLAG_SPECS. */ +export interface GlobalTokenMaps { + /** flag token (`--long` or `-alias`) → runtime Globals field. */ + valueFlags: Record; + booleanFlags: Record; + /** `---stdin` → secret kind (the only stdin source is fd 0). */ + secretStdinFlags: Record; +} + +export function globalTokenMaps(): GlobalTokenMaps { + const valueFlags: Record = {}; + const booleanFlags: Record = {}; + const secretStdinFlags: Record = {}; + for (const f of GLOBAL_FLAG_SPECS) { + const tokens = f.alias ? [`--${f.name}`, `-${f.alias}`] : [`--${f.name}`]; + if (f.kind === "value") for (const t of tokens) valueFlags[t] = specField(f); + else if (f.kind === "boolean") for (const t of tokens) booleanFlags[t] = specField(f); + else secretStdinFlags[`--${f.name}`] = f.secretKey!; + } + return { valueFlags, booleanFlags, secretStdinFlags }; +} diff --git a/ts/src/adapters/inbound/cli/help/catalog.ts b/ts/src/adapters/inbound/cli/help/catalog.ts new file mode 100644 index 000000000..b1c422f89 --- /dev/null +++ b/ts/src/adapters/inbound/cli/help/catalog.ts @@ -0,0 +1,86 @@ +/** + * Help catalog — the machine-readable half of HelpService: the structured global/stdin flag + * model and the `--json-schema` command catalog. The human text renderer lives in./index. + * Single structured source: the flag model is rendered as text in command --help AND emitted as + * `globalFlags` in the root catalog. + */ +import { z } from "zod"; +import type { ChainFamily } from "../../../../domain/types/index.js"; +import type { CommandDefinition } from "../contracts/index.js"; +import { CommandRegistry } from "../registry/index.js"; +import { commandId } from "../command-id.js"; +import { GLOBAL_FLAG_SPECS, type GlobalFlagSpec } from "../globals/index.js"; + +// Flags accepted on every command (kubectl-style globals + secret channels). The flag model — arity, +// descriptions, defaults, and the global-vs-command-scoped split — is owned by domain metadata +// GLOBAL_FLAG_SPECS; this layer is the rendered shape (`--flag`/`-alias` tokens) and a pure +// projection over it. GLOBAL_FLAGS = the globally-listed flags; STDIN_FLAGS = the command-scoped ones. +export interface GlobalFlag { + flag: string; + alias?: string; + type: "string" | "number" | "boolean"; + values?: string[]; + description: string; + optional?: boolean; + defaultValue?: string | number | boolean; +} + +/** spec → rendered GlobalFlag (adds the `--`/`-` token prefixes the help/catalog surface use). */ +function globalFlagDoc(f: GlobalFlagSpec): GlobalFlag { + return { + flag: `--${f.name}`, + ...(f.alias ? { alias: `-${f.alias}` } : {}), + type: f.kind === "value" ? f.valueType! : "boolean", + ...(f.choices ? { values: [...f.choices] } : {}), + description: f.description, + ...(f.defaultValue !== undefined ? { defaultValue: f.defaultValue } : {}), + }; +} + +export const GLOBAL_FLAGS: readonly GlobalFlag[] = GLOBAL_FLAG_SPECS.filter((f) => !f.commandScoped).map(globalFlagDoc); + +// stdin channel → its documented --*-stdin flag, derived from the command-scoped specs (keyed by secretKey). +const STDIN_FLAGS = Object.fromEntries( + GLOBAL_FLAG_SPECS.filter((f) => f.commandScoped).map((f) => [f.secretKey, globalFlagDoc(f)]), +) as Record, GlobalFlag>; + +export function inputFlagsFor(cmd: CommandDefinition): readonly GlobalFlag[] { + return cmd.stdin ? [STDIN_FLAGS[cmd.stdin]] : []; +} + +/** usage line: the typed path is complete for both kinds (neutral = full, chain = logical). */ +export function commandUsage(cmd: CommandDefinition): string { + return `wallet-cli ${cmd.path.join(" ")} [flags]`; +} + +/** never let one un-convertible schema break the whole catalog. */ +function commandInputSchema(input: CommandDefinition["input"]): unknown { + try { + return z.toJSONSchema(input as z.ZodType); + } catch { + return { type: "object" }; + } +} + +/** machine-readable catalog of the whole command surface — the agent's single discovery call. */ +export function buildCatalog(registry: CommandRegistry, version: string, familyFilter?: ChainFamily): string { + const commands = registry + .all() + .filter((c) => !familyFilter || c.family === familyFilter) + .map((cmd) => ({ cmd, id: commandId(cmd) })) + .sort((a, b) => a.id.localeCompare(b.id)) + .map(({ cmd, id }) => ({ + id, + kind: cmd.family ? "chain" : "neutral", + ...(cmd.family ? { family: cmd.family } : {}), + path: cmd.path, + usage: commandUsage(cmd), + summary: cmd.summary ?? "", + requires: { network: cmd.network, auth: cmd.auth, wallet: cmd.wallet }, + ...(cmd.capability ? { capability: cmd.capability } : {}), + examples: cmd.examples.map((e) => e.cmd), + ...(inputFlagsFor(cmd).length ? { inputFlags: inputFlagsFor(cmd) } : {}), + inputSchema: commandInputSchema(cmd.input), + })); + return JSON.stringify({ tool: "wallet-cli", version, globalFlags: GLOBAL_FLAGS, commands }); +} diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts new file mode 100644 index 000000000..795c49ed6 --- /dev/null +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -0,0 +1,396 @@ +/** + * HelpService — --help / --version / --json-schema. Zod-driven: every flag's help, + * required/optional/default, examples, and the agent JSON-schema come from the command's + * zod fields/input; one schema supplies validation, types, help, and agent schema. + * + * Two command kinds, discriminated by `family`: neutral (full path) and chain (logical path, + * per-family impls). A leading family token (e.g. tron) is an optional addressing prefix here. + */ +import { z } from "zod"; +import type { ChainFamily, ExitCode } from "../../../../domain/types/index.js"; +import type { CommandDefinition, StreamManager } from "../contracts/index.js"; +import { CommandRegistry } from "../registry/index.js"; +import { introspectFields, type FieldInfo } from "../arity/index.js"; +import { GLOBAL_FLAGS, type GlobalFlag, inputFlagsFor, buildCatalog } from "./catalog.js"; + +const META = new Set(["--help", "-h", "--version", "-V", "--json-schema"]); + +export function hasMeta(tokens: string[]): boolean { + return tokens.some((t) => META.has(t)); +} + +export class HelpService { + constructor( + private readonly registry: CommandRegistry, + private readonly streams: StreamManager, + private readonly version: string, + ) {} + + handleMeta(tokens: string[]): ExitCode { + if (tokens.includes("--version") || tokens.includes("-V")) { + this.streams.result(this.version); + return 0; + } + const positionals = tokens.filter((t) => !t.startsWith("-")); + const { family, path } = this.#split(positionals); + const concrete = this.#resolveConcrete(family, path); + + if (tokens.includes("--json-schema")) { + if (concrete) { + this.streams.result(JSON.stringify(z.toJSONSchema(concrete.input))); + return 0; + } + // no concrete command → machine catalog (every command + flags), optionally scoped to a + // chain family (`tron --json-schema`). Mirrors the help tree. + this.streams.result(this.#catalog(family)); + return 0; + } + + if (concrete) { + this.streams.result(this.#renderCommand(concrete)); + return 0; + } + if (!family && path.length === 1 && this.#isNeutralGroup(path[0]!)) { + this.streams.result(this.#renderNeutralGroup(path[0]!)); + return 0; + } + if (path.length > 1 && this.#isChainGroup(path[0]!)) { + let candidates = this.registry.resolveCandidates(path); + if (family) candidates = candidates.filter((c) => c.family === family); + if (candidates.length > 0) { + this.streams.result(this.#renderLogicalCommand(path, candidates)); + return 0; + } + } + this.streams.result(this.#renderTree(path[0])); + return 0; + } + + /** strip an optional leading family token (e.g. tron) — a help/catalog addressing prefix. */ + #split(positionals: string[]): { family?: ChainFamily; path: string[] } { + const head = positionals[0]; + if (head && (this.registry.families() as string[]).includes(head)) { + return { family: head as ChainFamily, path: positionals.slice(1) }; + } + return { path: positionals }; + } + + /** resolve to a single command: a neutral command by full path, or a family-pinned chain command. */ + #resolveConcrete(family: ChainFamily | undefined, path: string[]): CommandDefinition | null { + if (path.length === 0) return null; + if (family) return this.registry.resolveForFamily(path, family); + const neutral = this.registry.resolveNeutral(path); + if (neutral) return neutral; + // single-segment chain leaf (e.g. `block`): resolve by its HEAD so `block`, `block 123`, and even + // `block ` all render the leaf help instead of a phantom `block COMMAND` group. Group heads + // like `account` have no command at the bare path, so they stay groups (headLeaf is undefined). + const headLeaf = this.registry.resolveCandidates([path[0]!])[0]; + if (headLeaf && headLeaf.path.length === 1) return headLeaf; + return null; + } + + #renderTree(head?: string): string { + if (!head) return this.#renderRoot(); + if (this.#isChainGroup(head)) return this.#renderLogicalNs(head); + if (this.#isNeutralGroup(head)) return this.#renderNeutralGroup(head); + return this.#renderRoot(); + } + + /** top-level overview: first release presents TRON as the product surface. + * Docker-style three groups: Common (高频入口) / Management (链上资源名词) / Commands (本机治理). */ + #renderRoot(): string { + const common = [ + ["create", "Create a new HD wallet (BIP39 seed)", ""], + ["import", "Import a wallet", ""], + ["list", "List wallets / accounts", ""], + ] as const; + const management = [ + ["account", "Query on-chain account state", ""], + ["token", "Manage the token address book and query tokens", ""], + ["tx", "Build, send, broadcast, and inspect transactions", ""], + ["contract", "Call, send, deploy, and inspect smart contracts", ""], + ["stake", "Stake / delegate resources", "tron"], + ["message", "Sign arbitrary messages", ""], + ["block", "Get a block (latest if omitted)", ""], + ] as const; + const commands = [ + ["use", "Set the active account", ""], + ["current", "Show the current (active) account", ""], + ["rename", "Rename an account label", ""], + ["derive", "Derive the next HD account from a seed wallet", ""], + ["backup", "Export an account's secret + metadata (0600)", ""], + ["delete", "Delete a wallet / account", ""], + ["config", "Show / get / set configuration values", ""], + ["networks", "List known networks", ""], + ] as const; + const sections = [common, management, commands] as const; + const nameWidth = Math.max(...sections.flat().map(([name]) => name.length)) + 2; + // chain-only groups carry a right-hand (family) tag; align it past the widest description. + const tagCol = Math.max(...sections.flat().map(([, desc]) => desc.length)) + 2; + const commandRow = (name: string, desc: string, tag: string): string => { + const body = ` ${name.padEnd(nameWidth)}${dim(desc)}`; + return tag ? `${body}${" ".repeat(Math.max(2, tagCol - desc.length))}(${tag})` : body.trimEnd(); + }; + const row = (width: number) => (name: string, desc: string): string => ` ${name.padEnd(width)}${desc ? dim(desc) : ""}`.trimEnd(); + const optionRows = [ + ["-o, --output string", 'Output format ("text", "json") (default from config)'], + ["--network string", 'Network id or alias, e.g. "tron", "nile", "shasta"'], + ["--account string", "Account label or address to act as (overrides active)"], + ["--timeout int", "Request timeout in milliseconds"], + ["-v, --verbose", "Verbose / debug logging"], + ["-h, --help", "Show help"], + ["-V, --version", "Print version information and quit"], + ] as const; + const optionRow = row(Math.max(...optionRows.map(([name]) => name.length)) + 2); + + // Usage first, description after (: 描述统一在 Usage 之后); root Usage is the inline form. + const lines = [ + `${bold("Usage:")} wallet-cli [OPTIONS] COMMAND`, + "", + `${bold("wallet-cli")} — CLI wallet for TRON.`, + "Agent-first: deterministic exit codes, JSON output, no interactive prompts.", + "", + bold("Common Commands:"), + ]; + for (const [name, desc, tag] of common) lines.push(commandRow(name, desc, tag)); + + lines.push("", bold("Management Commands:")); + for (const [name, desc, tag] of management) lines.push(commandRow(name, desc, tag)); + + lines.push("", bold("Commands:")); + for (const [name, desc, tag] of commands) lines.push(commandRow(name, desc, tag)); + + lines.push("", bold("Global Options:")); + for (const [name, desc] of optionRows) lines.push(optionRow(name, desc)); + lines.push("", "Run 'wallet-cli COMMAND --help' for more information on a command."); + return lines.join("\n"); + } + + /** neutral group (`import --help`): list the group's sub-commands. Derived from the registry. */ + #renderNeutralGroup(head: string): string { + const cmds = this.#neutralGroupCommands(head); + const rows = cmds.map((c) => [c.path[1] ?? "", c.summary ?? ""] as const); + return this.#renderGroup(head, rows, 1000); + } + + /** logical resource group (`account --help`): default surface, implementations chosen by --network/defaultNetwork. */ + #renderLogicalNs(group: string): string { + const commands = this.#chainGroupCommands(group); + const rows = commands.map((c) => [c.path[1] ?? "", c.summary ?? ""] as const); + return this.#renderGroup(group, rows, 18); + } + + /** shared group skeleton (群组层): inline Usage → description → verb list → footer. */ + #renderGroup(group: string, rows: ReadonlyArray, maxWidth: number): string { + const width = Math.min(maxWidth, Math.max(0, ...rows.map(([verb]) => verb.length)) + 2); + const lines = [`${bold("Usage:")} wallet-cli ${group} COMMAND`, ""]; + const desc = GROUP_DESCRIPTIONS[group]; + if (desc) lines.push(desc, ""); + lines.push(bold("Commands:")); + for (const [verb, summary] of rows) lines.push(` ${verb.padEnd(width)} ${summary}`.trimEnd()); + lines.push("", `Run 'wallet-cli ${group} COMMAND --help' for more information on a command.`); + return lines.join("\n"); + } + + /** logical leaf (`account balance --help`): merge per-family flags; addressing/auth taken from the first impl. */ + #renderLogicalCommand(path: string[], candidates: CommandDefinition[]): string { + const fields = new Map(); + for (const cmd of candidates) { + for (const f of introspectFields(cmd.fields)) fields.set(f.name, f); + } + const base = candidates[0]!; + return this.#renderLeaf({ + path, + summary: base.summary, + network: base.network, + auth: base.auth, + wallet: base.wallet, + broadcasts: base.broadcasts, + fields: [...fields.values()], + inputFlags: inputFlagsFor(base), + examples: base.examples, + }); + } + + #renderCommand(cmd: CommandDefinition): string { + return this.#renderLeaf({ + path: cmd.path, + summary: cmd.summary, + network: cmd.network, + auth: cmd.auth, + wallet: cmd.wallet, + broadcasts: cmd.broadcasts, + fields: introspectFields(cmd.fields), + inputFlags: inputFlagsFor(cmd), + examples: cmd.examples, + }); + } + + /** shared leaf skeleton (叶子层): Usage → description → Requires → Flags → Input flags → Global flags → Examples. */ + #renderLeaf(c: { + path: string[]; + summary?: string; + network: CommandDefinition["network"]; + auth: CommandDefinition["auth"]; + wallet: CommandDefinition["wallet"]; + broadcasts?: boolean; + fields: FieldInfo[]; + inputFlags: readonly GlobalFlag[]; + examples: CommandDefinition["examples"]; + }): string { + const lines = ["Usage:", ` wallet-cli ${c.path.join(" ")} [flags]`]; + if (c.summary) lines.push("", c.summary); + + const requires: string[] = []; + if (c.network === "required") requires.push("--network "); + if (c.auth === "required") requires.push("master password — pass --password-stdin for non-interactive use, or enter it interactively in a TTY"); + if (c.wallet !== "none") requires.push("an account — defaults to active; override with --account (or `use `)"); + if (requires.length) { + lines.push("", "Requires:"); + for (const r of requires) lines.push(` ${r}`); + } + + if (c.fields.length) { + const heads = c.fields.map(flagHead); + const width = Math.min(34, Math.max(...heads.map((h) => h.length))); + lines.push("", "Flags:"); + c.fields.forEach((f, i) => { + const desc = f.description ?? ""; + const tag = flagTag(f); + lines.push(` ${heads[i]!.padEnd(width)} ${desc}${desc && tag ? " " : ""}${tag}`.trimEnd()); + }); + } + + if (c.inputFlags.length) { + lines.push("", "Input flags:"); + for (const f of c.inputFlags) lines.push(globalFlagLine(f)); + } + + lines.push("", "Global flags:"); + // curated per command: --network only when the command selects a network; --password-stdin + // only when it requires unlock; --account is surfaced via Requires, not repeated here. + for (const g of globalFlagsForText(c.network, c.auth, c.broadcasts ?? false)) lines.push(globalFlagLine(g)); + + if (c.examples.length) { + lines.push("", "Examples:"); + for (const e of c.examples) lines.push(` ${e.cmd}${e.note ? ` # ${e.note}` : ""}`); + } + return lines.join("\n"); + } + + /** chain groups = first path segment of every family-bound command. */ + #chainGroups(): string[] { + const seen = new Set(); + const out: string[] = []; + for (const c of this.registry.all()) { + if (!c.family) continue; + const group = c.path[0]; + if (group && !seen.has(group)) (seen.add(group), out.push(group)); + } + return out; + } + + #isChainGroup(group: string): boolean { + return this.#chainGroups().includes(group); + } + + /** chain group sub-commands, deduped across families by logical path. */ + #chainGroupCommands(group: string): Array<{ path: string[]; summary?: string }> { + const seen = new Set(); + const out: Array<{ path: string[]; summary?: string }> = []; + for (const c of this.registry.all()) { + if (!c.family || c.path[0] !== group) continue; + const key = c.path.join("."); + if (!seen.has(key)) (seen.add(key), out.push({ path: c.path, summary: c.summary })); + } + return out; + } + + /** neutral groups = heads of neutral commands that have sub-verbs (e.g. import). */ + #neutralGroupCommands(head: string): CommandDefinition[] { + return this.registry.all().filter((c) => !c.family && c.path[0] === head && c.path.length > 1); + } + + #isNeutralGroup(head: string): boolean { + return this.#neutralGroupCommands(head).length > 0; + } + + /** machine-readable catalog of the whole command surface — the agent's single discovery call. */ + #catalog(familyFilter?: ChainFamily): string { + return buildCatalog(this.registry, this.version, familyFilter); + } +} + +/** "--flag " header for a command flag — enum fields list their choices instead of . */ +function flagHead(f: FieldInfo): string { + const typ = f.choices ? ` <${f.choices.join("|")}>` : f.baseType === "boolean" ? "" : ` <${f.baseType}>`; + return `--${f.kebab}${typ}`; +} + +/** "[required]" / "[optional, default: X]" / "[optional]" tag derived from the zod schema. */ +function flagTag(f: FieldInfo): string { + if (!f.optional && !f.hasDefault) return "[required]"; + if (f.hasDefault) return `[optional, default: ${formatDefault(f.defaultValue)}]`; + return "[optional]"; +} + +function formatDefault(v: unknown): string { + if (typeof v === "string") return v === "" ? '""' : v; + return String(v); +} + +// Per-command "Global flags" projection: output/timeout/verbose always; --network only when the +// command selects a network; --password-stdin only when it requires unlock; --wait/--wait-timeout +// only for ✍️ broadcast commands; --account is never repeated here (it surfaces under Requires). The full +// GLOBAL_FLAGS array still backs the --json-schema catalog. +function globalFlagsForText(network: CommandDefinition["network"], auth: CommandDefinition["auth"], broadcasts: boolean): GlobalFlag[] { + return GLOBAL_FLAGS.filter((g) => { + if (g.flag === "--account") return false; + if (g.flag === "--network") return network !== "none"; + if (g.flag === "--password-stdin") return auth === "required"; + if (g.flag === "--wait" || g.flag === "--wait-timeout") return broadcasts; + return true; + }); +} + +/** one rendered " --flag description [tag]" line, shared by Input flags and Global flags. */ +function globalFlagLine(g: GlobalFlag): string { + const tag = globalFlagTag(g); + return ` ${globalFlagHead(g).padEnd(26)} ${g.description}${g.description && tag ? " " : ""}${tag}`.trimEnd(); +} + +// Group (群组层) one-line descriptions, keyed by the registry group head. Only groups that surface a +// ` --help` page need an entry; absent → the description line is omitted. +const GROUP_DESCRIPTIONS: Record = { + import: "Import a wallet from an existing secret or device.", + account: "Query on-chain account state.", + token: "Manage the token address book and query tokens.", + tx: "Build, send, broadcast, and inspect transactions.", + contract: "Call, send, deploy, and inspect smart contracts.", + stake: "Stake / delegate resources.", + message: "Sign arbitrary messages.", + block: "Get a block (latest if omitted).", +}; + +/** "--output, -o " style header for text help. */ +function globalFlagHead(g: GlobalFlag): string { + const head = g.alias ? `${g.flag}, ${g.alias}` : g.flag; + const typ = g.type === "boolean" ? "" : ` <${g.values ? g.values.join("|") : g.type}>`; + return `${head}${typ}`; +} + +function globalFlagTag(g: GlobalFlag): string { + if (g.defaultValue !== undefined) return `[optional, default: ${formatDefault(g.defaultValue)}]`; + return "[optional]"; +} + +/** color only when stdout is a TTY and NO_COLOR is unset — piped/redirected help stays plain. */ +function colorOn(): boolean { + return !!process.stdout.isTTY && !process.env.NO_COLOR; +} +function bold(s: string): string { + return colorOn() ? `\x1b[1m${s}\x1b[0m` : s; +} +function dim(s: string): string { + return colorOn() ? `\x1b[2m${s}\x1b[0m` : s; +} diff --git a/ts/src/adapters/inbound/cli/input/prompt/index.ts b/ts/src/adapters/inbound/cli/input/prompt/index.ts new file mode 100644 index 000000000..6b0a02975 --- /dev/null +++ b/ts/src/adapters/inbound/cli/input/prompt/index.ts @@ -0,0 +1,249 @@ +/** + * Prompter — the single owner of interactive TTY I/O. Logic (validation loops, + * confirm match, select navigation) is backend-agnostic; the real backend talks to + * /dev/tty. Prompts/echo never touch stdout. + */ +import { openSync, closeSync } from "node:fs"; +import { ReadStream } from "node:tty"; +import * as readline from "node:readline"; +import { ExecutionError } from "../../../../../domain/errors/index.js"; + +export type KeyEvent = { name?: string; ctrl?: boolean; sequence?: string }; +export interface Choice { value: T; label: string } + +export interface PromptBackend { + isTTY(): boolean; + /** read one line; when hidden, keystrokes are not echoed. */ + question(prompt: string, hidden: boolean): Promise; + /** resolve on the next keypress (raw mode must be active). */ + readKey(): Promise; + write(s: string): void; + beginRaw(): void; + endRaw(): void; + /** release any held resources (e.g. the /dev/tty stream) so the process can exit. */ + close?(): void; +} + +export class Prompter { + #renderedSelectLines = 0; + #interactive = true; + + constructor(private readonly be: PromptBackend) {} + + /** + * Per-dispatch interaction policy. Dispatch sets this per command so non-interactive commands + * behave as if there were no TTY — every prompt site already gates on isTTY(), so flipping this + * off makes gap-fill / password / secret prompts fail-fast on missing input instead of blocking. + */ + setInteractive(allowed: boolean): void { this.#interactive = allowed; } + + isTTY(): boolean { return this.#interactive && this.be.isTTY(); } + + /** release backend resources at end of run (no-op for in-memory backends). */ + close(): void { this.be.close?.(); } + + async text(o: { label: string; validate?: (v: string) => string | null }): Promise { + for (;;) { + const v = (await this.be.question(`${color("cyan", "?")} ${o.label}: `, false)).trim(); + const err = o.validate?.(v); + if (err) { this.be.write(`${color("red", " x")} ${err}\n`); continue; } + return v; + } + } + + async hidden(o: { label: string; confirm?: boolean; confirmLabel?: string; validate?: (v: string) => string | null }): Promise { + for (;;) { + const v = await this.be.question(`${color("cyan", "?")} ${o.label}: `, true); + const err = o.validate?.(v); + if (err) { this.be.write(`${color("red", " x")} ${err}\n`); continue; } + if (o.confirm) { + const again = await this.be.question(`${color("cyan", "?")} ${o.confirmLabel ?? "Confirm"}: `, true); + if (v !== again) { this.be.write(`${color("red", " x")} entries do not match\n`); continue; } + } + return v; + } + } + + async confirm(o: { label: string; expect?: string }): Promise { + const suffix = o.expect === undefined ? " [y/N]" : ""; + const v = await this.be.question(`${color("yellow", "?")} ${o.label}${suffix}: `, false); + if (o.expect !== undefined) return v.trim() === o.expect; + return /^y(es)?$/i.test(v.trim()); + } + + async select(o: { label: string; choices: Choice[]; loadMore?: () => Promise[]> }): Promise { + let items = [...o.choices]; + let idx = 0; + this.be.beginRaw(); + try { + this.#renderedSelectLines = 0; + this.#render(o.label, items, idx); + for (;;) { + const k = await this.be.readKey(); + if (k.ctrl && k.name === "c") throw new ExecutionError("aborted", "cancelled"); + if (k.name === "up") idx = Math.max(0, idx - 1); + else if (k.name === "down") { + if (idx === items.length - 1 && o.loadMore) { + const more = await o.loadMore(); + if (more.length > items.length) { items = more; idx++; } + } else { + idx = Math.min(items.length - 1, idx + 1); + } + } else if (k.name === "return") return items[idx]!.value; + this.#render(o.label, items, idx); + } + } finally { + this.be.endRaw(); + this.#renderedSelectLines = 0; + } + } + + #render(label: string, items: Choice[], idx: number): void { + if (this.#renderedSelectLines > 0) { + this.be.write(`\x1b[${this.#renderedSelectLines}F\x1b[J`); + } + const lines = items.map((c, i) => `${i === idx ? color("cyan", ">") : " "} ${c.label}`); + const frame = [ + `${color("cyan", "?")} ${label} ${dim("(Up/Down, Enter)")}`, + ...lines, + ]; + this.#renderedSelectLines = frame.length; + this.be.write(`${frame.join("\n")}\n`); + } +} + +function color(kind: "cyan" | "red" | "yellow" | "green", s: string): string { + if (process.env.NO_COLOR) return s; + const code = { cyan: 36, red: 31, yellow: 33, green: 32 }[kind]; + return `\x1b[${code}m${s}\x1b[0m`; +} + +function dim(s: string): string { + return process.env.NO_COLOR ? s : `\x1b[2m${s}\x1b[0m`; +} + +/** Real backend: reads /dev/tty, writes prompts to /dev/tty (never stdout). */ +export class TtyBackend implements PromptBackend { + #tty: boolean; + #fd?: number; + #input?: ReadStream; + #keyQueue: KeyEvent[] = []; + #pendingKey?: (key: KeyEvent) => void; + #keyListener?: (s: string, key: KeyEvent) => void; + constructor() { + // Probe for a controlling terminal without holding the fd; the real stream opens on first prompt. + try { + closeSync(openSync("/dev/tty", "r")); + this.#tty = true; + } catch { + this.#tty = false; + } + } + isTTY(): boolean { return this.#tty; } + write(s: string): void { process.stderr.write(s); } + + /** + * ONE persistent tty.ReadStream for the whole run (a real TTY stream, unlike fs.createReadStream). + * Per-prompt fds caused two failures: an undestroyed fs stream hung the event loop, and opening a + * fresh fd per prompt let the previous prompt's async teardown reset the terminal AFTER the next + * prompt set raw mode → the confirm prompt echoed the secret. A single reused stream avoids both; + * the runner calls close once at the end to release it so the process exits. + */ + #stream(): ReadStream { + if (!this.#input) { + this.#fd = openSync("/dev/tty", "r"); + this.#input = new ReadStream(this.#fd); + } + return this.#input; + } + + /** + * Visible input goes through readline (echoes typed text, manages the prompt). Hidden input is read + * MANUALLY in raw mode: readline's terminal-mode redraw emits `ESC[1G ESC[0J` which erases the + * just-written prompt on a real terminal (prompt vanished → looked hung). A raw manual read writes + * the prompt once and never redraws; raw mode means the OS never echoes the secret. + */ + question(prompt: string, hidden: boolean): Promise { + const input = this.#stream(); + if (!hidden) { + const rl = readline.createInterface({ input, output: process.stderr, terminal: true }); + return new Promise((resolve) => { + rl.question(prompt, (ans) => { rl.close(); input.pause(); resolve(ans); }); + }); + } + return new Promise((resolve) => { + process.stderr.write(prompt); + input.setRawMode(true); + input.resume(); + let buf = ""; + const finish = (val: string): void => { + input.setRawMode(false); + input.off("data", onData); + input.pause(); + process.stderr.write("\n"); + resolve(val); + }; + const onData = (d: Buffer): void => { + for (const ch of d.toString("utf8")) { + const code = ch.charCodeAt(0); + if (ch === "\r" || ch === "\n") return finish(buf); // Enter + if (code === 3) { process.stderr.write("\n"); process.exit(130); } // Ctrl-C + if (code === 4) return finish(buf); // Ctrl-D + if (code === 127 || code === 8) { buf = buf.slice(0, -1); continue; } // Backspace + if (code < 32) continue; // ignore other control chars + buf += ch; + } + }; + input.on("data", onData); + }); + } + + beginRaw(): void { + const s = this.#stream(); + readline.emitKeypressEvents(s); + if (!this.#keyListener) { + this.#keyListener = (_s: string, key: KeyEvent) => { + if (this.#pendingKey) { + const resolve = this.#pendingKey; + this.#pendingKey = undefined; + resolve(key ?? {}); + return; + } + this.#keyQueue.push(key ?? {}); + }; + s.on("keypress", this.#keyListener); + } + s.setRawMode(true); + s.resume(); + } + endRaw(): void { + if (this.#keyListener) { + this.#input?.off("keypress", this.#keyListener); + this.#keyListener = undefined; + } + this.#pendingKey = undefined; + this.#keyQueue = []; + this.#input?.setRawMode(false); + this.#input?.pause(); + } + readKey(): Promise { + const queued = this.#keyQueue.shift(); + if (queued) return Promise.resolve(queued); + return new Promise((resolve) => { + this.#pendingKey = resolve; + }); + } + /** Release the persistent /dev/tty stream so the event loop drains and the process exits. */ + close(): void { + if (this.#input) { + try { this.#input.setRawMode(false); } catch { /* may already be closed */ } + this.#input.destroy(); + this.#input = undefined; + } + this.#fd = undefined; + } +} + +export function createPrompter(): Prompter { + return new Prompter(new TtyBackend()); +} diff --git a/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts b/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts new file mode 100644 index 000000000..197da86d6 --- /dev/null +++ b/ts/src/adapters/inbound/cli/input/prompt/prompter.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import { Prompter, type PromptBackend, type KeyEvent } from "./index.js"; + +class FakeBackend implements PromptBackend { + out = ""; + #answers: string[]; + #keys: KeyEvent[]; + constructor(answers: string[] = [], keys: KeyEvent[] = []) { this.#answers = answers; this.#keys = keys; } + isTTY() { return true; } + async question(prompt: string, _hidden: boolean) { this.out += prompt; return this.#answers.shift() ?? ""; } + async readKey() { return this.#keys.shift() ?? { name: "return" }; } + write(s: string) { this.out += s; } + beginRaw() {} + endRaw() {} +} + +describe("Prompter.setInteractive", () => { + it("forces isTTY false when interaction is disabled, even on a real TTY", () => { + const p = new Prompter(new FakeBackend()); // FakeBackend.isTTY() === true + expect(p.isTTY()).toBe(true); + p.setInteractive(false); + expect(p.isTTY()).toBe(false); + p.setInteractive(true); + expect(p.isTTY()).toBe(true); + }); +}); + +describe("Prompter.text", () => { + it("re-prompts until validate passes", async () => { + const be = new FakeBackend(["", " ", "ok"]); + const p = new Prompter(be); + const v = await p.text({ label: "name", validate: (s) => (s.trim() ? null : "required") }); + expect(v).toBe("ok"); + }); +}); + +describe("Prompter.hidden", () => { + it("requires the confirm entry to match", async () => { + const be = new FakeBackend(["Abcdef1!", "nope", "Abcdef1!", "Abcdef1!"]); + const p = new Prompter(be); + const v = await p.hidden({ label: "pw", confirm: true }); + expect(v).toBe("Abcdef1!"); + }); + it("re-prompts on validate failure", async () => { + const be = new FakeBackend(["weak", "Abcdef1!"]); + const p = new Prompter(be); + const v = await p.hidden({ label: "pw", validate: (s) => (s.length >= 8 ? null : "too short") }); + expect(v).toBe("Abcdef1!"); + }); +}); + +describe("Prompter.confirm", () => { + it("expect-mode returns true only when the exact ref is typed", async () => { + const ok = new Prompter(new FakeBackend(["wlt_a.0"])); + expect(await ok.confirm({ label: "type ref", expect: "wlt_a.0" })).toBe(true); + const no = new Prompter(new FakeBackend(["wrong"])); + expect(await no.confirm({ label: "type ref", expect: "wlt_a.0" })).toBe(false); + }); +}); + +describe("Prompter.select", () => { + it("arrows to an item and returns its value on enter", async () => { + const be = new FakeBackend([], [{ name: "down" }, { name: "return" }]); + const p = new Prompter(be); + const v = await p.select({ label: "pick", choices: [{ value: "a", label: "A" }, { value: "b", label: "B" }] }); + expect(v).toBe("b"); + }); + it("loads more when arrowing past the last item", async () => { + const be = new FakeBackend([], [{ name: "down" }, { name: "down" }, { name: "return" }]); + const p = new Prompter(be); + let loaded = false; + const v = await p.select({ + label: "pick", + choices: [{ value: "x0", label: "0" }], + loadMore: async () => { loaded = true; return [{ value: "x0", label: "0" }, { value: "x1", label: "1" }]; }, + }); + expect(loaded).toBe(true); + expect(v).toBe("x1"); + }); + it("advances onto the newly loaded item after a single down past the end", async () => { + const be = new FakeBackend([], [{ name: "down" }, { name: "return" }]); + const p = new Prompter(be); + const v = await p.select({ + label: "pick", + choices: [{ value: "x0", label: "0" }], + loadMore: async () => [{ value: "x0", label: "0" }, { value: "x1", label: "1" }], + }); + expect(v).toBe("x1"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/input/prompt/validators.test.ts b/ts/src/adapters/inbound/cli/input/prompt/validators.test.ts new file mode 100644 index 000000000..fdcc46ab3 --- /dev/null +++ b/ts/src/adapters/inbound/cli/input/prompt/validators.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { passwordPolicyErrors, isValidPrivateKeyHex, isValidMnemonic, PASSWORD_SPECIALS } from "./validators.js"; + +describe("passwordPolicyErrors", () => { + it("accepts a strong password", () => { + expect(passwordPolicyErrors("Abcdef1!")).toEqual([]); + }); + it("rejects too short", () => { + expect(passwordPolicyErrors("Ab1!")).toContainEqual(expect.stringContaining("8")); + }); + it("flags each missing class", () => { + const errs = passwordPolicyErrors("abcdefgh"); // no upper, digit, special + expect(errs.length).toBe(3); + }); + it("uses the documented special set", () => { + expect(PASSWORD_SPECIALS).toContain("!"); + expect(passwordPolicyErrors("Abcdefg1#")).toEqual([]); // # is in the set + }); +}); + +describe("isValidPrivateKeyHex", () => { + it("accepts 64 hex with or without 0x", () => { + const k = "a".repeat(64); + expect(isValidPrivateKeyHex(k)).toBe(true); + expect(isValidPrivateKeyHex("0x" + k)).toBe(true); + }); + it("rejects wrong length or non-hex", () => { + expect(isValidPrivateKeyHex("a".repeat(63))).toBe(false); + expect(isValidPrivateKeyHex("z".repeat(64))).toBe(false); + }); +}); + +describe("isValidMnemonic", () => { + it("accepts a valid 12-word phrase", () => { + expect(isValidMnemonic("legal winner thank year wave sausage worth useful legal winner thank yellow")).toBe(true); + }); + it("rejects garbage", () => { + expect(isValidMnemonic("not a real mnemonic phrase at all nope nope nope nope nope")).toBe(false); + }); +}); diff --git a/ts/src/adapters/inbound/cli/input/prompt/validators.ts b/ts/src/adapters/inbound/cli/input/prompt/validators.ts new file mode 100644 index 000000000..480e56d3d --- /dev/null +++ b/ts/src/adapters/inbound/cli/input/prompt/validators.ts @@ -0,0 +1,24 @@ +/** Pure input validators for the interactive prompt layer (no I/O). */ +import { Derivation } from "../../../../../domain/derivation/index.js"; + +export const PASSWORD_SPECIALS = "!@#$%^&*()-_=+[]{};:,.?"; + +/** First-time master-password policy. Returns unmet requirements ([] = acceptable). */ +export function passwordPolicyErrors(pw: string): string[] { + const errs: string[] = []; + if (pw.length < 8) errs.push("must be at least 8 characters"); + if (!/[A-Z]/.test(pw)) errs.push("must include an uppercase letter"); + if (!/[a-z]/.test(pw)) errs.push("must include a lowercase letter"); + if (!/[0-9]/.test(pw)) errs.push("must include a digit"); + const specials = new Set(PASSWORD_SPECIALS); + if (![...pw].some((c) => specials.has(c))) errs.push(`must include a special character (${PASSWORD_SPECIALS})`); + return errs; +} + +export function isValidPrivateKeyHex(s: string): boolean { + return /^(0x)?[0-9a-fA-F]{64}$/.test(s.trim()); +} + +export function isValidMnemonic(s: string): boolean { + return Derivation.validateMnemonic(s); +} diff --git a/ts/src/adapters/inbound/cli/input/secret/index.ts b/ts/src/adapters/inbound/cli/input/secret/index.ts new file mode 100644 index 000000000..c5f8d4650 --- /dev/null +++ b/ts/src/adapters/inbound/cli/input/secret/index.ts @@ -0,0 +1,165 @@ +/** + * SecretResolver — the single place that reads secrets, memoized per source. + * Every secret kind binds to its own source `---stdin`, which reads stdin (fd 0) — at most + * one secret may use it per run. The `---file`/`/dev/fd/N` multi-fd path was removed; commands + * needing a 2nd secret (import-mnemonic/import-private-key/backup) go interactive. + * There is NO env source (no MASTER_PASSWORD): secrets never sit in env/process-table/history. + * Handlers must never touch process.stdin directly. Secrets never enter logs/envelopes. + */ +import type { SecretKind, SecretResolver as ISecretResolver, StreamManager } from "../../contracts/index.js"; +import { ExecutionError, UsageError } from "../../../../../domain/errors/index.js"; +import type { Prompter } from "../prompt/index.js"; +import { passwordPolicyErrors, isValidMnemonic, isValidPrivateKeyHex } from "../prompt/validators.js"; + +/** path per secret kind; the only source is `---stdin`, so the value is always `-` (stdin). */ +export type SecretPaths = Partial>; + +/** the flag stem for a kind (e.g. privateKey → "private-key", so `--private-key-stdin`). */ +function flagOf(kind: SecretKind): string { + return kind.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); +} + +export class SecretResolver implements ISecretResolver { + #byPath = new Map(); + #stdinUsedBy?: SecretKind; + #primed = new Map(); + + constructor( + private readonly streams: StreamManager, + private readonly paths: SecretPaths = {}, + private readonly prompter?: Prompter, + ) {} + + /** whether a master-password source is configured, WITHOUT consuming it. */ + hasMasterPassword(): boolean { + return this.paths.password !== undefined || this.#primed.has("password"); + } + + masterPassword(): string { + const primed = this.#primed.get("password"); + if (primed !== undefined) return primed; + if (this.paths.password === undefined) { + throw new ExecutionError("auth_required", "master password required: pass --password-stdin"); + } + return this.read("password"); + } + + /** whether a source for `kind` is configured, WITHOUT consuming it. */ + has(kind: SecretKind): boolean { + return this.paths[kind] !== undefined; + } + + read(kind: SecretKind): string { + const primed = this.#primed.get(kind); + if (primed !== undefined) return primed; + const path = this.paths[kind]; + if (path === undefined) { + if (kind === "password") + throw new ExecutionError("auth_required", "master password required: pass --password-stdin"); + throw new ExecutionError("secret_source_error", `missing --${flagOf(kind)}-stdin`); + } + return this.#readPath(path, kind); + } + + /** + * Read a REQUIRED source. A missing source is a usage error (forgot a required flag → + * missing_option, exit 2); secret_source_error is reserved for present-but-unreadable. + */ + require(kind: SecretKind): string { + if (!this.has(kind)) { + throw new UsageError("missing_option", `--${flagOf(kind)}-stdin is required`); + } + return this.read(kind); + } + + /** + * Exactly-one selector for commands that accept an inline value OR a stdin source + * (e.g. --transaction|--tx-stdin, --message|--message-stdin). Both → invalid_option; + * neither → missing_option (both usage/exit 2). + */ + pick(inline: string | undefined, kind: SecretKind, inlineFlag: string): string { + const hasStdin = this.has(kind); + if (inline !== undefined && hasStdin) { + throw new UsageError("invalid_option", `--${inlineFlag} and --${flagOf(kind)}-stdin are mutually exclusive`); + } + if (inline !== undefined) return inline; + if (hasStdin) return this.read(kind); + throw new UsageError("missing_option", `--${inlineFlag} or --${flagOf(kind)}-stdin is required`); + } + + async resolveSecret(kind: "mnemonic" | "privateKey"): Promise { + const validate = kind === "mnemonic" ? isValidMnemonic : isValidPrivateKeyHex; + if (this.has(kind)) { + const v = this.read(kind).trim(); + if (!validate(v)) throw new UsageError("invalid_secret", `--${flagOf(kind)}-stdin is not a valid ${kind}`); + this.streams.diagnostic("info", `${flagOf(kind)} ✓ via pipe`); + return v; + } + if (this.prompter?.isTTY()) { + const label = kind === "mnemonic" + ? "Paste recovery phrase (hidden)" + : "Paste private key (hidden)"; + const v = await this.prompter.hidden({ + label, + validate: (s) => (validate(s.trim()) ? null : `invalid ${kind}`), + }); + const trimmed = v.trim(); + this.#primed.set(kind, trimmed); + return trimmed; + } + throw new UsageError("missing_option", `--${flagOf(kind)}-stdin is required (or run in a terminal)`); + } + + async primePassword(plan: { mode: "set" | "verify"; verify?: (pw: string) => boolean }): Promise { + if (this.has("password")) { + const pw = this.read("password"); + if (plan.mode === "set") { + const errs = passwordPolicyErrors(pw); + if (errs.length) throw new UsageError("weak_password", `password too weak: ${errs.join("; ")}`); + } + if (plan.mode === "verify" && plan.verify && !plan.verify(pw)) { + throw new ExecutionError("auth_failed", "incorrect master password"); + } + this.#primed.set("password", pw); + this.streams.diagnostic("info", "password ✓ via pipe"); + return; + } + if (this.prompter?.isTTY()) { + let pw: string; + if (plan.mode === "set") { + pw = await this.prompter.hidden({ + label: "Set master password (hidden)", + confirmLabel: "Confirm master password", + confirm: true, + validate: (s) => { const e = passwordPolicyErrors(s); return e.length ? e.join("; ") : null; }, + }); + } else { + pw = ""; + for (let attempt = 0; attempt < 3; attempt++) { + pw = await this.prompter.hidden({ label: "Master password (hidden)" }); + if (plan.verify?.(pw)) { this.#primed.set("password", pw); return; } + this.streams.diagnostic("warn", "incorrect master password"); + } + throw new ExecutionError("auth_failed", "incorrect master password"); + } + this.#primed.set("password", pw); + return; + } + throw new ExecutionError("auth_required", "master password required: pass --password-stdin"); + } + + /** The only source is stdin (fd 0), so `path` is always `-`; at most one secret may use it. */ + #readPath(_path: string, kind: SecretKind): string { + if (this.#stdinUsedBy && this.#stdinUsedBy !== kind) { + throw new ExecutionError( + "secret_source_error", + `stdin already consumed by --${flagOf(this.#stdinUsedBy)}-stdin; only one secret may use stdin per run`, + ); + } + this.#stdinUsedBy = kind; + if (!this.#byPath.has("-")) { + this.#byPath.set("-", this.streams.readStdinOnce().replace(/\r?\n$/, "")); + } + return this.#byPath.get("-")!; + } +} diff --git a/ts/src/adapters/inbound/cli/input/secret/secret.test.ts b/ts/src/adapters/inbound/cli/input/secret/secret.test.ts new file mode 100644 index 000000000..af1d9f438 --- /dev/null +++ b/ts/src/adapters/inbound/cli/input/secret/secret.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from "vitest"; +import { SecretResolver } from "./index.js"; +import { Prompter, type PromptBackend, type KeyEvent } from "../prompt/index.js"; +import { StreamManager } from "../../stream/index.js"; + +function streams(stdin = ""): StreamManager { + // out/err captured to no-op; readStdinOnce returns the provided value + const sm = new StreamManager("text", false, () => {}, () => {}); + vi.spyOn(sm, "readStdinOnce").mockReturnValue(stdin); + return sm; +} + +class Backend implements PromptBackend { + constructor(private answers: string[], private tty = true) {} + isTTY() { return this.tty; } + async question() { return this.answers.shift() ?? ""; } + async readKey(): Promise { return { name: "return" }; } + write() {} + beginRaw() {} + endRaw() {} +} + +const PW = "Abcdef1!"; + +describe("resolveSecret", () => { + it("prompts (hidden) when no stdin source and validates mnemonic", async () => { + const valid = "legal winner thank year wave sausage worth useful legal winner thank yellow"; + const r = new SecretResolver(streams(), {}, new Prompter(new Backend(["bad phrase", valid]))); + expect(await r.resolveSecret("mnemonic")).toBe(valid); + }); + it("uses the stdin source when present", async () => { + const k = "a".repeat(64); + const r = new SecretResolver(streams(k + "\n"), { privateKey: "-" }, new Prompter(new Backend([]))); + expect(await r.resolveSecret("privateKey")).toBe(k); + }); + it("rejects a malformed stdin secret with invalid_secret", async () => { + const r = new SecretResolver(streams("zzz\n"), { privateKey: "-" }, new Prompter(new Backend([]))); + await expect(r.resolveSecret("privateKey")).rejects.toMatchObject({ code: "invalid_secret" }); + }); + it("errors when missing and no TTY", async () => { + const r = new SecretResolver(streams(), {}, new Prompter(new Backend([], false))); + await expect(r.resolveSecret("mnemonic")).rejects.toMatchObject({ code: "missing_option" }); + }); +}); + +describe("primePassword", () => { + it("set mode prompts with confirm + policy, then masterPassword() returns it", async () => { + const r = new SecretResolver(streams(), {}, new Prompter(new Backend(["weak", PW, PW]))); + await r.primePassword({ mode: "set" }); + expect(r.masterPassword()).toBe(PW); + }); + it("verify mode re-prompts until verify() passes", async () => { + const r = new SecretResolver(streams(), {}, new Prompter(new Backend(["nope", PW]))); + await r.primePassword({ mode: "verify", verify: (pw) => pw === PW }); + expect(r.masterPassword()).toBe(PW); + }); + it("set mode via --password-stdin enforces policy (weak_password)", async () => { + const r = new SecretResolver(streams("weak\n"), { password: "-" }, new Prompter(new Backend([]))); + await expect(r.primePassword({ mode: "set" })).rejects.toMatchObject({ code: "weak_password" }); + }); + it("verify mode via --password-stdin just caches", async () => { + const r = new SecretResolver(streams(PW + "\n"), { password: "-" }, new Prompter(new Backend([]))); + await r.primePassword({ mode: "verify", verify: () => true }); + expect(r.masterPassword()).toBe(PW); + }); + it("verify mode via --password-stdin rejects a wrong password", async () => { + const r = new SecretResolver(streams("wrong\n"), { password: "-" }, new Prompter(new Backend([]))); + await expect(r.primePassword({ mode: "verify", verify: (pw) => pw === PW })).rejects.toMatchObject({ code: "auth_failed" }); + }); + it("no source and no TTY → auth_required", async () => { + const r = new SecretResolver(streams(), {}, new Prompter(new Backend([], false))); + await expect(r.primePassword({ mode: "verify", verify: () => true })).rejects.toMatchObject({ code: "auth_required" }); + }); +}); + +describe("hasMasterPassword", () => { + it("hasMasterPassword is false with no source/primed even under a TTY (lazy guard must fail fast)", () => { + const r = new SecretResolver(streams(), {}, new Prompter(new Backend([], /* tty */ true))); + expect(r.hasMasterPassword()).toBe(false); + }); +}); diff --git a/ts/src/adapters/inbound/cli/output/envelope.test.ts b/ts/src/adapters/inbound/cli/output/envelope.test.ts new file mode 100644 index 000000000..208d0cebc --- /dev/null +++ b/ts/src/adapters/inbound/cli/output/envelope.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { OutputEnvelope } from "./envelope.js"; +import type { CommandDefinition } from "../contracts/index.js"; + +// Single shipping family (TRON); the envelope no longer redacts addresses — it passes the +// command's result payload through verbatim under the wallet-cli.result.v1 contract. +const cmd = { path: ["current"] } as CommandDefinition; +const m = { durationMs: 0, warnings: [] }; + +describe("OutputEnvelope.success — result payload passthrough", () => { + it("passes a single descriptor's addresses map through unchanged", () => { + const data = { accountId: "a.0", addresses: { tron: "Ttron" } }; + const env = OutputEnvelope.success(cmd, undefined, data, m); + expect((env.data as { addresses: Record }).addresses).toEqual({ tron: "Ttron" }); + }); + + it("passes a list of descriptors through unchanged", () => { + const data = [ + { accountId: "a.0", addresses: { tron: "Ttron0" } }, + { accountId: "a.1", addresses: { tron: "Ttron1" } }, + ]; + const env = OutputEnvelope.success(cmd, undefined, data, m); + expect(env.data).toEqual(data); + }); + + it("leaves data without an addresses field untouched", () => { + const data = { accountId: "a.0", scope: "wallet", secretRemoved: true }; + const env = OutputEnvelope.success(cmd, undefined, data, m); + expect(env.data).toEqual(data); + }); +}); diff --git a/ts/src/adapters/inbound/cli/output/envelope.ts b/ts/src/adapters/inbound/cli/output/envelope.ts new file mode 100644 index 000000000..c41e4f679 --- /dev/null +++ b/ts/src/adapters/inbound/cli/output/envelope.ts @@ -0,0 +1,70 @@ +/** + * OutputEnvelope — the result/error envelope builder for the OutputFormatter. Shapes + * the user-facing `wallet-cli.result.v1` contract: schema version, chain view, and meta. + * Pure (no I/O); the formatter turns the envelope into strings. + */ +import type { NetworkDescriptor } from "../../../../domain/types/index.js"; +import type { ChainView, CommandDefinition, ErrorEnvelope, Meta, ResultEnvelope } from "../contracts/index.js"; +import { commandId } from "../command-id.js"; + +type CliErrorEnvelopeShape = { code: string; message: string; details?: object }; + +const SCHEMA_VERSION = "wallet-cli.result.v1" as const; + +/** JSON serialization that keeps big numbers as strings. */ +export function toJson(value: unknown): string { + return JSON.stringify(value, (_k, v) => { + if (typeof v === "bigint") return v.toString(); + if (v instanceof Uint8Array) return Buffer.from(v).toString("hex"); + return v; + }); +} + +function chainView(net: NetworkDescriptor): ChainView { + return { + family: net.family, + networkId: net.id, + network: net.aliases[0] ?? net.chainId, + chainId: net.chainId, + }; +} + +function meta(durationMs: number, warnings: string[]): Meta { + return { durationMs, warnings }; +} + +export const OutputEnvelope = { + success( + cmd: CommandDefinition, + net: NetworkDescriptor | undefined, + data: unknown, + m: { durationMs: number; warnings: string[] }, + ): ResultEnvelope { + const env: ResultEnvelope = { + schema: SCHEMA_VERSION, + success: true, + command: commandId(cmd), + data: data ?? {}, + meta: meta(m.durationMs, m.warnings), + }; + if (net) env.chain = chainView(net); // neutral commands omit chain + return env; + }, + + error( + commandId: string, + net: NetworkDescriptor | undefined, + err: CliErrorEnvelopeShape, + m: { durationMs: number; warnings: string[] }, + ): ErrorEnvelope { + const env: ErrorEnvelope = { + schema: SCHEMA_VERSION, + success: false, + command: commandId, + error: err, + meta: meta(m.durationMs, m.warnings), + }; + if (net) env.chain = chainView(net); + return env; + }, +}; diff --git a/ts/src/adapters/inbound/cli/output/index.ts b/ts/src/adapters/inbound/cli/output/index.ts new file mode 100644 index 000000000..83a137c34 --- /dev/null +++ b/ts/src/adapters/inbound/cli/output/index.ts @@ -0,0 +1,98 @@ +/** + * OutputFormatter — turn outcomes into result/event frame strings without changing + * behavior. Instead of one class branching on `if (output === "json")`, this is an interface + * with two implementations chosen by createOutputFormatter (borrowed from ledger wallet-cli + * output.ts). The formatter only computes strings; StreamManager owns writing & stream choice. + * - success → single terminal frame (caller hands to streams.result) + * - error → terminal error (json envelope to stdout, or short stderr line) + * - event → intermediate progress frame (caller hands to streams.event); null = not shown + * JSON mode emits exactly one terminal envelope; empty data is {}; big numbers stay strings. + * Neutral commands omit `chain`. + */ +import type { NetworkDescriptor, OutputMode } from "../../../../domain/types/index.js"; +import type { ProgressEvent } from "../../../../application/contracts/index.js"; +import type { CommandDefinition, StreamManager } from "../contracts/index.js"; +import type { CliError } from "../../../../domain/errors/index.js"; +import { OutputEnvelope, toJson } from "./envelope.js"; +import { renderGenericText } from "../render/index.js"; + +export interface OutputFormatter { + /** the single result frame for the caller to hand to streams.result. */ + success(cmd: CommandDefinition, net: NetworkDescriptor | undefined, data: unknown, accountLabel?: string): string; + /** terminal error output (JSON envelope to stdout, or short line to stderr). */ + error(err: CliError, ctx?: { commandId?: string; net?: NetworkDescriptor }): void; + /** intermediate progress frame for streams.event; null = this mode does not show it. */ + event(e: ProgressEvent): string | null; +} + +abstract class BaseOutputFormatter { + constructor( + protected readonly streams: StreamManager, + protected readonly startedAt: number, + ) {} + + protected meta() { + return { durationMs: Date.now() - this.startedAt, warnings: this.streams.warnings() }; + } +} + +class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter { + success(cmd: CommandDefinition, net: NetworkDescriptor | undefined, data: unknown): string { + // JSON mode always uses the envelope; the account label is a text-mode display nicety. + return toJson(OutputEnvelope.success(cmd, net, data, this.meta())); + } + + error(err: CliError, ctx?: { commandId?: string; net?: NetworkDescriptor }): void { + const env = OutputEnvelope.error(ctx?.commandId ?? "", ctx?.net, err.toEnvelope(), this.meta()); + this.streams.result(toJson(env)); + } + + event(e: ProgressEvent): string { + return JSON.stringify(e); + } +} + +class HumanOutputFormatter extends BaseOutputFormatter implements OutputFormatter { + success(cmd: CommandDefinition, net: NetworkDescriptor | undefined, data: unknown, accountLabel?: string): string { + const env = OutputEnvelope.success(cmd, net, data, this.meta()); + const custom = cmd.formatText?.(env.data, { command: env.command, net, accountLabel }); + if (custom) return custom; + return renderGenericText(env.command, net, env.data); + } + + error(err: CliError): void { + this.streams.errorLine(`error [${err.code}]: ${err.message}`); + } + + event(e: ProgressEvent): string { + return renderEvent(e); + } +} + +export function createOutputFormatter( + output: OutputMode, + streams: StreamManager, + startedAt: number, +): OutputFormatter { + return output === "json" + ? new JsonOutputFormatter(streams, startedAt) + : new HumanOutputFormatter(streams, startedAt); +} + +// plain human progress line (no spinner / no TTY detection — Standard CLI / agent-first). +function renderEvent(e: ProgressEvent): string { + switch (e.type) { + case "awaiting_device": + switch (e.reason) { + case "sign": return "⧖ review and approve the transaction on your device"; + case "verify_address": return "⧖ review and confirm the address on your device"; + case "open_app": return "⧖ confirm on your device to open the app"; + case "unlock": return "⧖ unlock your device with your PIN"; + } + // eslint-disable-next-line no-fallthrough + case "pre-verify-address": return `compare with your device: ${e.address}`; + case "signed": return "✓ signed; broadcasting…"; + case "broadcasting": return "broadcasting…"; + case "dry-run": return "dry run (transaction not broadcast)"; + } +} diff --git a/ts/src/adapters/inbound/cli/output/output.test.ts b/ts/src/adapters/inbound/cli/output/output.test.ts new file mode 100644 index 000000000..b7630b05c --- /dev/null +++ b/ts/src/adapters/inbound/cli/output/output.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { createOutputFormatter } from "./index.js"; +import { StreamManager } from "../stream/index.js"; +import { UsageError } from "../../../../domain/errors/index.js"; +import { commandId } from "../command-id.js"; +import type { NetworkDescriptor } from "../../../../domain/types/index.js"; +import type { CommandDefinition } from "../contracts/index.js"; +import { TextFormatters } from "../render/index.js"; + +function capture(output: "text" | "json") { + const out: string[] = []; + const err: string[] = []; + const sm = new StreamManager(output, false, (s) => out.push(s), (s) => err.push(s)); + return { sm, out, err }; +} + +const cmd = { family: "tron", path: ["account", "balance"] } as unknown as CommandDefinition; +const net: NetworkDescriptor = { + id: "tron:nile", family: "tron", chainId: "nile", aliases: ["nile"], capabilities: [], +}; + +describe("createOutputFormatter (json)", () => { + it("success returns a single parseable envelope", () => { + const { sm } = capture("json"); + const f = createOutputFormatter("json", sm, 0); + const env = JSON.parse(f.success(cmd, net, { balance: "1" })); + expect(env.success).toBe(true); + expect(env.command).toBe("tron.account.balance"); + expect(env.data).toEqual({ balance: "1" }); + expect(env.meta).toMatchObject({ warnings: [] }); + }); + + it("error writes an error envelope to stdout via streams.result", () => { + const { sm, out, err } = capture("json"); + const f = createOutputFormatter("json", sm, 0); + f.error(new UsageError("missing_option", "need --to"), { commandId: commandId(cmd), net }); + expect(err).toEqual([]); + const env = JSON.parse(out[0]!); + expect(env.success).toBe(false); + expect(env.error).toMatchObject({ code: "missing_option" }); + }); + + it("event renders an NDJSON line that parses back to the event", () => { + const { sm } = capture("json"); + const f = createOutputFormatter("json", sm, 0); + const frame = f.event({ type: "awaiting_device", reason: "sign" }); + expect(JSON.parse(frame!)).toEqual({ type: "awaiting_device", reason: "sign" }); + }); +}); + +describe("createOutputFormatter (text)", () => { + it("success returns human lines naming the command", () => { + const { sm } = capture("text"); + const f = createOutputFormatter("text", sm, 0); + const text = f.success(cmd, net, { balance: "1" }); + expect(text).toContain("tron.account.balance"); + expect(text).toContain("balance"); + }); + + it("error writes a short line to stderr, not stdout", () => { + const { sm, out, err } = capture("text"); + const f = createOutputFormatter("text", sm, 0); + f.error(new UsageError("missing_option", "need --to")); + expect(out).toEqual([]); + expect(err[0]).toContain("missing_option"); + }); + + it("event renders a non-null human progress line (no spinner)", () => { + const { sm } = capture("text"); + const f = createOutputFormatter("text", sm, 0); + const frame = f.event({ type: "awaiting_device", reason: "sign" }); + expect(frame).not.toBeNull(); + expect(frame).not.toContain("{"); // human text, not NDJSON + }); + + it("renders wallet create as a focused human receipt", () => { + const { sm } = capture("text"); + const f = createOutputFormatter("text", sm, 0); + const walletCmd = { + path: ["create"], + formatText: TextFormatters.walletCreated("Created", [ + "Recovery phrase is encrypted locally and was not printed.", + "Run `backup` soon and store the file offline.", + ]), + } as unknown as CommandDefinition; + const text = f.success(walletCmd, undefined, { + status: "created", + accountId: "wlt_abc.0", + label: "main", + type: "seed", + active: true, + addresses: { tron: "T1234567890abcdef", evm: "0x1234567890abcdef" }, + }); + expect(text).toContain("Created wallet"); + expect(text).toContain("main"); + expect(text).toContain("Run `backup`"); + }); + + it("renders existing wallet receipts with a warning marker", () => { + const { sm } = capture("text"); + const f = createOutputFormatter("text", sm, 0); + const walletCmd = { + path: ["import", "private-key"], + formatText: TextFormatters.walletCreated("Imported", [ + "Private key was read from hidden input and was not printed.", + ]), + } as unknown as CommandDefinition; + const text = f.success(walletCmd, undefined, { + status: "existing", + accountId: "wlt_abc.0", + label: "main", + type: "seed", + addresses: { tron: "T1234567890abcdef", evm: "0x1234567890abcdef" }, + }); + // icon and label live in separate ANSI spans, so assert on the pieces (not a fused substring). + expect(text).toContain("⚠"); + expect(text).toContain("Existing wallet"); + expect(text).not.toContain("✅"); // existing wallets must not show the success check + }); + + it("renders backup metadata without secret material", () => { + const { sm } = capture("text"); + const f = createOutputFormatter("text", sm, 0); + const backupCmd = { path: ["backup"], formatText: TextFormatters.walletBackup } as unknown as CommandDefinition; + const text = f.success(backupCmd, undefined, { + accountId: "wlt_abc.0", + secretType: "mnemonic", + out: "/tmp/main-backup.json", + fileMode: "0600", + bytes: 512, + mnemonic: "test test test test test test test test test test test junk", + privateKey: "00".repeat(32), + }); + expect(text).toContain("/tmp/main-backup.json"); + expect(text).not.toContain("test test"); + expect(text).not.toContain("000000"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/registry/index.ts b/ts/src/adapters/inbound/cli/registry/index.ts new file mode 100644 index 000000000..e94ee912c --- /dev/null +++ b/ts/src/adapters/inbound/cli/registry/index.ts @@ -0,0 +1,76 @@ +/** + * CommandRegistry — holds all CommandDefinitions; resolves a command from a positional path + * (+ family for chain commands); exposes metadata for CliShell (yargs tree) and HelpService. + * Thin: tokenizing, flag collection, and help layout are yargs' job. + * + * Two command kinds, discriminated solely by `family`: + * - neutral (no family): addressed by its full path (create, import mnemonic, config get…). + * - chain (family set): same logical path may have per-family impls, chosen by --network. + */ +import type { ChainFamily } from "../../../../domain/types/index.js"; +import type { CommandDefinition, CommandRegistryLike } from "../contracts/index.js"; +import { commandId } from "../command-id.js"; + +/** flat command-tree metadata for CliShell (yargs tree) + HelpService. */ +export interface CommandTreeMeta { + commands: Array<{ path: string[]; id: string; family?: ChainFamily; summary?: string }>; +} + +/** storage/lookup key: family scopes chain commands; neutral commands share the empty scope. */ +function keyOf(cmd: CommandDefinition): string { + return `${cmd.family ?? ""}:${cmd.path.join(".")}`; +} + +export class CommandRegistry implements CommandRegistryLike { + #byKey = new Map(); + + add(cmd: CommandDefinition): void { + const key = keyOf(cmd); + if (this.#byKey.has(key)) throw new Error(`duplicate command ${key}`); + this.#byKey.set(key, cmd); + } + + families(): ChainFamily[] { + const set = new Set(); + for (const cmd of this.#byKey.values()) if (cmd.family) set.add(cmd.family); + return [...set]; + } + + /** command-backed capability keys per family (deduped). Summaries are resolved by the caller + * (runner) against CAP_SUMMARIES — the registry stays free of presentation/infra concerns. */ + capabilityKeysByFamily(): Map { + const out = new Map>(); + for (const cmd of this.#byKey.values()) { + if (!cmd.family || !cmd.capability) continue; + const set = out.get(cmd.family) ?? new Set(); + set.add(cmd.capability); + out.set(cmd.family, set); + } + return new Map([...out].map(([f, s]) => [f, [...s]])); + } + + /** Resolve a neutral command (no family) by its full path. */ + resolveNeutral(path: string[]): CommandDefinition | null { + return this.#byKey.get(`:${path.join(".")}`) ?? null; + } + + /** All commands matching a logical path, regardless of family (used to pick by --network). */ + resolveCandidates(path: string[]): CommandDefinition[] { + const key = path.join("."); + return this.all().filter((c) => c.path.join(".") === key); + } + + /** Family-specific implementation of a logical path. */ + resolveForFamily(path: string[], family: ChainFamily): CommandDefinition | null { + return this.resolveCandidates(path).find((c) => c.family === family) ?? null; + } + + all(): CommandDefinition[] { + return [...this.#byKey.values()]; + } + + tree(): CommandTreeMeta { + const commands = this.all().map((c) => ({ path: c.path, id: commandId(c), family: c.family, summary: c.summary })); + return { commands }; + } +} diff --git a/ts/src/adapters/inbound/cli/registry/registry.test.ts b/ts/src/adapters/inbound/cli/registry/registry.test.ts new file mode 100644 index 000000000..f92856ba8 --- /dev/null +++ b/ts/src/adapters/inbound/cli/registry/registry.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import type { ChainFamily } from "../../../../domain/types/index.js"; +import type { CommandDefinition } from "../contracts/index.js"; +import { CommandRegistry } from "./index.js"; +import { commandId } from "../command-id.js"; + +function command(family: ChainFamily, path: string[]): CommandDefinition { + const fields = z.object({}); + return { + family, + path, + network: "optional", + wallet: "optional", + auth: "none", + fields, + input: fields, + examples: [], + run: async () => ({}), + }; +} + +describe("CommandRegistry logical resolution", () => { + it("returns every implementation for a logical path", () => { + const reg = new CommandRegistry(); + // synthetic second family via cast: only tron ships, but the registry keys on the family + // string, so this still exercises multi-implementation logical resolution. + reg.add(command("tron", ["account", "balance"])); + reg.add(command("evm" as any, ["account", "balance"])); + + expect(reg.resolveCandidates(["account", "balance"]).map((c) => commandId(c))).toEqual([ + "tron.account.balance", + "evm.account.balance", + ]); + }); + + it("selects one implementation by family", () => { + const reg = new CommandRegistry(); + reg.add(command("tron", ["account", "balance"])); + reg.add(command("evm" as any, ["account", "balance"])); + + expect(commandId(reg.resolveForFamily(["account", "balance"], "evm" as any)!)).toBe("evm.account.balance"); + expect(reg.resolveForFamily(["account", "missing"], "evm" as any)).toBeNull(); + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/family-render.test.ts b/ts/src/adapters/inbound/cli/render/family-render.test.ts new file mode 100644 index 000000000..e0fb9be0f --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/family-render.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { FAMILY_RENDER } from "./index.js"; + +describe("FAMILY_RENDER parity", () => { + it("nativeAmount units", () => { + expect(FAMILY_RENDER.tron.nativeAmount("1000000")).toBe("1 TRX"); + }); + it("feeFallback: tron formats sun→TRX", () => { + expect(FAMILY_RENDER.tron.feeFallback("1000000")).toBe("1 TRX"); + }); + it("addressLabel", () => { + expect(FAMILY_RENDER.tron.addressLabel).toBe("TRON address"); + }); + it("tron txInfoRows include Energy + Fee in TRX", () => { + const rows = FAMILY_RENDER.tron.txInfoRows({ txid: "t", status: "SUCCESS", feeSun: "1000000", energyUsed: 5 } as any); + expect(rows).toContainEqual(["Fee", "1 TRX"]); + expect(rows.map((r) => r[0])).toContain("Energy"); + }); +}); diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts new file mode 100644 index 000000000..6bd9154e0 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -0,0 +1,572 @@ +import type { NetworkDescriptor, TxInfoView, TxReceiptKind, TxReceiptView, TxStatusView } from "../../../../domain/types/index.js"; +import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; +import { ChainFamily } from "../../../../domain/family/index.js"; +import { RESOURCES, resourceOfRpcCode, type Resource } from "../../../../domain/resources/index.js"; +import { sourceLabel } from "../../../../domain/sources/index.js"; +import { fromBaseUnits } from "../../../../domain/amounts/index.js"; +import { formatScalar, formatInt, formatUsd, formatSun, formatTime, formatUtc, num, shorten, quote } from "./scalars.js"; +import { type Obj, type Pair, asObj, kv, query, receipt, titled, table, ok, fail, pending, warn } from "./layout.js"; + +/** + * Per-family render hooks — the one table that folds the scattered `r.family === tron ? … : …` + * branches. Adding a chain = one entry here (alongside its FAMILIES + FamilyDef entries). + */ +interface FamilyRenderHooks { + /** the full TxInfo detail rows (family-shaped: Energy/TRX vs Gas/wei). Reads the flat + * TxInfoView and picks its own family's fields — no narrowing cast (no closed union). */ + txInfoRows(r: TxInfoView): Pair[]; + /** native smallest-unit amount → display string (sun→TRX / wei). */ + nativeAmount(raw: string): string; + /** fee fallback when no structured fee object is present. */ + feeFallback(fee: unknown): string; + /** address-type label for the per-family address rows. */ + addressLabel: string; +} + +const txInfoAmount = (v: string | undefined, suffix: string): string => + v === undefined || v === "" ? "" : `${formatScalar(v)}${suffix}`; + +export const FAMILY_RENDER: Record = { + tron: { + nativeAmount: (raw) => `${formatSun(raw)} TRX`, + feeFallback: (fee) => `${formatSun(fee)} TRX`, + addressLabel: "TRON address", + txInfoRows: (r) => [ + ["TxID", r.txid], + ["From", r.from ?? ""], + ["To", r.to ?? ""], + ["Amount", txInfoAmount(r.amount, r.symbol ? ` ${r.symbol}` : "")], + ["Status", r.status ?? "unknown"], + ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], + ["Energy", r.energyUsed === undefined ? "" : formatInt(r.energyUsed)], + ["Fee", r.feeSun === undefined ? "" : `${formatSun(r.feeSun)} TRX`], + ], + }, +}; + +export const TextFormatters = { + walletCreated: (verb: "Created" | "Imported", notes: string[]): TextFormatter => (data) => + renderWalletCreated(verb, asObj(data), notes), + walletWatch: ((data) => { + const d = asObj(data); + return receipt(ok(), `Added watch-only account ${quote(displayName(d))}`, [ + ["Address", firstAddress(d)], + ["Note", "read-only; signing operations will be rejected"], + ]); + }) satisfies TextFormatter, + walletLedger: ((data) => renderLedgerImported(asObj(data))) satisfies TextFormatter, + walletList: ((data) => renderWalletList(Array.isArray(data) ? data.map(asObj) : [])) satisfies TextFormatter, + walletUse: ((data) => { + const d = asObj(data); + return receipt(ok(), `Active account: ${displayName(d)}`, addressPairs(d)); + }) satisfies TextFormatter, + walletCurrent: ((data) => { + const d = asObj(data); + return titled(`Active account: ${displayName(d)}`, addressPairs(d)); + }) satisfies TextFormatter, + walletRename: ((data) => { + const d = asObj(data); + return receipt(ok(), "Renamed account", [ + ["Old label", String(d.previousLabel ?? "")], + ["New label", displayName(d)], + ]); + }) satisfies TextFormatter, + walletDerive: ((data) => { + const d = asObj(data); + return receipt(ok(), `Derived sub-account ${quote(displayName(d))}`, [ + ["Address", firstAddress(d)], + ["Active", d.active === true ? "yes" : ""], + ["Note", "shares master mnemonic; no separate backup needed"], + ]); + }) satisfies TextFormatter, + walletDelete: ((data) => { + const d = asObj(data); + return receipt(ok(), `Deleted wallet ${String(d.accountId ?? "")}`, [ + ["Secret removed", d.secretRemoved === false ? "no" : "yes"], + ["New active", d.newActive ? String(d.newActive) : ""], + ]); + }) satisfies TextFormatter, + walletBackup: ((data) => { + const d = asObj(data); + return [ + receipt(warn(), `Backup written ${String(d.out ?? "")}`, [ + ["Account ID", String(d.accountId ?? "")], + ["Secret", secretLabel(d.secretType)], + ["File mode", String(d.fileMode ?? "0600")], + ["Bytes", String(d.bytes ?? "?")], + ]), + "", + `${warn()} Secret material was written only to the backup file, never to stdout.`, + ].join("\n"); + }) satisfies TextFormatter, + + config: ((data) => renderConfig(asObj(data))) satisfies TextFormatter, + networks: ((data) => table( + ["Network", "Family", "Chain", "Aliases", "Fee model"], + (Array.isArray(data) ? data : []).map(asObj).map((n) => [ + String(n.id ?? ""), + String(n.family ?? ""), + String(n.chainId ?? ""), + Array.isArray(n.aliases) ? n.aliases.join(",") : "", + String(n.feeModel ?? ""), + ]), + )) satisfies TextFormatter, + + accountBalance: ((data, ctx) => { + const d = asObj(data); + const unit = String(d.unit ?? "TRX"); + const human = unit === "TRX" ? formatSun(d.balance) : formatScalar(d.balance); + return query([identity(ctx, d.address), ["Balance", `${human} ${unit}`]]); + }) satisfies TextFormatter, + accountInfo: ((data, ctx) => renderAccountInfo(asObj(data), ctx)) satisfies TextFormatter, + accountHistory: ((data, ctx) => { + const d = asObj(data); + const rows = (Array.isArray(d.records) ? d.records : []).map(asObj).map(historyRow); + return [`${quote(acct(ctx, d.address))} recent transactions`, table(["Time", "Type", "Amount", "From / To", "Status"], rows)].join("\n"); + }) satisfies TextFormatter, + tokenBookAdd: ((data) => { + const d = asObj(data); + const token = asObj(d.token); + const verb = d.action === "updated" ? "Updated token book" : "Added to token book"; + return receipt(ok(), verb, [ + ["Name", String(token.name ?? "")], + ["Symbol", String(token.symbol ?? token.id ?? "")], + ["Decimals", token.decimals === undefined ? "" : String(token.decimals)], + ]); + }) satisfies TextFormatter, + tokenBookList: ((data) => { + const d = asObj(data); + const rows = (Array.isArray(d.tokens) ? d.tokens : []).map(asObj).map((t) => [ + String(t.symbol ?? ""), + String(t.name ?? ""), + String(t.source ?? ""), + String(t.id ?? ""), + ]); + return table(["Symbol", "Name", "Source", "Contract / ID"], rows); + }) satisfies TextFormatter, + tokenBookRemove: ((data) => { + const removed = asObj(asObj(data).removed); + return receipt(ok(), "Removed from token book", [ + ["Name", String(removed.name ?? "")], + ["Symbol", String(removed.symbol ?? "")], + ]); + }) satisfies TextFormatter, + accountPortfolio: ((data, ctx) => { + const d = asObj(data); + const rows = (Array.isArray(d.holdings) ? d.holdings : []).map(asObj).map((h) => [ + String(h.symbol ?? ""), + formatScalar(h.balance), + h.priceUsd === null || h.priceUsd === undefined ? "-" : `$${formatUsd(h.priceUsd)}`, + h.valueUsd === null || h.valueUsd === undefined ? "-" : `$${formatUsd(h.valueUsd)}`, + ]); + const total = d.totalValueUsd === null || d.totalValueUsd === undefined ? "-" : `$${formatUsd(d.totalValueUsd)}`; + const lines = [ + `${quote(acct(ctx, d.address ?? d.account))} Portfolio`, + table(["Token", "Balance", "Price (USD)", "Value (USD)"], rows), + `Total ≈ ${total}`, + ]; + if (d.priceError) lines.push(`${warn()} price warning: ${String(d.priceError)}`); + return lines.join("\n"); + }) satisfies TextFormatter, + + tokenBalance: ((data, ctx) => { + const d = asObj(data); + const balance = d.decimals !== undefined ? fromBaseUnits(String(d.balance ?? "0"), num(d.decimals, 0)) : formatScalar(d.balance); + return query([ + identity(ctx, d.address), + ["Name", String(d.name ?? "")], + ["Symbol", String(d.symbol ?? "")], + ["Balance", balance], + ]); + }) satisfies TextFormatter, + tokenInfo: ((data) => { + const d = asObj(data); + return query([ + ["Name", String(d.name ?? d.token_name ?? d.id ?? "")], + ["Symbol", String(d.symbol ?? d.abbr ?? "")], + ["Decimals", String(d.decimals ?? d.precision ?? "")], + ]); + }) satisfies TextFormatter, + + txReceipt: ((r) => renderTxReceipt(r)) satisfies TextFormatter, + txStatus: ((r) => { + // `failed` is computed by the command (tron: result ≠ SUCCESS) — no family branch. + const status = r.failed ? `failed ${fail()}` : r.confirmed ? `confirmed ${ok()}` : `pending ${pending()}`; + return query([ + ["TxID", r.txid], + ["Status", status], + ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`], + ]); + }) satisfies TextFormatter, + txInfo: ((r) => { + return query(FAMILY_RENDER[r.family].txInfoRows(r)); + }) satisfies TextFormatter, + + contractCall: ((data) => { + const d = asObj(data); + return query([ + ["Method", methodName(String(d.method ?? ""))], + ["Result", `${formatResult(d.result)} (raw)`], + ]); + }) satisfies TextFormatter, + contractInfo: ((data) => renderContractInfo(asObj(data))) satisfies TextFormatter, + + messageSign: ((data) => { + const d = asObj(data); + return receipt(ok(), "Signed", [ + ["Address", String(d.address ?? "")], + ["Signature", String(d.signature ?? "")], + ]); + }) satisfies TextFormatter, + block: ((data) => { + const block = asObj(asObj(data).block); + const header = asObj(asObj(block.block_header).raw_data); + const n = block.number ?? header.number; + const ts = block.timestamp ?? header.timestamp; + const txs = Array.isArray(block.transactions) ? block.transactions.length : 0; + return query([ + ["Number", n === undefined ? "" : `#${formatInt(n)}`], + ["Time", ts ? formatUtc(ts) : "unknown"], + ["Transactions", String(txs)], + ]); + }) satisfies TextFormatter, +}; + +export function renderGenericText(command: string, net: NetworkDescriptor | undefined, data: unknown): string { + const lines: string[] = [`${ok()} ${command}`]; + if (net) lines.push(` network: ${net.aliases[0] ?? net.chainId} (${net.id})`); + if (data && typeof data === "object" && !Array.isArray(data)) { + for (const [k, v] of Object.entries(data as Obj)) { + if (Array.isArray(v) && v.length > 0) { + lines.push(` ${k}:`); + for (const item of v) lines.push(` - ${formatScalar(item)}`); + } else { + lines.push(` ${k}: ${formatScalar(v)}`); + } + } + } else if (Array.isArray(data)) { + for (const item of data) lines.push(` - ${formatScalar(item)}`); + } else if (data !== undefined && data !== null) { + lines.push(` ${String(data)}`); + } + return lines.join("\n"); +} + +function renderWalletCreated(verb: "Created" | "Imported", d: Obj, notes: string[]): string { + const existing = d.status === "existing"; + const title = existing ? "Existing wallet" : `${verb} wallet`; + const lines = [ + receipt(existing ? warn() : ok(), `${title} ${quote(displayName(d))}`, [ + ["Account ID", String(d.accountId ?? "")], + ["Type", typeLabel(d.type)], + ...addressPairs(d), + ["Active", d.active === true ? "yes" : ""], + ]), + ]; + if (notes.length) lines.push("", ...notes.map((n) => `${warn()} ${n}`)); + return lines.join("\n"); +} + +function renderLedgerImported(d: Obj): string { + const existing = d.status === "existing"; + return [ + receipt(existing ? warn() : ok(), `${existing ? "Existing Ledger account" : "Registered Ledger account"} ${quote(displayName(d))}`, [ + ["Account ID", String(d.accountId ?? "")], + ["App", String(d.family ?? "")], + ["Path", String(d.path ?? "")], + ...addressPairs(d), + ]), + "", + `${warn()} No private key is stored locally. Signing requires device confirmation.`, + ].join("\n"); +} + +function renderWalletList(items: Obj[]): string { + if (items.length === 0) return "No wallets found."; + const rows = items.map((d) => [ + displayName(d), + typeLabel(d.type), + firstAddress(d), + d.active ? "*" : "", + ]); + return table(["Label", "Type", "Address", "Active"], rows); +} + +function renderAccountInfo(d: Obj, ctx: TextRenderContext): string { + const account = asObj(d.account); + const owner = asObj(account.owner_permission); + const active = Array.isArray(account.active_permission) ? account.active_permission.length : 0; + const created = account.create_time ? new Date(Number(account.create_time)).toISOString().slice(0, 10) : ""; + const ownerKeys = Array.isArray(owner.keys) ? owner.keys.length : "?"; + const resources = asObj(d.resources); + const bandwidth = asObj(resources.bandwidth); + const energy = asObj(resources.energy); + const pairs: Pair[] = []; + if (ctx.accountLabel) pairs.push(["Label", ctx.accountLabel]); + pairs.push(["Address", String(d.address ?? "")]); + pairs.push(["Balance", `${formatSun(account.balance)} TRX`]); + const staked = stakedSummary(account); + if (staked) pairs.push(["Staked", staked]); + if (resources.energy) pairs.push(["Energy", `used ${formatInt(energy.used)} / ${formatInt(energy.limit)}`]); + if (resources.bandwidth) pairs.push(["Bandwidth", `used ${formatInt(bandwidth.used)} / ${formatInt(bandwidth.limit)}`]); + pairs.push(["Created", created]); + pairs.push(["Permissions", `owner ${String(owner.threshold ?? "?")}-of-${ownerKeys}, ${active} active group${active === 1 ? "" : "s"}`]); + return query(pairs); +} + +/** Sum FreezeBalanceV2 stakes into a " TRX (energy + bandwidth )" summary. */ +function stakedSummary(account: Obj): string { + const frozen = Array.isArray(account.frozenV2) ? account.frozenV2.map(asObj) : []; + // frozenV2's bandwidth entries carry no `type`, so an unrecognized code folds into bandwidth. + const sums = new Map(RESOURCES.map((r) => [r, 0n])); + for (const f of frozen) { + const r = resourceOfRpcCode(String(f.type ?? "")) ?? "bandwidth"; + sums.set(r, (sums.get(r) ?? 0n) + BigInt(Number(f.amount ?? 0))); + } + const total = RESOURCES.reduce((t, r) => t + (sums.get(r) ?? 0n), 0n); + if (total === 0n) return ""; + const parts = RESOURCES.map((r) => `${r} ${formatSun(sums.get(r) ?? 0n)}`).join(" + "); + return `${formatSun(total)} TRX (${parts})`; +} + +function renderContractInfo(d: Obj): string { + let names: string[]; + let count: number; + if (Array.isArray(d.methods)) { + names = d.methods.map(String); + count = num(d.functionCount, names.length); + } else { + const contract = asObj(d.contract); + const info = asObj(d.info); + const abi = contract.abi ?? info.abi ?? contract.ABI ?? info.ABI; + const nestedEntries = asObj(abi).entrys; + const entries: unknown[] = Array.isArray(abi) ? abi : Array.isArray(nestedEntries) ? nestedEntries : []; + const methods = entries.map(asObj).filter((e) => e.type === "Function" || e.type === "function"); + names = methods.map((e) => e.name).filter(Boolean).map(String); + count = methods.length; + } + const name = String(d.name ?? asObj(d.contract).name ?? asObj(d.info).name ?? ""); + const preview = names.slice(0, 3).join(" / "); + return query([ + ["Contract", String(d.address ?? "")], + ["Name", name], + ["Methods", `${count}${preview ? ` (${preview}${count > 3 ? " …" : ""})` : ""}`], + ]); +} + +function renderConfig(d: Obj): string { + if ("input" in d) { + return receipt(ok(), "Set config", [ + ["Key", String(d.key)], + ["Value", configValue(d.value)], + ]); + } + if ("key" in d) return kv([[String(d.key), configValue(d.value)]], ""); + return kv(Object.entries(d).map(([k, v]) => [k, configValue(v)] as Pair), ""); +} + +/** config values keep their literal form (no thousands grouping, raw key names). */ +function configValue(v: unknown): string { + if (Array.isArray(v)) return v.map(String).join(", "); + return v === null || v === undefined ? "" : String(v); +} + +/** Default-mode broadcast/dry-run/sign-only receipt for tx/stake/contract signing commands. + * Narrows on the typed `kind`/`family` — no stringly command-id matching, no alias probing. */ +function renderTxReceipt(r: TxReceiptView): string { + if (r.mode === "dry-run") { + return receipt(pending(), `Dry run ${actionLabel(r.kind)}`, [ + ["Fee", formatFee(r.fee, r.family)], + ["Tx", summarizeTx(r.tx)], + ]); + } + if (r.mode === "sign-only") { + return receipt(ok(), `Signed ${actionLabel(r.kind)}`, [ + ["Fee", formatFee(r.fee, r.family)], + ["Signed", summarizeTx(r.signed)], + ]); + } + const txid = String(r.txId ?? r.hash ?? ""); + const stage = r.stage ?? "submitted"; + const summary = receiptSummary(r); + const pairs: Pair[] = [...receiptRows(r)]; + if (txid) pairs.push(["TxID", txid]); + + // submitted (default, non-blocking): txid only, no fee/energy yet — those need confirmation. + if (stage === "submitted") { + pairs.push(["Status", "pending — not yet on-chain"]); + const body = receipt(pending(), summary, pairs); + return txid ? `${body}\n! Track it: wallet-cli tx info --txid ${txid}` : body; + } + + // confirmed / failed (after --wait): real on-chain block / fee / energy / result. + if (r.blockNumber) pairs.push(["Block", `#${formatInt(r.blockNumber)}`]); + if (r.energyUsed) pairs.push(["Energy", formatInt(r.energyUsed)]); + if (r.feeSun) pairs.push(["Fee", `${formatSun(r.feeSun)} TRX`]); + if (r.kind === "stake-unfreeze") pairs.push(["Withdrawable", "after the unlock period — then run `stake withdraw`"]); + if (stage === "failed") { + pairs.push(["Status", "failed"]); + if (r.result) pairs.push(["Reason", String(r.result)]); + return receipt(fail(), summary, pairs); + } + pairs.push(["Status", "success"]); + return receipt(ok(), summary, pairs); +} + +/** the verb-phrase summary for a broadcast receipt, by action kind. */ +function receiptSummary(r: TxReceiptView): string { + const stakeAmt = r.amountSun !== undefined ? `${formatSun(r.amountSun)} TRX` : "TRX"; + const resource = r.resource ? String(r.resource) : ""; + switch (r.kind) { + case "stake-freeze": return `Staked ${stakeAmt}${resource ? ` for ${resource}` : ""}`; + case "stake-unfreeze": return `Unstaked ${stakeAmt}`; + case "stake-delegate": return `Delegated ${stakeAmt}${resource ? ` of ${resource}` : ""}`; + case "stake-undelegate": return `Reclaimed ${stakeAmt}${resource ? ` of ${resource}` : ""}`; + case "stake-withdraw": return r.withdrawnSun ? `Withdrew ${formatSun(r.withdrawnSun)} TRX to balance` : "Withdrew expired TRX to balance"; + case "stake-cancel": return "Cancelled pending unstakes"; + case "contract-send": return `Called ${methodName(String(r.method ?? ""))}`; + case "contract-deploy": return "Contract deployed"; + case "send": { + const amount = receiptAmount(r); + return amount ? `Sent ${amount}` : "Sent"; + } + case "broadcast": return "Broadcast"; + } +} + +/** action-specific extra rows (To/From/Address/Contract), by kind. */ +function receiptRows(r: TxReceiptView): Pair[] { + const rows: Pair[] = []; + if (r.kind === "stake-delegate") rows.push(["To", String(r.receiver ?? "")]); + else if (r.kind === "stake-undelegate") rows.push(["From", String(r.receiver ?? "")]); + else if (r.kind === "contract-deploy") rows.push(["Address", String(r.contractAddress ?? "")]); + else if (r.to ?? r.receiver) rows.push(["To", String(r.to ?? r.receiver)]); + if (r.kind === "contract-send") rows.push(["Contract", String(r.contract ?? "")]); + return rows; +} + +/** broadcast-receipt amount: token-aware (symbol/decimals when known, else the contract/asset-id + * identifier for raw-amount sends), native smallest-unit → coin only when no token is involved. */ +function receiptAmount(r: TxReceiptView): string { + if (r.rawAmount !== undefined && r.rawAmount !== null && r.rawAmount !== "") { + const raw = String(r.rawAmount); + const isToken = r.token !== undefined || r.contract !== undefined || r.assetId !== undefined; + if (isToken) { + const human = r.decimals !== undefined && r.decimals !== null ? fromBaseUnits(raw, num(r.decimals, 0)) : formatScalar(raw); + const label = r.token ?? r.contract ?? (r.assetId !== undefined ? `asset ${String(r.assetId)}` : ""); + return label ? `${human} ${String(label)}` : human; + } + return FAMILY_RENDER[r.family].nativeAmount(raw); + } + if (r.amountSun) return `${formatSun(r.amountSun)} TRX`; + return ""; +} + +/** human label for an action kind, e.g. "send" → "tx send" (for dry-run/sign-only headers). */ +function actionLabel(kind: TxReceiptKind): string { + switch (kind) { + case "send": return "tx send"; + case "broadcast": return "tx broadcast"; + case "stake-freeze": return "stake freeze"; + case "stake-unfreeze": return "stake unfreeze"; + case "stake-delegate": return "stake delegate"; + case "stake-undelegate": return "stake undelegate"; + case "stake-withdraw": return "stake withdraw"; + case "stake-cancel": return "stake cancel-unfreeze"; + case "contract-send": return "contract send"; + case "contract-deploy": return "contract deploy"; + } +} + +function historyRow(r: Obj): string[] { + const ts = r.time ?? r.block_timestamp ?? r.timestamp; + const type = r.type ?? r.transfer_type ?? r.direction ?? ""; + const amount = r.amount ?? r.value ?? r.quant ?? ""; + const symbol = r.symbol ?? (r.token_info && typeof r.token_info === "object" ? asObj(r.token_info).symbol : undefined); + const counterparty = r.counterparty ?? r.to ?? r.from ?? ""; + const status = r.status === "failed" || r.confirmed === false ? "failed" : "ok"; + return [formatTime(ts), String(type), `${formatScalar(amount)}${symbol ? ` ${String(symbol)}` : ""}`, String(counterparty), status === "ok" ? ok() : fail()]; +} + +/** account display id for receipts: the centrally-injected --account label when present, + * else the full on-chain address. Callers add their own quoting where wanted. */ +function acct(ctx: TextRenderContext, address: unknown): string { + return ctx.accountLabel ?? String(address ?? ""); +} + +/** identity field pair: prefer the account label, else show the full address — the field + * name tracks the value's real meaning (§0.4). */ +function identity(ctx: TextRenderContext, address: unknown): Pair { + return ctx.accountLabel ? ["Label", ctx.accountLabel] : ["Address", String(address ?? "")]; +} + +function displayName(d: Obj): string { + return String(d.label ?? d.accountId ?? d.id ?? "unnamed"); +} + +/** non-empty address entries — drops families whose address is blank/absent. */ +function nonEmptyAddressEntries(d: Obj): Pair[] { + return Object.entries(asObj(d.addresses)) + .filter(([, address]) => typeof address === "string" && address.length > 0) + .map(([family, address]) => [family, String(address)] as Pair); +} + +function firstAddress(d: Obj): string { + const first = nonEmptyAddressEntries(d)[0]; + return first ? first[1] : ""; +} + +/** per-family address field pairs, addresses shown in full (§0.4 ②). */ +function addressPairs(d: Obj): Pair[] { + return nonEmptyAddressEntries(d).map(([family, address]) => [familyAddressLabel(family), address] as Pair); +} + +function familyAddressLabel(family: string): string { + return FAMILY_RENDER[family as ChainFamily]?.addressLabel ?? `${family} address`; +} + +function typeLabel(v: unknown): string { + return sourceLabel(v); +} + +function secretLabel(v: unknown): string { + switch (v) { + case "mnemonic": return "recovery phrase"; + case "privateKey": return "private key"; + default: return String(v ?? "secret"); + } +} + +function methodName(sig: string): string { + return sig.replace(/\(.*/, "") || sig; +} + +function formatResult(v: unknown): string { + if (Array.isArray(v)) return v.map((x) => formatScalar(x)).join(", "); + return formatScalar(v); +} + +function formatFee(fee: unknown, family: ChainFamily): string { + if (!fee) return "unknown"; + if (typeof fee === "object") { + const f = asObj(fee); + if (f.feeSun) return `${formatSun(f.feeSun)} TRX`; + if (f.bandwidthBurnSunIfNoFreeze) return `${formatSun(f.bandwidthBurnSunIfNoFreeze)} TRX`; + // energy estimate (TRC20/contract via estimateResources): no sun figure — staked energy may + // cover it. Report the estimated energy + whether the account's available energy covers it. + if (f.energy !== undefined) { + const energy = Number(f.energy); + const avail = f.availableEnergy === undefined ? undefined : Number(f.availableEnergy); + const covered = avail !== undefined && avail >= energy ? " (covered by staked energy)" : ""; + return `~${energy.toLocaleString()} energy${covered}`; + } + if (f.note) return String(f.note); + } + return FAMILY_RENDER[family].feeFallback(fee); +} + +function summarizeTx(tx: unknown): string { + if (!tx || typeof tx !== "object") return formatScalar(tx); + const o = asObj(tx); + return shorten(String(o.txid ?? o.txID ?? o.hash ?? JSON.stringify(o))); +} diff --git a/ts/src/adapters/inbound/cli/render/layout.ts b/ts/src/adapters/inbound/cli/render/layout.ts new file mode 100644 index 000000000..ee1a71676 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/layout.ts @@ -0,0 +1,46 @@ +/** + * Layout primitives — structural composition of label/value blocks and tables, + * plus status glyphs. The "§0.4 字段独占一行" vocabulary; no scalar or domain knowledge. + */ +export type Obj = Record; +export type Pair = [string, string]; + +/** aligned `