diff --git a/ts/docs/architecture.md b/ts/docs/architecture.md
deleted file mode 100644
index fe3b8985..00000000
--- a/ts/docs/architecture.md
+++ /dev/null
@@ -1,230 +0,0 @@
-# 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.md b/ts/docs/evm-development-plan.md
deleted file mode 100644
index 2fc00530..00000000
--- a/ts/docs/evm-development-plan.md
+++ /dev/null
@@ -1,593 +0,0 @@
-# wallet-cli EVM Support Development Plan
-
-> Status: to be implemented
-> Applies to: `ts-refactor`
-> Purpose: list the full scope of everything that must be modified, added, and accepted when extending the current TRON-only CLI to TRON + EVM. This document deliberately does not expand into the implementation details of functions and RPC payloads.
-
-## 1. Definition of Done
-
-EVM support cannot merely mean "can connect to an Ethereum RPC". When complete, all of the following must hold simultaneously:
-
-1. `evm` is a formal `ChainFamily`, no longer simulated by a type cast in tests.
-2. It can resolve Ethereum mainnet `evm:1`, as well as any `evm:` added in the config file, e.g. `evm:11155111`, `evm:8453`, `evm:31337`.
-3. An EVM network can be selected by canonical id or a unique alias, and can be set as `defaultNetwork`.
-4. seed, private key, watch-only, and the Ledger Ethereum app all have explicit EVM behavior.
-5. EVM has its own gateway, signing strategy, use cases, and CLI command module, and does not stuff EVM methods into the TRON gateway.
-6. `wallet-cli --help`, family help, command help, `--json-schema`, and `wallet-cli networks` all let a user/agent discover EVM.
-7. Text and JSON output correctly present EVM address, chain id, native currency, gas, fee, nonce, transaction hash, and receipt.
-8. The existing TRON commands, output, secret handling, and exit-code contract remain unchanged.
-
-Development dependency order:
-
-```text
-public contract → Domain → Persistence/Config → Application ports → EVM adapters
- → EVM use cases → CLI commands → Bootstrap → Help/Output → Tests/Docs
-```
-
-### 1.1 The Real Code-Change Classification
-
-The "locations involved" that appear later in this document include modifications, references, and verification — it does not mean every file must change. Based on the current code, the actual classification is as follows.
-
-#### Definitely added
-
-- `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 (added only if EVM history/ABI metadata is to be provided)
-
-#### Definitely modified
-
-- Domain family/address/network/wallet/transaction types
-- Config builtins, custom-network validation, and network display
-- Keystore schema migration and the EVM-address backfill for old wallets
-- `ChainGatewayMap` and the family plugin registry
-- The file location of the generic gateway registry (currently misplaced in `chain/tron/provider.ts`)
-- The outbound Ledger dispatcher (if Ledger EVM is exposed)
-- Token builtin/normalization and the CoinGecko network mapping
-- Root/family/merged command help, the global `--network` description, and the wallet help examples
-- The family renderer, EVM transaction/fee text output
-- Dependencies, architecture docs, help/golden baselines
-
-#### In principle not modified, only adding EVM tests to verify reusability
-
-- `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, and output formatter infrastructure
-
-#### Modified only if a public decision requires it
-
-- `CapabilityRegistry` and bootstrap capability composition: needed only if history/indexer, legacy/EIP-1559, etc. require per-network gating.
-- Help catalog: EVM commands enter the catalog automatically via the registry; modification is needed only to add a families/networks summary at the top level of the catalog.
-- `WalletService`: the existing family detection and repository delegation are reusable; usually only tests are needed, and migration should stay in the persistence adapter.
-- Shared transaction types/pipeline: types must be extended with display fields, but the pipeline control flow is in principle unchanged.
-
-If the implementation must modify one of the above "in principle not modified" modules, you should first point out which family-neutral capability the current abstraction lacks; you must not add `if (family === "evm")` just because the EVM adapter is awkward to write.
-
-## 2. Public Decisions to Lock Before Development
-
-The following decisions must be written into the architecture contract first, otherwise help, the network schema, the command schema, and the tests will be revised repeatedly.
-
-### 2.1 Network identity
-
-Network aliases are deferred while selector input accepts canonical ids only. Alias-related requirements below remain future work for when that feature is restored.
-
-- The EVM canonical network id is fixed as `evm:`.
-- The `xxx` in `evm:xxx` is the EIP-155 chain id, not a name; the actual value must be a positive-integer string.
-- Minimum builtin networks:
- - `evm:1`, with recommended aliases `eth`, `ethereum`.
- - One public testnet, recommended `evm:11155111`, alias `sepolia`.
-- Other networks are added via `config.yaml`; whether to additionally build in Base, Polygon, BSC, etc. is a product decision.
-- An alias must be globally unique; a duplicate alias must keep the `ambiguous_network_alias` error.
-- The chain id reported by RPC must match the configured value; the mismatch must not be ignored before signing or broadcast.
-- `defaultNetwork` is still recommended to remain `tron:mainnet`, unless there is a separate product migration decision.
-
-The recommended public shape of a custom network:
-
-```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 The First-Version Command Surface
-
-The same logical path selects the TRON or EVM implementation via `--network`. A separate top-level `ethereum ...` execution grammar must not be created for EVM.
-
-| Command group | First-version EVM requirement | Notes |
-| --- | --- | --- |
-| wallet lifecycle | supported | create/import/derive show the EVM address afterward; watch can recognize `0x...`. |
-| `account balance` | supported | uses the network native currency. |
-| `account info` | supported | EVM semantics are defined by the EVM use case, not by imitating the TRON resource fields. |
-| `account history` | explicit decision | standard JSON-RPC has no address history; an explorer/indexer adapter is required, otherwise it must not claim availability in the help/capability of an unsupported network. |
-| `account portfolio` | supported | native coin + ERC-20 token book + price provider. |
-| `token add/list/remove/balance/info` | supported | the EVM token kind is `erc20`. |
-| `tx send/broadcast/status/info` | supported | native/ERC-20, legacy/EIP-1559, raw signed tx. |
-| `contract call/send/deploy/info` | supported or explicitly degraded | if `contract info` needs ABI metadata, there must be an explorer source; with only bytecode, the help must describe it truthfully. |
-| `message sign` | supported | software and Ledger behavior are consistent. |
-| `block` | supported | latest or a specified block. |
-| `stake ...` | no EVM implementation | stays TRON-only; root/family help must mark it. |
-
-### 2.3 SDK and Hardware Wallet
-
-- Select a single EVM SDK; the current architecture recommends `viem`, added to production dependencies.
-- If full Ledger EVM support is claimed, add an Ethereum Ledger app adapter and the corresponding package (e.g. `@ledgerhq/hw-app-eth`).
-- If the first version does not do Ledger EVM, `FAMILIES.evm.ledger`, `import ledger --help`, and the README must not show the Ethereum app.
-
-## 3. Phased Development Checklist
-
-### Phase 0: Contract and Test Baseline
-
-- [ ] Confirm the network id, builtin networks, command matrix, Ledger, and history/indexer decisions in Section 2.
-- [ ] Build EVM address, transaction, receipt, legacy fee, EIP-1559 fee, ERC-20, block, and RPC error fixtures.
-- [ ] Establish a new multichain help/golden baseline; the existing TRON-only help parity must no longer serve as the sole truth for the entire root help.
-- [ ] Define the upgrade strategy and rollback behavior for the old `wallets.json` version.
-
-### Phase 1: Domain
-
-Locations involved:
-
-- `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`
-
-Work list:
-
-- [ ] Add `evm` to `ChainFamily` and `FAMILIES`.
-- [ ] Register the EVM BIP44 coin type `60`, smallest unit `wei`, address codec, and Ledger metadata.
-- [ ] Add EVM address derive, validate, normalize/checksum rules and tests.
-- [ ] Restore `NetworkDescriptor` to a discriminated union keyed by `family`.
-- [ ] Add `EvmNetworkDescriptor`: `rpcUrl`, decimal chain id, fee model, native currency, and optional explorer/history config.
-- [ ] Verify that the canonical id's family and chain id are consistent with the descriptor.
-- [ ] Extend the transaction view: gas, gas price, max fee, priority fee, effective gas price, nonce, EVM receipt/status, and other fields.
-- [ ] Remove TRON-only naming assumptions from the shared view; when keeping backward-compatible TRON JSON fields, document them explicitly.
-- [ ] Confirm the family constraint of the `erc20` token kind and contract-address normalization.
-- [ ] Update seed/private-key address derivation, dedup, account projection, and family-detection tests.
-- [ ] Complete typed errors for network/chain mismatch, invalid chain id, invalid EVM address, etc.
-
-### Phase 2: Wallet Persistence and Migration
-
-Locations involved:
-
-- `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 and migration fixtures
-
-Work list:
-
-- [ ] Raise the `wallets.json` schema version.
-- [ ] Handle existing seed/private-key wallets that have only a TRON cached address; adding `evm` to `ChainAddresses` must not invalidate old files.
-- [ ] Define the timing for the EVM address's lazy backfill / explicit migration, and the UX when a master password is needed.
-- [ ] Ensure migration uses an atomic write and lock, and a failure does not corrupt the original file.
-- [ ] Newly created and newly imported seed/private-key wallets generate both a TRON and an EVM address.
-- [ ] `derive` for a new account generates addresses for both families.
-- [ ] watch-only automatically recognizes TRON/EVM; an EVM address is normalized before storage.
-- [ ] Ledger/watch remain single-family sources.
-- [ ] list/current/use/rename/delete/backup and address lookup support EVM addresses.
-- [ ] Backup metadata includes both known TRON/EVM addresses; for an old wallet not yet backfilled, the field must not be fabricated.
-- [ ] Verify behavior for the same private key, a different BIP44 seed path, old-data migration, and dedup.
-
-### Phase 3: Config and NetworkRegistry
-
-Locations involved:
-
-- `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`
-
-Work list:
-
-- [ ] Add the `evm:1` and the chosen-testnet builtin descriptors.
-- [ ] Apply runtime schema validation to a user-defined network, no longer casting directly to `NetworkDescriptor`.
-- [ ] Support any valid `evm:`, and reject inconsistencies among `family`, id, and chain id.
-- [ ] Validate `rpcUrl`, native currency, fee model, aliases, and the optional indexer/explorer fields.
-- [ ] When alias support is restored, `NetworkRegistry.resolve()` supports the EVM canonical id, case-insensitive aliases, and the ambiguity check.
-- [ ] `config defaultNetwork evm:1`, alias setting, and persistence work.
-- [ ] `wallet-cli networks` displays builtin and custom EVM networks, the native symbol, and a safe summary of RPC/fee model/capabilities.
-- [ ] Do not leak any API key that the RPC URL may contain in ordinary output; a redaction rule is needed.
-- [ ] The network RPC client verifies the remote chain id on first use.
-
-### Phase 4: Application Ports and Shared Services
-
-Locations involved:
-
-- `src/application/ports/chain/evm-gateway.ts` (added)
-- `src/application/ports/chain/gateway-provider.ts`
-- `src/application/services/evm-confirmation.ts` (added)
-- `src/application/services/capability/index.ts` (modified only if per-network capability is needed)
-
-The following shared modules are expected to only gain EVM reuse tests, with no production-code change: `Broadcaster`, `LedgerDevice`,
-`TxPipeline`, `SignerResolver`, software/device signer, `TargetResolver`, `transactionMode`.
-
-Work list:
-
-- [ ] Define `EvmGateway`, holding only the read/build/estimate/broadcast capabilities EVM needs.
-- [ ] Add `evm` to `ChainGatewayMap`, preserving the typed family lookup.
-- [ ] Use EVM fixtures to verify that the shared `Broadcaster`, `TxPipeline`, `Signer`, and `SignStrategy` can carry an EVM transaction directly; the control flow is expected to be unchanged.
-- [ ] Implement EVM confirmation normalization, preserving the existing contract of returning a submitted receipt after a `--wait` timeout.
-- [ ] Support the legacy and EIP-1559 fee models; a network-specific trait must not be misjudged by a family-wide command capability as supported by all EVM networks.
-- [ ] Adjust the capability registration to distinguish "the family has this command" from "this network has an indexer / EIP-1559 / etc. capability".
-- [ ] Preserve the invariants: watch-only cannot sign, a wrong-family account is blocked, and dry-run does not decrypt the private key.
-
-### Phase 5: EVM Outbound Adapters
-
-Recommended new locations:
-
-```text
-src/adapters/outbound/chain/evm/
-├── index.ts
-├── provider.ts
-├── evm.ts
-├── signing-strategy.ts
-├── evm-responses.ts
-└── history-reader.ts # added only if history/indexer support is decided
-```
-
-Work list:
-
-- [ ] Implement a per-network EVM JSON-RPC client/gateway.
-- [ ] Implement native balance, nonce/code, block, transaction, receipt, and fee/gas reads.
-- [ ] Implement native/ERC-20 transfer, contract call/send/deploy, estimate, and raw transaction broadcast.
-- [ ] Implement software transaction signing and personal-message signing.
-- [ ] Validate every RPC response before normalizing, to avoid passing a provider-specific shape into a use case.
-- [ ] Uniformly handle errors such as revert reason, replacement/nonce, insufficient funds, underpriced fee, chain mismatch, and timeout.
-- [ ] If supporting account history / ABI metadata, add a separate explorer/indexer adapter; do not pretend standard JSON-RPC can provide it.
-- [ ] Add EVM adapter unit tests with a mocked transport, not relying on a public RPC.
-
-### Phase 6: Ledger EVM Adapter
-
-Locations involved:
-
-- `src/adapters/outbound/ledger/index.ts` or split into 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 and tsup bundling config
-
-Work list:
-
-- [ ] Add Ethereum Ledger app transport, address, transaction signing, and message signing.
-- [ ] The EVM derivation path uses coin type 60, and supports the existing flow for index/path/address scan.
-- [ ] `import ledger --app ethereum` appears in the schema, help, interactive choices, and tests.
-- [ ] The precheck compares the device address with the cached address.
-- [ ] Classify user rejection, wrong app, locked device, wrong seed, and transport error.
-- [ ] Update the tsup `noExternal` / native addon config and the Ledger emulator/real-device verification.
-
-### Phase 7: EVM Application Use Cases
-
-Recommended new locations:
-
-```text
-src/application/use-cases/evm/
-├── account-service.ts
-├── token-service.ts
-├── transaction-service.ts
-├── contract-service.ts
-└── block-service.ts
-```
-
-Work list:
-
-- [ ] Implement EVM account balance/info/portfolio; handle history per the Section 2 decision.
-- [ ] Implement ERC-20 metadata, balance, and token book workflows.
-- [ ] Implement native/ERC-20 send, signed raw tx broadcast, status/info.
-- [ ] Implement the agreed first-version semantics of contract call/send/deploy/info.
-- [ ] Implement EVM block query.
-- [ ] Reuse `MessageService`, `TxPipeline`, `TransactionMode`, the token repository, and the price port; do not reuse a use case carrying TRON semantics.
-- [ ] All returned shapes use a family-aware, stably-emittable normalized view.
-
-### Phase 8: EVM CLI Command Module
-
-Recommended new locations:
-
-```text
-src/adapters/inbound/cli/commands/evm/
-├── index.ts
-├── account.ts
-├── token.ts
-├── tx.ts
-├── contract.ts
-├── message.ts
-└── block.ts
-```
-
-Shared locations involved:
-
-- `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`
-
-Work list:
-
-- [ ] Each EVM command registers `family: "evm"`, the logical path, capability, requirements, Zod fields, examples, and formatter.
-- [ ] EVM address, hash, hex data, ABI, quantity, gas, fee, nonce, and block identifier use an EVM-specific schema.
-- [ ] `tx send` handles both native and ERC-20, but does not expose TRC10/TRC20 flags.
-- [ ] The legacy/EIP-1559 command flags, mutual-exclusion conditions, and defaults are driven by a single schema that drives both help and JSON Schema.
-- [ ] EVM does not register `stake` commands.
-- [ ] Logical routing selects the EVM implementation via `--network evm:`; the same path for TRON/EVM does not pollute each other's fields or examples.
-- [ ] The command id is stable as `evm.`, e.g. `evm.tx.send`.
-
-### Phase 9: Bootstrap and Family Composition
-
-Locations involved:
-
-- `src/bootstrap/families/evm.ts` (added)
-- `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` (may be renamed to a family-neutral gateway-registry location)
-
-Work list:
-
-- [ ] Build the `evmFamily` plugin: meta, gateway factory, sign strategy, use cases, command module.
-- [ ] Add `evmFamily` to `FAMILY_REGISTRY`.
-- [ ] `familyMap()` is complete for both TRON/EVM factories and signing strategies.
-- [ ] The gateway cache is still isolated by canonical network id, not sharing a client across different chains.
-- [ ] Capability composition is produced correctly from family commands + per-network traits.
-- [ ] Bootstrap tests expect the enabled families to be `tron`, `evm`, and verify the command registration of both.
-
-### Phase 10: Help, Discovery, and Machine Catalog
-
-This phase is a necessary condition for publicly supporting EVM; it must not be treated as a documentation wrap-up.
-
-Locations involved:
-
-- `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 and baselines
-
-Must support and test:
-
-- [ ] `wallet-cli --help`
- - Shows the supported families: TRON, EVM.
- - Explains that the command implementation is selected by `--network`.
- - Has at least one `--network evm:1` example.
- - `stake` is clearly marked TRON-only.
-- [ ] `wallet-cli evm --help`
- - Shows the EVM-available command tree, without `stake`.
- - If the `evm` prefix is used only for help/catalog discovery and cannot be used for ordinary execution, it must say so explicitly in the output.
-- [ ] `wallet-cli evm tx send --help`
- - Shows only EVM fields, EVM examples, the fee-model explanation, and the EVM address format.
-- [ ] `wallet-cli tx send --help`
- - The merged logical help must clearly mark family-specific flags/examples; it must not just take the metadata of the registry's first family.
-- [ ] `wallet-cli tx send --network evm:1 --help`
- - Meta parsing must correctly consume the `--network` value and parse it into EVM help; it must not treat `evm:1` as a command positional.
-- [ ] `wallet-cli networks --help`
- - Explains the canonical id `evm:`, aliases, and the source of custom networks.
-- [ ] `wallet-cli --json-schema`
- - The full catalog includes the `evm.*` commands.
- - The top level of the catalog should add an enabled-families and available-networks summary.
-- [ ] `wallet-cli evm --json-schema`
- - Emits only EVM chain commands; the schema and examples contain no TRON-only flags.
-- [ ] `--json-schema` for each EVM leaf
- - The input schema, requires, capability, examples, and stdin flags are correct.
-- [ ] global `--network` description
- - The examples include at least `tron:nile`, `evm:1`, an alias, and the config fallback.
-- [ ] unknown/disabled family, unknown EVM network, and family/network mismatch all emit a clear usage error and exit 2.
-
-### Phase 11: Text and JSON Output
-
-Locations involved:
-
-- `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
-
-Work list:
-
-- [ ] Add EVM hooks to `FAMILY_RENDER`.
-- [ ] Display the native amount per the network `nativeCurrency`, not assuming every EVM network is ETH.
-- [ ] EVM transaction info/receipt shows hash, from/to/value, nonce, gas, fee, status, block, and contract address.
-- [ ] Both legacy and EIP-1559 fee text render correctly.
-- [ ] wallet/list/current/import/derive show both TRON and EVM addresses, and the address is not wrongly abbreviated or mislabeled by family.
-- [ ] The `networks` text table shows the EVM chain id, native symbol, and fee model.
-- [ ] The JSON envelope keeps `wallet-cli.result.v1` and emits:
- - `command: "evm...."`
- - `chain.family: "evm"`
- - `chain.network: "evm:"`
- - `chain.chainId: ""`
-- [ ] All wei, gas, fee, nonce, and block quantities avoid JavaScript number precision loss; JSON uses a stable string rule.
-- [ ] error, warning, and progress still obey the stdout/stderr and single-terminal-frame contract.
-
-### Phase 12: Token Book and Price Provider
-
-Locations involved:
-
-- `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`
-
-Work list:
-
-- [ ] Add official ERC-20 token entries for the chosen builtin EVM networks; a testnet may keep an empty list.
-- [ ] The ERC-20 contract id uses a consistent normalized/checksummed comparison to avoid case-based duplicates.
-- [ ] Confirm the token book scope is still `(networkId, accountRef)`, so different EVM chains do not share a list.
-- [ ] The CoinGecko native-coin id and asset platform must not be derived from the `evm:` prefix alone; they must be decided by the actual network mapping/config.
-- [ ] When a custom EVM network has no price mapping, return null/warning, and do not fail the portfolio command.
-- [ ] token price lookup, official/user merge, remove protection, and portfolio tests cover EVM.
-
-### Phase 13: Tests and Quality Gates
-
-#### Unit tests
-
-- [ ] EVM address derive/validate/checksum.
-- [ ] BIP44 coin type 60 and 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 and typed errors.
-- [ ] software/Ledger transaction and message signing.
-- [ ] legacy/EIP-1559 transaction build, estimate, broadcast, confirmation.
-- [ ] ERC-20, contract, block, account, and 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 include `evm:1` and custom `evm:31337`.
-- [ ] `config defaultNetwork evm:1` and alias round trip.
-- [ ] `--network evm:1` routes to an `evm.*` command id.
-- [ ] The same logical command yields a different schema, client, and output under TRON/EVM.
-- [ ] wrong-family account, unknown chain, alias collision, unsupported network trait.
-- [ ] JSON one-frame, exit `0/1/2`, stderr progress, secret redaction.
-- [ ] All old TRON golden tests still pass; the expected output of the root help switches to the new multichain baseline.
-
-#### Integration/live tests
-
-- [ ] Add a local EVM suite, recommended Anvil, covering account, native/ERC-20 send, contract, block, sign-only, broadcast, `--wait`.
-- [ ] Add a public EVM testnet smoke suite, using an isolated wallet home and secret source, not logging the private key.
-- [ ] Keep the Nile live suite, confirming the EVM changes cause no TRON regression.
-- [ ] If supporting Ledger EVM, add Speculos or real-device 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
-# new: EVM local integration suite
-# new: EVM public-testnet smoke suite
-```
-
-### Phase 14: User Documentation and Release
-
-Locations involved:
-
-- `README.md`
-- `docs/architecture.md`
-- network/config example docs
-- command/help baselines
-- release notes and migration notes
-
-Work list:
-
-- [ ] Change the README to TRON + EVM, adding `evm:1`, custom chain, wallet, and send examples.
-- [ ] Mark the architecture diagram and the family-extension section as EVM-implemented, no longer writing it as a future item.
-- [ ] The docs list the builtin networks, canonical id, aliases, custom-network schema, and how to set `defaultNetwork`.
-- [ ] Explain that the same wallet has different TRON/EVM derivation paths, and that watch/Ledger are single-family.
-- [ ] Explain optional capabilities such as EVM history, contract metadata, price, and Ledger, and their network requirements.
-- [ ] Provide the behavior of upgrading from the old wallets schema, the backup recommendation, and the failure-recovery method.
-- [ ] Before release, run end-to-end acceptance once each with a brand-new home and an old-version home.
-
-## 4. Master Table of File Changes
-
-| Layer | Modified | Added |
-| --- | --- | --- |
-| Domain | family, address, network, wallet, tx, token, errors | EVM codec/types (may cohere within existing modules) |
-| 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 and fixtures |
-| Docs | README, architecture, network/config docs | migration/release notes |
-
-## 5. Unacceptable Shortcuts
-
-- Do not merely add `evm` to the union without handling the old-wallet address-cache migration.
-- Do not type-cast a custom network directly in `ConfigLoader` without validation.
-- Do not add EVM RPC methods into `TronGateway` or create a universal gateway that contains all chains' methods.
-- Do not let all EVM networks automatically gain explorer, history, or EIP-1559 capability just because the family has the command.
-- Do not let root help, leaf help, and JSON Schema still show only TRON examples.
-- Do not hardcode all EVM native currencies as ETH.
-- Do not convert a bigint fee/value into an unsafe JavaScript number.
-- Do not leak an API key / private key in the RPC URL, an error, a verbose log, the JSON envelope, or a test artifact.
-- Do not break TRON command ids, the JSON envelope, exit codes, or the stdout/stderr discipline by adding EVM.
-
-## 6. Final Acceptance Examples
-
-EVM may be declared publicly supported only when all of the following behaviors hold:
-
-```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
-```
-
-Here `evm:31337` must be providable by the user's config; it is not required that every chain id become a builtin network.
diff --git a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md
index 6c13d906..2689b02d 100644
--- a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md
+++ b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md
@@ -262,7 +262,7 @@ interface FamilyPlugin {
}
```
-`bootstrap/families/tron.ts` is TRON's concrete composition: it builds the `TronRpcClient`, the TronGrid history reader, the TRON use cases, and the `TronModule`. Application and adapters must not import the family registry in reverse.
+`bootstrap/families/tron.ts` is TRON's concrete composition: it builds the `TronRpcClient`, the TronGrid history reader, the TRON use cases, and registers each command via `registerTronChainCommands`, which `addChain`s the neutral `ChainSpec` for each command together with its TRON `FamilyBinding`. Application and adapters must not import the family registry in reverse.
---
@@ -276,35 +276,38 @@ interface FamilyPlugin {
| --- | --- |
| `path` | Neutral commands use the full path; chain commands use a cross-family logical path. |
| `family` | Omitted for neutral commands; when present, the resolved network selects the family implementation. |
-| `stdin` | A dedicated stdin channel for `privateKey`, `mnemonic`, `tx`, `message`. |
+| `stdin` | A dedicated stdin channel, one of `tx` or `message` (signed-tx JSON / message to sign). Wallet secrets (`mnemonic`, `privateKey`) and the master password are **not** stdin-backed — see `secretsTtyOnly`. |
| `network` | `none`, `optional`, `required`; today both optional/required can fall back to the default network. |
| `wallet` | `none` or `optional`; optional can override the active account with `--account`. |
| `auth` | An unlock declaration for help/catalog; actual software signing uses lazy decrypt. |
| `broadcasts` | Controls whether help reveals `--wait`. |
| `passwordMode` | `establish` or `verify`, controls interactive master-password priming. |
| `interactive` | Only commands that explicitly opt in may open a TTY prompt. |
+| `secretsTtyOnly` | The command's secrets (import mnemonic/private-key, `change-password`) are TTY-only; no `--*-stdin` source exists, and dispatch fails fast when not on a TTY. |
| `capability` | A per-network capability that must pass before execution. |
| `fields` / `input` | Zod field metadata and the complete validation schema. |
| `run` | Translates CLI input/context into a use-case call and returns structured data. |
| `formatText` | Optional text renderer; JSON does not use it. |
-The stable command id is derived from metadata: a neutral command is `path.join(".")`, e.g. `import.mnemonic`; a chain command is `family.path`, e.g. `tron.tx.send`.
+The stable command id is derived from metadata as `path.join(".")` for every command — a neutral command is e.g. `import.mnemonic`, and a chain command is e.g. `tx.send` (no family prefix). The family is not encoded in the id; it travels in the envelope's `chain` view (`chain.family`), so the id matches what the user types and is redundancy-free.
### 4.2 The Two Command Classes and Routing
+A chain command is one `ChainCommandDefinition` — a service-free `ChainSpec` plus a `families` table of per-family `FamilyBinding`s (`run` + optional `fields`/`refine` delta). The registry keys it by logical path; dispatch resolves the network, then selects the binding by `network.family`. When the resolved network's family has no binding for that command, dispatch returns `network_family_mismatch`. The merged input schema is `baseFields` plus each binding's `fields`, and validation composes `baseRefine` then each binding's `refine`. `isChainCommand` (presence of `families`) is the discriminator between the two command kinds.
+
```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
+ KIND -->|no| CHAIN[resolveChain by logical path]
+ CHAIN --> NET[resolve explicit/default network]
+ NET --> BIND[select families network.family binding]
+ NEUTRAL --> EXEC[executeCommand]
+ BIND --> XEXEC[executeChainCommand: merge base+delta, run, spec.formatText]
```
- `tron` is not a public prefix for ordinary execution commands; `--network` decides the family.
-- Help/JSON Schema may use the family prefix to address a concrete implementation precisely.
+- Help/JSON Schema may accept a leading family token (e.g. `tron block --json-schema`) as an addressing convenience, but the emitted id stays unqualified and the catalog entry lists `families: [...]`.
- An unknown top-level/subcommand/flag must return `unknown_command` or `invalid_option`; yargs must not silently succeed.
### 4.3 The Fixed Dispatch Order
@@ -335,16 +338,22 @@ wallet-cli
├── create
├── import mnemonic | private-key | ledger | watch
├── list | use | current | rename | derive | backup | delete
+├── change-password
├── config | networks
├── account balance | info | history | portfolio
├── token balance | info | add | list | remove
├── tx send | broadcast | status | info
├── contract call | send | deploy | info
-├── stake freeze | unfreeze | withdraw | cancel-unfreeze | delegate | undelegate
+├── stake freeze | unfreeze | withdraw | cancel-unfreeze | delegate | undelegate | info | delegated
+├── vote cast | list | status
+├── reward balance | withdraw
+├── chain params | prices | node
├── message sign
└── block [number]
```
+`create`, `import`, `list`, `use`, `current`, `rename`, `derive`, `backup`, `delete`, `change-password`, `config`, and `networks` are neutral. `change-password` re-encrypts every software keystore under a new master password; both the old and new passwords are entered interactively (TTY-only). `stake info`/`delegated`, `vote status`, `reward balance`, and `chain *` are read-only chain queries; `vote cast` and `reward withdraw` are transaction-creating.
+
Neutral commands do not touch a chain. Chain commands are currently all provided by the TRON plugin. All transaction-creating commands jointly support:
- `--dry-run`: build + estimate, no decrypt, no sign, no broadcast.
@@ -362,7 +371,7 @@ Neutral commands do not touch a chain. Chain commands are currently all provided
| `--timeout` | Timeout for a single RPC/device operation. |
| `--verbose` / `-v` | Additional diagnostics. |
| `--wait` | Poll for confirmation after broadcast. |
-| `--wait-timeout` | Upper bound for confirmation polling, default 60000 ms. |
+| `--wait-timeout` | Upper bound for confirmation polling; defaults to `config.waitTimeoutMs` (built-in 60000 ms). |
| `--password-stdin` | Read the master password from fd 0. |
| `--help` / `--version` / `--json-schema` | Meta requests. |
@@ -434,7 +443,7 @@ Application defines capabilities, not concrete technologies:
| `NetworkRegistry` | canonical network id/default resolution | outbound config registry |
| `LedgerDevice` | address, tx/message signing, app config | `Ledger` |
| `ChainGatewayProvider` | obtain a gateway by network/family | `ChainGatewayRegistry` |
-| `TronGateway` | TRON reads/build/estimate/broadcast | `TronRpcClient` |
+| `TronGateway` | TRON reads/build/estimate/broadcast, plus stake/delegation/vote/reward and chain (params/prices/node) queries | `TronRpcClient` |
| `TronHistoryReader` | TronGrid transaction history | `TronGridHistoryReader` |
| `TokenRepository` | official/user token book | `TokenBook` |
| `PriceProvider` | best-effort USD price | CoinGecko/Null provider |
@@ -444,10 +453,10 @@ Application defines capabilities, not concrete technologies:
### 7.2 Use Cases
-- `WalletService`: create/import/list/use/current/rename/derive/delete/backup, with no knowledge of JSON/Zod/yargs.
-- `ConfigService`: effective config view, key validation, canonical network normalization, and document update.
+- `WalletService`: create/import/list/use/current/rename/derive/delete/backup/change-password, with no knowledge of JSON/Zod/yargs. `changePassword` re-encrypts every software keystore under a new master password.
+- `ConfigService`: effective config view, key validation, canonical network normalization, and document update. Writable keys are `defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`.
- `MessageService`: sign a message via the signer port.
-- TRON use cases: account, token, transaction, contract, stake, block; they use only the TRON gateway and the necessary shared ports.
+- TRON use cases: account, token, transaction, contract, stake, vote, reward, chain, block; they use only the TRON gateway and the necessary shared ports. `TronVoteService` reads voting power authoritatively from `TronStakeService.votingPower` (injected), not from raw balances; its witness/brokerage fan-out is bounded and per-request cached. `TronChainService` exposes governance params, energy/bandwidth prices, and node sync status.
An inbound command's responsibility is to turn argv/Zod input and `ExecutionContext` into use-case input and then choose a stable output view; it must not do persistence or provider transport itself.
@@ -488,7 +497,9 @@ Canonical-id resolution is case-insensitive. Aliases remain descriptor metadata
`ChainGatewayRegistry` is injected with the family factory by Bootstrap and caches the client by network id. Its generic `client()` may only use the truly common minimal capabilities; a family use case obtains the `TronGateway` via the guarded `get(net, "tron")`. TRON staking and the future EVM gas/nonce must not be forced into a universal gateway.
-Capabilities consist of two parts: the command-backed keys declared by registered commands, plus the network traits in `NetworkDescriptor.capabilities`. The gate must happen before the use case.
+Capabilities consist of two parts: the command-backed keys declared by registered commands (e.g. `vote.cast`, `vote.list`, `vote.status`, `reward.balance`, `reward.withdraw`, `staking.freeze`, `staking.delegate`), plus the network traits in `NetworkDescriptor.capabilities`. The gate must happen before the use case.
+
+The TRON gateway reads account/resource/stake/vote/reward state and confirms transactions against the **full node's unconfirmed view** (`getUnconfirmedTransactionInfo`, `/wallet/getaccount`, `/wallet/getReward`, `/wallet/getBrokerage`, etc.), not the solidified node. Unconfirmed info is available roughly one block after inclusion (~3s) rather than after solidification (~19 blocks / ~60s), so `--wait` confirms at "mined in a block" quickly and all reads are fresh and mutually consistent.
---
@@ -527,7 +538,7 @@ flowchart LR
CONFIRM --> FINAL[confirmed / failed
or timeout → submitted]
```
-The pipeline knows only the signer and the `Broadcaster` port; the family use case provides build, estimate, and confirm callbacks. `timeoutMs` limits a single operation; `waitTimeoutMs` limits confirmation polling. Once a transaction has been broadcast, a polling error/timeout must not reclassify the command as not-broadcast — it returns `submitted`.
+The pipeline knows only the signer and the `Broadcaster` port; the family use case provides build, estimate, and confirm callbacks. `timeoutMs` limits a single operation; `waitTimeoutMs` (from `config.waitTimeoutMs`, overridable by `--wait-timeout`) limits confirmation polling. TRON's `tronConfirmation` polls the full node's unconfirmed transaction info, so confirmation resolves in a few seconds rather than after solidification. Once a transaction has been broadcast, a polling error/timeout must not reclassify the command as not-broadcast — it returns `submitted`.
### 9.3 Ledger
@@ -581,7 +592,7 @@ IDs are random 5-byte Crockford base32 lowercase strings. Labels are case-insens
The user entries in `tokens.json` are partitioned by `|`; the effective list is official first, then user-only, deduplicated by `(kind,id)`. Official entries cannot be deleted/overwritten.
-`config.yaml` is shallow-merged with the builtin config. The only writable keys are `defaultNetwork`, `defaultOutput`, `timeoutMs`; `networks` is a CLI read-only view. Runtime globals are not written back to config.
+`config.yaml` is shallow-merged with the builtin config. The only writable keys are `defaultNetwork`, `defaultOutput`, `timeoutMs`, `waitTimeoutMs`; `networks` is a CLI read-only view. `waitTimeoutMs` must be a non-negative integer and supplies the default confirmation-polling cap (built-in 60000 ms). Runtime globals are not written back to config.
### 10.4 Encrypted Blobs
@@ -603,9 +614,10 @@ flowchart LR
- A handler must not read `process.stdin` directly; `StreamManager.readStdinOnce()` reads at most once per execution.
- A single invocation may use fd 0 through only one `--*-stdin` channel.
+- Only `password` (`--password-stdin`, a global) and the command-scoped `tx` / `message` channels are stdin-backed. Wallet secrets (`mnemonic`, `privateKey`) and the master-password change have **no** stdin flag — they are TTY-only (`secretsTtyOnly`): a hidden interactive prompt, or fail fast with `tty_required` off a TTY. Importing an existing secret and re-keying the vault are deliberately human moments.
- Secret argv, `MASTER_PASSWORD`, `--*-file`, and ordinary env secrets are not supported.
- A secret must not enter logs, diagnostics, error details, or the result envelope.
-- Interactive allowlist: create, the four imports, delete, backup; the order is password → field gap-fill/account selection → command confirm.
+- Interactive allowlist: create, the four imports, delete, backup, change-password; the order is password → field gap-fill/account selection → command confirm.
---
@@ -639,7 +651,7 @@ flowchart LR
GLOBAL[GLOBAL_FLAG_SPECS] --> ARITY & HELP & CATALOG
```
-A hand-maintained command flag table must not be created separately. The public help/output is a stable contract; when it changes, automated tests must verify root, group, leaf, JSON Schema, and functional scenarios.
+Arity is derived from the field's Zod type: a `z.array(...)` field projects to a first-class repeatable yargs flag (`array: true`), so `--for a --for b` arrives as a `string[]` with no preprocess/pipe patch, and its help renders the element type (``), not the container. A hand-maintained command flag table must not be created separately. The public help/output is a stable contract; when it changes, automated tests must verify root, group, leaf, JSON Schema, and functional scenarios.
---
@@ -668,7 +680,7 @@ flowchart LR
PLUGIN --> TEST[7 routing/output/contract tests]
```
-Adding a family must extend `ChainFamily`/`FAMILIES`, the discriminated network/address types, `ChainGatewayMap`, the sign strategy, the gateway, use cases, commands, the family plugin, and networks/render/tests. Only a genuinely identical intent and I/O shape may be factored into a shared port; the TRON resource model and the EVM gas/nonce must remain separate.
+Adding a family must extend `ChainFamily`/`FAMILIES`, the discriminated network/address types, `ChainGatewayMap`, the sign strategy, the gateway, and use cases; add a `FamilyBinding` for that family to each shared command's `ChainSpec.families` table (with its option delta in `binding.fields` and family-shaped rows in `FAMILY_RENDER[family]`) rather than defining new command objects; and extend networks/render/tests. Only a genuinely identical intent and I/O shape may be factored into a shared port; the TRON resource model and the EVM gas/nonce must remain separate.
### 14.3 Adding a Wallet Source
diff --git a/ts/docs/wallet-cli-v1-failed-cases-retest-qa-report.md b/ts/docs/wallet-cli-v1-failed-cases-retest-qa-report.md
deleted file mode 100644
index 797cfff8..00000000
--- a/ts/docs/wallet-cli-v1-failed-cases-retest-qa-report.md
+++ /dev/null
@@ -1,118 +0,0 @@
-# Wallet-CLI v1 失败用例复测补充报告
-
-## 一、测试概要
-
-| 项目 | 内容 |
-|---|---|
-| 测试对象 | Wallet-CLI TypeScript CLI,分支 feat/ts-version |
-| 测试用例来源 | /Users/admin/Documents/wallet_cil/testcases/wallet-cli-v1-command-modules/Wallet-CLI-完整测试用例.md |
-| 测试数据来源 | /Users/admin/Documents/wallet_cil/testcases/wallet-cli-v1-command-modules/wallet-cli-v1-test-data.env |
-| 执行入口 | node dist/index.js |
-| 执行网络 | tron:mainnet |
-| 首轮执行目录 | /Users/admin/Documents/wallet_cil/wallet-cli-feat-ts-version/ts/qa/execution-results/wallet-cli-v1-command-modules-no-ledger-20260702-144259 |
-| 复测执行目录 | /Users/admin/Documents/wallet_cil/wallet-cli-feat-ts-version/ts/qa/execution-results/wallet-cli-v1-failed-cases-retest-no-ledger-20260702-151024 |
-| 复测时间 | 2026-07-02T07:13:16.264Z |
-| 复测策略 | 仅复测首轮失败的 13 条用例;每条用例先执行 help,再执行实际命令;单条实际命令超时 120000ms;Ledger 仍按要求排除;敏感信息已脱敏 |
-| 前置处理 | 复测使用独立 WALLET_CLI_HOME,执行器自动创建测试钱包、watch-only 账户并设置默认网络;未修改源码 |
-
-## 二、测试范围
-
-包含:首轮失败的全局参数与帮助、通用命令、本地命令、Token、交易、合约、管理命令全局参数相关用例。
-
-不包含:Ledger 硬件设备相关用例、首轮已通过用例、主网真实资产写入验证、主网广播/质押/合约写入正向提交验证。
-
-## 三、执行结果汇总
-
-| 指标 | 数量 |
-|---|---:|
-| 首轮失败用例 | 13 |
-| 本轮纳入复测 | 13 |
-| 排除用例 | 0 |
-| 复测通过 | 1 |
-| 复测失败 | 12 |
-| 复测阻塞 | 0 |
-| 复测通过率 | 7.69% |
-
-## 四、模块测试结果汇总
-
-| 模块 | 用例总数 | 执行数 | 通过 | 失败 | 阻塞 | 通过率 | 是否通过 | 备注 |
-|---|---:|---:|---:|---:|---:|---:|---|---|
-| 全局参数与帮助 | 1 | 1 | 0 | 1 | 0 | 0.00% | 未通过 | 仍存在失败用例 |
-| 通用命令 | 3 | 3 | 0 | 3 | 0 | 0.00% | 未通过 | 仍存在失败用例 |
-| 本地命令 | 3 | 3 | 0 | 3 | 0 | 0.00% | 未通过 | 仍存在失败用例 |
-| Token | 3 | 3 | 0 | 3 | 0 | 0.00% | 未通过 | 仍存在失败用例 |
-| 交易 | 1 | 1 | 1 | 0 | 0 | 100.00% | 通过 | 复测通过 |
-| 合约 | 1 | 1 | 0 | 1 | 0 | 0.00% | 未通过 | 仍存在失败用例 |
-| 管理命令全局参数 | 1 | 1 | 0 | 1 | 0 | 0.00% | 未通过 | 仍存在失败用例 |
-
-## 五、详细测试结果
-
-| 测试用例ID | 模块 | 子模块 | 测试场景 | 前置条件 | 执行命令 | 优先级 | 预期结果 | 实际结果 | 执行结论 | 用例类型 | 回報 |
-|---|---|---|---|---|---|---|---|---|---|---|---|
-| GLOB-017 | 全局参数与帮助 | 密码 stdin | 密码错误 | 已存在软件钱包 | printf '%s' "wrong-password" \| wallet-cli message sign --message "qa" --account "$QA_ACCOUNT" --password-stdin | P1 | 命令失败;返回 `wrong_password`;不返回签名 | 退出码=1;错误码=auth_failed;error code mismatch; expected one of wrong_password;输出=error [auth_failed]: incorrect master password | 失败 | 异常 | 預期結果偏差 實際結果正常|
-| COM-015 | 通用命令 | import private-key | master password 错误后重试 | 已设置 master password;TTY 可交互 | wallet-cli import private-key --label qa-pk-retry | P1 | 第一次密码错误提示 `wrong_password` 或等价错误;允许重新输入;正确密码后继续导入 | 退出码=2;error code mismatch; expected one of wrong_password;输出=spawn node /Users/admin/Documents/wallet_cil/wallet-cli-feat-ts-version/ts/dist/index.js import private-key --label qa-pk-retry ? Master password (hidden): warning: incorrect master password ? Master password (hidden): auth_failed | 失败 | 异常 | 沒復現 |
-| COM-025 | 通用命令 | import watch | 当前版本不支持登记 EVM watch-only 地址 | 本地无同名 label | wallet-cli import watch --address "$QA_EVM_ADDRESS" --label qa-watch-evm | P2 | 命令失败;返回 `invalid_option`;不创建账户 | 退出码=2;错误码=invalid_value;error code mismatch; expected one of invalid_option;输出=error [invalid_value]: unrecognised address format: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 | 失败 | 异常 | 預期結果偏差 實際結果正常|
-| COM-027 | 通用命令 | import watch | 地址格式非法 | 无 | wallet-cli import watch --address "abc123" --label qa-watch-invalid | P1 | 命令失败;返回 `invalid_option`;不创建账户 | 退出码=2;错误码=invalid_value;error code mismatch; expected one of invalid_option;输出=error [invalid_value]: unrecognised address format: abc123 | 失败 | 异常 | 預期結果偏差 實際結果正常|
-| LOC-013 | 本地命令 | derive | 密码错误 | `$QA_SEED_ACCOUNT` 存在 | printf '%s' "wrong-password" \| wallet-cli derive --account "$QA_SEED_ACCOUNT" --label qa-derived-wrong --password-stdin | P1 | 命令失败;返回 `wrong_password`;不派生账户 | 退出码=1;错误码=auth_failed;error code mismatch; expected one of wrong_password;输出=error [auth_failed]: incorrect master password | 失败 | 异常 | 預期結果偏差 實際結果正常|
-| LOC-019 | 本地命令 | backup | 密码错误 | `$QA_ACCOUNT` 为软件钱包 | wallet-cli backup --account "$QA_ACCOUNT" --out "$BACKUP_DIR/wrong-password.json" | P1 | 命令失败;返回 `wrong_password`;不生成文件 | 退出码=1;错误码=auth_failed;error code mismatch; expected one of wrong_password;输出=error [auth_failed]: incorrect master password | 失败 | 异常 | 預期結果偏差 實際結果正常|
-| LOC-029 | 本地命令 | config | timeout 边界值 | 无 | wallet-cli config timeoutMs 0 | P2 | 当前实现允许非负数 timeoutMs;写入成功并返回配置结果 | 退出码=2;错误码=invalid_value;expected success but exit=2;输出=error [invalid_value]: timeoutMs must be a positive number | 失败 | 边界 | 預期結果偏差 實際結果正常|
-| MGT-TOKEN-007 | Token | token add | 添加 TRC20 到地址簿 | 合约未添加 | wallet-cli token add --contract $QA_CONTRACT_TRC20 --network $NETWORK_TRON | P1 | 添加成功;随后 `token list` 可见 | 退出码=2;错误码=token_already_listed;expected success but exit=2;输出=error [token_already_listed]: TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t is already an official token on tron:mainnet | 失败 | 正常 | 預期結果偏差 實際結果正常|
-| MGT-TOKEN-008 | Token | token add | 重复添加 | 合约已添加 | wallet-cli token add --contract $QA_CONTRACT_TRC20 --network $NETWORK_TRON | P2 | 返回已存在或幂等成功,不产生重复项 | 退出码=2;错误码=token_already_listed;expected success but exit=2;输出=error [token_already_listed]: TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t is already an official token on tron:mainnet | 失败 | 边界 | 預期結果偏差 實際結果正常|
-| MGT-TOKEN-012 | Token | token remove | 移除用户 TRC20 | 已添加用户 token | wallet-cli token remove --contract $QA_CONTRACT_TRC20 --network $NETWORK_TRON | P1 | 移除成功;`token list` 不再展示用户项 | 退出码=2;错误码=token_is_official;expected success but exit=2;输出=error [token_is_official]: cannot remove an official token: TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t | 失败 | 正常 | 預期結果偏差 實際結果正常|
-| MGT-TX-007 | 交易 | tx send | 发送 TRC10 代币 | asset-id 有效 | wallet-cli tx send --to $QA_TRON_ADDRESS --amount 1 --asset-id $QA_ASSET_ID --network $NETWORK_TRON --dry-run | P1 | 返回 TRC10 transfer plan | 退出码=0;expected success observed;输出=⏳ Dry run tx send Fee TRC10 transfer uses bandwidth only Tx bddf4e6302...56921342 | 通过 | 正常 | 預期結果偏差 實際結果正常|
-| MGT-CONTRACT-010 | 合约 | contract deploy | 带 constructor 参数部署 | ABI/bytecode 有效 | wallet-cli contract deploy --abi "$QA_ABI_JSON" --bytecode $QA_BYTECODE_HEX --fee-limit 100000000 --constructor-sig 'constructor(uint256)' --params '[{"type":"uint256","value":"1"}]' --network $NETWORK_TRON --dry-run | P1 | constructor 参数编码正确,返回部署预览 | 退出码=2;错误码=invalid_option;expected success but exit=2;输出=error [invalid_option]: unknown option(s): --constructor-sig, --constructor-sig | 失败 | 正常 | |
-| MGT-GLOBAL-003 | 管理命令全局参数 | global | timeout 设置 | 网络可访问 | wallet-cli account info --account $QA_ACCOUNT --network $NETWORK_TRON --timeout 1 | P2 | 当前查询在 1ms 参数下仍可能快速成功;命令不应卡死,成功或 timeout 均需稳定返回 | 退出码=1;错误码=rpc_error;expected success but exit=1;输出=error [rpc_error]: TRON getAccount failed: The operation was aborted due to timeout | 失败 | 边界 | 預期結果偏差 實際結果正常 |
-
-## 六、问题清单
-
-| 问题ID | 严重级别 | 问题描述 | 影响用例数 | 关联用例 | 影响说明 | 修复建议 |
-|---|---|---|---:|---|---|---|
-| RETEST-BUG-001 | 中 | 密码错误场景错误码仍为 auth_failed,与用例期望 wrong_password 不一致 | 4 | GLOB-017, COM-015, LOC-013, LOC-019 | 错误码契约仍未对齐,调用方无法按 wrong_password 分支处理。 | 统一产品错误码为 wrong_password,或将 PRD/用例预期调整为 auth_failed 并补充 message 断言。 |
-| RETEST-BUG-002 | 中 | watch 地址格式异常错误码仍为 invalid_value,与预期 invalid_option 不一致 | 2 | COM-025, COM-027 | 参数校验错误分类仍不一致。 | 明确地址格式错误归类,并同步 CLI 实现、help 文档与测试用例。 |
-| RETEST-BUG-003 | 中 | 配置 timeoutMs=0 被实现拒绝,与边界用例预期不一致 | 1 | LOC-029 | 配置边界规则不一致,当前实现要求 timeoutMs 为正数。 | 若产品定义允许 0,需放宽校验;若不允许,更新用例预期为 invalid_value。 |
-| RETEST-BUG-004 | 中 | 官方 Token 地址簿添加/删除行为与用例预期不一致 | 3 | MGT-TOKEN-007, MGT-TOKEN-008, MGT-TOKEN-012 | 官方 Token 重复添加返回 token_already_listed,删除官方 Token 返回 token_is_official,正向用例仍失败。 | 确认官方 Token 是否允许用户重复添加/删除;按产品规则修订用例或调整实现为幂等成功。 |
-| RETEST-BUG-005 | 高 | contract deploy constructor 参数命令契约不一致 | 1 | MGT-CONTRACT-010 | 当前 CLI 不支持 --constructor-sig,带 constructor 参数部署 dry-run 无法通过。 | 补齐 --constructor-sig 兼容参数,或将用例改为当前 CLI 支持的 constructor 参数表达方式。 |
-| RETEST-BUG-006 | 中 | 1ms timeout 链上查询仍返回 rpc_error,边界行为未满足用例预期 | 1 | MGT-GLOBAL-003 | 极短 timeout 下行为稳定失败,不能满足“成功或稳定 timeout”预期中的成功分支。 | 明确 1ms timeout 的标准预期;建议用例接受 timeout/rpc_error,或实现返回统一 timeout 错误码。 |
-
-## 七、复测通过清单
-
-| 用例ID | 模块 | 子模块 | 测试场景 | 说明 |
-|---|---|---|---|---|
-| MGT-TX-007 | 交易 | tx send | 发送 TRC10 代币 | 由失败转为通过 |
-
-## 八、补充质量门禁
-
-| 检查项 | 结果 | 说明 |
-|---|---|---|
-| npm run build | 通过 | 本轮复测前已完成构建,dist/index.js 可执行 |
-| npm run typecheck | 通过 | 上轮完整测试后已执行 tsc --noEmit,退出码 0 |
-| npm test | 未通过 | 上轮验证中存在 crypto、keystore、wallet create、golden backup 相关超时失败,本轮未重复执行单元测试 |
-| Ledger 专项 | 未执行 | 按用户要求排除 Ledger/硬件钱包相关用例 |
-
-## 九、Ledger 排除清单
-
-| 用例ID | 子模块 | 场景 | 处理说明 |
-|---|---|---|---|
-| COM-017 | import ledger | 按 index 导入 TRON Ledger 账户 | 需 Ledger 硬件/设备确认,本轮按范围排除 |
-| COM-018 | import ledger | 按 path 导入 Ledger | 需 Ledger 硬件/设备确认,本轮按范围排除 |
-| COM-019 | import ledger | 按地址扫描导入 | 需 Ledger 硬件/设备确认,本轮按范围排除 |
-| COM-020 | import ledger | 导入 EVM Ledger 账户 | 需 Ledger 硬件/设备确认,本轮按范围排除 |
-| COM-021 | import ledger | index/path 互斥 | Ledger 模块整体排除,参数校验可后续单独补测 |
-| COM-022 | import ledger | scan-limit 缺少 address | Ledger 模块整体排除,参数校验可后续单独补测 |
-| COM-023 | import ledger | 设备未连接 | 需 Ledger 硬件/设备状态,本轮按范围排除 |
-
-## 十、结论建议
-
-- 本轮失败用例复测未通过:13 条首轮失败用例中 1 条复测通过,12 条仍失败。
-- 已恢复/转绿用例:MGT-TX-007,本轮 TRC10 dry-run 查询未再出现 RPC 429,判定通过。
-- 仍需修复或对齐的重点:错误码契约(wrong_password/auth_failed、invalid_option/invalid_value)、timeoutMs=0 边界定义、官方 Token 地址簿幂等规则、contract deploy constructor 参数兼容、1ms timeout 错误码规范。
-- 建议修复后继续按本报告 12 条失败用例做定向复测,再执行完整非 Ledger 回归确认无连带回归。
-
-## 十一、报告附件
-
-| 附件 | 路径 |
-|---|---|
-| 复测原始日志 | /Users/admin/Documents/wallet_cil/wallet-cli-feat-ts-version/ts/qa/execution-results/wallet-cli-v1-failed-cases-retest-no-ledger-20260702-151024/raw-execution-log.md |
-| 复测结构化结果 | /Users/admin/Documents/wallet_cil/wallet-cli-feat-ts-version/ts/qa/execution-results/wallet-cli-v1-failed-cases-retest-no-ledger-20260702-151024/results.json |
-| 复测执行器报告 | /Users/admin/Documents/wallet_cil/wallet-cli-feat-ts-version/ts/qa/execution-results/wallet-cli-v1-failed-cases-retest-no-ledger-20260702-151024/test-report.md |
-| 首轮完整报告 | /Users/admin/Documents/wallet_cil/wallet-cli-feat-ts-version/ts/qa/execution-results/wallet-cli-v1-command-modules-no-ledger-20260702-144259/test-report.md |
diff --git a/ts/src/adapters/inbound/cli/arity/arity.test.ts b/ts/src/adapters/inbound/cli/arity/arity.test.ts
index a7c6ee69..9e1b3b19 100644
--- a/ts/src/adapters/inbound/cli/arity/arity.test.ts
+++ b/ts/src/adapters/inbound/cli/arity/arity.test.ts
@@ -60,3 +60,33 @@ describe("introspectFields — defaults & choices", () => {
expect(by("to").choices).toBeUndefined();
});
});
+
+describe("introspectFields — baseType & array fields", () => {
+ const fields = introspectFields(
+ z.object({
+ to: z.string().describe("a string"),
+ flag: z.boolean().describe("a switch"),
+ resource: ciEnum(["energy", "bandwidth"]).describe("an enum via ciEnum's preprocess pipe"),
+ for: z.array(z.string().min(1)).min(1).max(30).describe("a repeatable string flag"),
+ }),
+ );
+ const by = (name: string) => fields.find((f) => f.name === name)!;
+
+ it("reports a repeatable array field as its per-entry element type, and marks isArray", () => {
+ expect(by("for").baseType).toBe("string");
+ expect(by("for").isArray).toBe(true);
+ });
+
+ it("reports scalars as their own type and not an array", () => {
+ expect(by("to").baseType).toBe("string");
+ expect(by("to").isArray).toBe(false);
+ expect(by("flag").baseType).toBe("boolean");
+ expect(by("flag").isArray).toBe(false);
+ });
+
+ it("never leaks the internal 'pipe' type — a ciEnum resolves past the preprocess pipe", () => {
+ expect(by("resource").baseType).not.toBe("pipe");
+ expect(by("resource").isArray).toBe(false);
+ expect(by("resource").choices).toEqual(["energy", "bandwidth"]);
+ });
+});
diff --git a/ts/src/adapters/inbound/cli/arity/index.ts b/ts/src/adapters/inbound/cli/arity/index.ts
index c9291e4b..fbb8da37 100644
--- a/ts/src/adapters/inbound/cli/arity/index.ts
+++ b/ts/src/adapters/inbound/cli/arity/index.ts
@@ -34,6 +34,8 @@ export interface FieldInfo {
name: string;
kebab: string;
baseType: string;
+ /** the field collects a repeated flag into an array (`--for a --for b`) — drives yargs `array`. */
+ isArray: boolean;
optional: boolean;
hasDefault: boolean;
/** the default value (when hasDefault) — surfaced verbatim in --help. */
@@ -66,6 +68,23 @@ function unwrap(schema: ZodType): { base: ZodType; optional: boolean; hasDefault
return { base: s, optional, hasDefault, defaultValue, description };
}
+/** the type name shown as `<...>` in --help. Descends a preprocess pipe (ciEnum) to its output
+ * and an array to its per-entry element, so a repeatable `z.array(z.string())` reads as
+ * `` — never the internal "pipe"/"array". */
+function displayBaseType(base: ZodType): string {
+ let def: any = (base as any)?.def;
+ if (def?.type === "pipe") def = def.out?.def;
+ if (def?.type === "array") def = def.element?.def;
+ return def?.type ?? "unknown";
+}
+
+/** true when the field collects a repeated flag into an array (after unwrapping optional/default). */
+function isArrayField(base: ZodType): boolean {
+ let def: any = (base as any)?.def;
+ if (def?.type === "pipe") def = def.out?.def;
+ return def?.type === "array";
+}
+
export function introspectFields(fields: ZodObject): FieldInfo[] {
const shape = fields.shape;
return Object.entries(shape).map(([name, schema]) => {
@@ -73,7 +92,8 @@ export function introspectFields(fields: ZodObject): FieldInfo[] {
return {
name,
kebab: camelToKebab(name),
- baseType: (base as any)?.def?.type ?? "unknown",
+ baseType: displayBaseType(base),
+ isArray: isArrayField(base),
optional,
hasDefault,
defaultValue,
@@ -98,6 +118,7 @@ function applyArity(y: Argv, fields: ZodObject): Argv {
for (const f of introspectFields(fields)) {
y.option(f.kebab, {
type: f.baseType === "boolean" ? "boolean" : "string",
+ ...(f.isArray ? { array: true } : {}), // repeatable flag → yargs collects into an array
describe: f.description,
demandOption: false, // requiredness is enforced by zod, not yargs
});
diff --git a/ts/src/adapters/inbound/cli/command-id.ts b/ts/src/adapters/inbound/cli/command-id.ts
index 22d1a9aa..0e50bfe6 100644
--- a/ts/src/adapters/inbound/cli/command-id.ts
+++ b/ts/src/adapters/inbound/cli/command-id.ts
@@ -1,12 +1,7 @@
-/**
- * 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";
+/** Canonical id = the logical path. Chain commands are no longer family-qualified; the family
+ * travels in the envelope's `chain` view, so `tron.` would be redundant with `chain.family`. */
export function commandId(cmd: { family?: ChainFamily; path: string[] }): string {
- return cmd.family ? [cmd.family, ...cmd.path].join(".") : cmd.path.join(".");
+ return cmd.path.join(".");
}
diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts
new file mode 100644
index 00000000..8d365956
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/commands/account.ts
@@ -0,0 +1,64 @@
+import { z } from "zod";
+import type { ChainSpec, FamilyBinding } 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 const accountBalanceSpec: ChainSpec = {
+ path: ["account", "balance"],
+ network: "optional", wallet: "optional", auth: "none",
+ capability: "account.balance.native",
+ summary: "Show native balance (TRX/SUN)",
+ baseFields: z.object({}),
+ examples: [{ cmd: "wallet-cli account balance" }],
+ formatText: TextFormatters.accountBalance,
+};
+
+export const accountBalanceTronBinding = (svc: TronAccountService): FamilyBinding => ({
+ run: async (ctx, net) => svc.balance(ctx, net, "tron"),
+});
+
+export const accountInfoSpec: ChainSpec = {
+ path: ["account", "info"],
+ network: "optional", wallet: "optional", auth: "none",
+ summary: "Show raw account data (getAccount; TRON includes resources)",
+ baseFields: z.object({}),
+ examples: [{ cmd: "wallet-cli account info" }],
+ formatText: TextFormatters.accountInfo,
+};
+
+export const accountInfoTronBinding = (svc: TronAccountService): FamilyBinding => ({
+ run: async (ctx, net) => svc.info(ctx, net),
+});
+
+export const accountHistorySpec: ChainSpec = {
+ path: ["account", "history"],
+ network: "optional", wallet: "optional", auth: "none",
+ summary: "Show transaction history (requires TronGrid)",
+ baseFields: 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"),
+ }),
+ examples: [{ cmd: "wallet-cli account history --limit 10" }],
+ formatText: TextFormatters.accountHistory,
+};
+
+export const accountHistoryTronBinding = (svc: TronAccountService): FamilyBinding => ({
+ run: async (ctx, net, input) => svc.historyFor(ctx, net, input),
+});
+
+export const accountPortfolioSpec: ChainSpec = {
+ path: ["account", "portfolio"],
+ network: "optional", wallet: "optional", auth: "none",
+ capability: "account.portfolio",
+ summary: "Show native + token balances with best-effort USD value",
+ baseFields: z.object({}),
+ examples: [{ cmd: "wallet-cli account portfolio" }],
+ formatText: TextFormatters.accountPortfolio,
+};
+
+export const accountPortfolioTronBinding = (svc: TronAccountService): FamilyBinding => ({
+ run: async (ctx, net) => svc.portfolio(ctx, net),
+});
diff --git a/ts/src/adapters/inbound/cli/commands/block.ts b/ts/src/adapters/inbound/cli/commands/block.ts
new file mode 100644
index 00000000..fdc268a8
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/commands/block.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+import type { ChainSpec, FamilyBinding } from "../contracts/index.js";
+import type { TronBlockService } from "../../../../application/use-cases/tron/block-service.js";
+import { Schemas } from "../schemas/index.js";
+import { TextFormatters } from "../render/index.js";
+
+export const blockSpec: ChainSpec = {
+ path: ["block"],
+ network: "optional", wallet: "none", auth: "none",
+ positionals: [{ field: "number" }],
+ summary: "Get a block (latest if omitted)",
+ baseFields: z.object({ number: Schemas.uintString().optional().describe("block number to fetch, in block height; omit to fetch the latest block") }),
+ examples: [{ cmd: "wallet-cli block" }, { cmd: "wallet-cli block 12345" }],
+ formatText: TextFormatters.block,
+};
+
+export const blockTronBinding = (svc: TronBlockService): FamilyBinding => ({
+ run: async (_ctx, net, input) => svc.get(net, input.number),
+});
diff --git a/ts/src/adapters/inbound/cli/commands/chain.ts b/ts/src/adapters/inbound/cli/commands/chain.ts
new file mode 100644
index 00000000..7f1f0984
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/commands/chain.ts
@@ -0,0 +1,44 @@
+import { z } from "zod";
+import type { ChainSpec, FamilyBinding } from "../contracts/index.js";
+import type { TronChainService } from "../../../../application/use-cases/tron/chain-service.js";
+import { TextFormatters } from "../render/index.js";
+
+export function chainDefinitions(service: TronChainService): Array<{ spec: ChainSpec; binding: FamilyBinding }> {
+ return [
+ {
+ spec: {
+ path: ["chain", "params"],
+ network: "optional", wallet: "none", auth: "none",
+ summary: "On-chain governance parameters",
+ baseFields: z.object({
+ key: z.string().optional().describe("return only this parameter (e.g. getEnergyFee); omit to list all"),
+ }),
+ examples: [{ cmd: "wallet-cli chain params" }, { cmd: "wallet-cli chain params --key getEnergyFee" }],
+ formatText: TextFormatters.chainParams,
+ },
+ binding: { run: async (_ctx, net, input) => service.params(net, input.key) },
+ },
+ {
+ spec: {
+ path: ["chain", "prices"],
+ network: "optional", wallet: "none", auth: "none",
+ summary: "Energy/bandwidth unit price and memo fee",
+ baseFields: z.object({}),
+ examples: [{ cmd: "wallet-cli chain prices" }],
+ formatText: TextFormatters.chainPrices,
+ },
+ binding: { run: async (_ctx, net) => service.prices(net) },
+ },
+ {
+ spec: {
+ path: ["chain", "node"],
+ network: "optional", wallet: "none", auth: "none",
+ summary: "Connected node status (version / sync / peers)",
+ baseFields: z.object({}),
+ examples: [{ cmd: "wallet-cli chain node" }],
+ formatText: TextFormatters.chainNode,
+ },
+ binding: { run: async (_ctx, net) => service.node(net) },
+ },
+ ];
+}
diff --git a/ts/src/adapters/inbound/cli/commands/change-password.test.ts b/ts/src/adapters/inbound/cli/commands/change-password.test.ts
new file mode 100644
index 00000000..50ceb8ab
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/commands/change-password.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect, vi } from "vitest";
+import { registerWalletCommands } from "./wallet.js";
+import { CommandRegistry } from "../registry/index.js";
+import type { ExecutionContext } from "../contracts/index.js";
+import type { WalletService } from "../../../../application/use-cases/wallet-service.js";
+import type { LedgerDevice } from "../../../../application/ports/ledger-device.js";
+
+const OLD = "OldPw1!aa";
+const NEW = "NewPw2@bb";
+
+// change-password is TTY-only (secretsTtyOnly): the old password is primed from the TTY by dispatch,
+// the new password comes from a hidden prompt. --password-stdin / non-TTY are rejected upstream in
+// the shell dispatch (secretsTtyOnly pre-check), exercised by the stdin regression smoke, not here —
+// this unit test covers the run handler in isolation.
+function setup(opts: { newPrompt?: string; confirm?: boolean } = {}) {
+ const changePassword = vi.fn(() => ({ wallets: ["seed", "hot"], count: 2 }));
+ const wallets = {
+ changePassword,
+ list: () => [
+ { accountId: "wlt_seed.0", type: "seed" },
+ { accountId: "wlt_hot", type: "privateKey" },
+ ],
+ } as unknown as WalletService;
+ const secrets = { read: (kind: string) => (kind === "password" ? OLD : undefined) };
+ const prompt = {
+ isTTY: () => true,
+ hidden: vi.fn(async () => opts.newPrompt ?? NEW),
+ confirm: vi.fn(async () => opts.confirm ?? true),
+ };
+ const ctx = { secrets, prompt } as unknown as ExecutionContext;
+ const registry = new CommandRegistry();
+ registerWalletCommands(registry, { walletService: wallets, ledger: {} as LedgerDevice });
+ const command = registry.resolveNeutral(["change-password"]!);
+ if (!command) throw new Error("change-password command not registered");
+ return { command, ctx, changePassword, prompt };
+}
+
+describe("change-password command (TTY-only)", () => {
+ it("prompts for the new password and returns the changePassword receipt", async () => {
+ const { command, ctx, changePassword } = setup();
+ await expect(command.run(ctx, undefined, { yes: true })).resolves.toEqual({ wallets: ["seed", "hot"], count: 2 });
+ expect(changePassword).toHaveBeenCalledWith(OLD, NEW);
+ });
+
+ it("rejects a new password equal to the old password", async () => {
+ const { command, ctx } = setup({ newPrompt: OLD });
+ await expect(command.run(ctx, undefined, { yes: true })).rejects.toMatchObject({ code: "invalid_value" });
+ });
+
+ it("returns aborted when the confirmation is declined", async () => {
+ const { command, ctx } = setup({ confirm: false });
+ await expect(command.run(ctx, undefined, { yes: false })).rejects.toMatchObject({ code: "aborted" });
+ });
+
+ it("skips the confirmation prompt with --yes", async () => {
+ const { command, ctx, prompt } = setup();
+ await command.run(ctx, undefined, { yes: true });
+ expect(prompt.confirm).not.toHaveBeenCalled();
+ });
+});
diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts
new file mode 100644
index 00000000..2183acf8
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/commands/contract.ts
@@ -0,0 +1,153 @@
+import { z } from "zod";
+import type { ChainSpec, FamilyBinding } from "../contracts/index.js";
+import { UsageError } from "../../../../domain/errors/index.js";
+import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js";
+import type { 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`);
+}
+
+// call/send parameters are ABI-encoded from {type, value} entries. Validate the shape at the
+// command boundary so a malformed entry fails as invalid_value here, not as an opaque encoder/RPC
+// error deep in TronWeb. (deploy params are raw positional values — they use jsonArray, not this.)
+const typedParam = z
+ .object({ type: z.string().min(1), value: z.unknown() })
+ .refine((e) => e.value !== undefined, { message: "value is required" });
+
+function typedParams(raw: string | undefined): TronContractParameter[] {
+ const arr = jsonArray(raw);
+ if (!z.array(typedParam).safeParse(arr).success) {
+ throw new UsageError(
+ "invalid_value",
+ '--params entries must be {"type","value"} objects with a non-empty ABI type',
+ );
+ }
+ return arr as TronContractParameter[];
+}
+
+const callFields = 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"),
+});
+
+export const contractCallSpec: ChainSpec = {
+ path: ["contract", "call"],
+ network: "optional", wallet: "none", auth: "none",
+ capability: "contract.call",
+ summary: "Read-only call (triggerConstantContract)",
+ baseFields: callFields,
+ examples: [{
+ cmd: `wallet-cli contract call --contract TR7... --method "balanceOf(address)" --params '[{"type":"address","value":"T..."}]'`,
+ }],
+ formatText: TextFormatters.contractCall,
+};
+
+export const contractCallTronBinding = (svc: TronContractService): FamilyBinding => ({
+ run: async (_ctx, net, input) => svc.call(
+ net, input.contract, input.method, typedParams(input.params),
+ ),
+});
+
+const sendFields = z.object({
+ contract: Schemas.addressFor("tron").describe("TRON contract address"),
+ 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: Schemas.uintString().default("0")
+ .describe("native TRX attached to the call, in SUN"),
+ feeLimit: Schemas.positiveIntString().default("100000000")
+ .describe("maximum energy fee to burn, in SUN"),
+ ...txModeFields,
+});
+
+export const contractSendSpec: ChainSpec = {
+ path: ["contract", "send"],
+ network: "required", wallet: "optional", auth: "required",
+ broadcasts: true,
+ capability: "contract.call",
+ summary: "State-changing call (triggerSmartContract)",
+ baseFields: sendFields,
+ examples: [{
+ cmd: `wallet-cli contract send --contract TR7... --method "transfer(address,uint256)" --params '[...]'`,
+ }],
+ formatText: TextFormatters.txReceipt,
+};
+
+export const contractSendTronBinding = (svc: TronContractService): FamilyBinding => ({
+ run: async (ctx, net, input) => svc.send(ctx, net, {
+ ...input,
+ parameters: typedParams(input.params),
+ }),
+});
+
+const deployFields = z.object({
+ abi: z.string().min(1).describe("contract ABI as a JSON array string"),
+ bytecode: z.string().min(1).describe("compiled contract bytecode as hex, 0x-prefixed or bare"),
+ feeLimit: Schemas.positiveIntString().describe("maximum energy fee to burn, in SUN"),
+ params: z.string().optional()
+ .describe("constructor args as a JSON array of raw positional values, e.g. [100, \"T...\"]; types are taken from the ABI constructor; omit to pass no constructor args"),
+ ...txModeFields,
+});
+
+export const contractDeploySpec: ChainSpec = {
+ path: ["contract", "deploy"],
+ network: "required", wallet: "optional", auth: "required",
+ broadcasts: true,
+ capability: "contract.deploy",
+ summary: "Deploy a smart contract",
+ // The Ledger TRON app firmware rejects CreateSmartContract (APDU 0x6a80), even with
+ // blind-signing enabled; software accounts sign and deploy it fine.
+ requires: ["a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type"],
+ baseFields: deployFields,
+ examples: [{
+ cmd: "wallet-cli contract deploy --abi '[...]' --bytecode 60... --fee-limit 1000000000 --params '[100, \"T...\"]'",
+ }],
+ formatText: TextFormatters.txReceipt,
+};
+
+export const contractDeployTronBinding = (svc: TronContractService): FamilyBinding => ({
+ run: async (ctx, net, input) => {
+ let abi: unknown;
+ try {
+ abi = JSON.parse(input.abi);
+ } catch {
+ throw new UsageError("invalid_value", "--abi must be valid JSON");
+ }
+ return svc.deploy(ctx, net, {
+ ...input,
+ abi,
+ parameters: jsonArray(input.params),
+ });
+ },
+});
+
+const infoFields = z.object({
+ contract: Schemas.addressFor("tron").describe("TRON contract address"),
+});
+
+export const contractInfoSpec: ChainSpec = {
+ path: ["contract", "info"],
+ network: "optional", wallet: "none", auth: "none",
+ capability: "contract.call",
+ summary: "Show contract ABI + metadata",
+ baseFields: infoFields,
+ examples: [{ cmd: "wallet-cli contract info --contract TR7..." }],
+ formatText: TextFormatters.contractInfo,
+};
+
+export const contractInfoTronBinding = (svc: TronContractService): FamilyBinding => ({
+ run: async (_ctx, net, input) => svc.info(net, input.contract),
+});
diff --git a/ts/src/adapters/inbound/cli/commands/reward.ts b/ts/src/adapters/inbound/cli/commands/reward.ts
new file mode 100644
index 00000000..97961b53
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/commands/reward.ts
@@ -0,0 +1,37 @@
+import { z } from "zod";
+import type { ChainSpec, FamilyBinding } from "../contracts/index.js";
+import type { TronRewardService } from "../../../../application/use-cases/tron/reward-service.js";
+import { txModeFields } from "./shared.js";
+import { TextFormatters } from "../render/index.js";
+
+export const rewardBalanceSpec: ChainSpec = {
+ path: ["reward", "balance"],
+ network: "optional", wallet: "optional", auth: "none",
+ capability: "reward.balance",
+ summary: "Show claimable voting/block reward and withdraw status",
+ baseFields: z.object({}),
+ examples: [{ cmd: "wallet-cli reward balance" }],
+ formatText: TextFormatters.rewardBalance,
+};
+
+export const rewardBalanceTronBinding = (svc: TronRewardService): FamilyBinding => ({
+ run: async (ctx, net) => svc.balance(ctx, net),
+});
+
+export const rewardWithdrawSpec: ChainSpec = {
+ path: ["reward", "withdraw"],
+ network: "optional", wallet: "optional", auth: "required",
+ broadcasts: true,
+ capability: "reward.withdraw",
+ summary: "Withdraw accrued voting/block rewards",
+ baseFields: z.object({ ...txModeFields }),
+ examples: [
+ { cmd: "wallet-cli reward withdraw" },
+ { cmd: "wallet-cli reward withdraw --wait" },
+ ],
+ formatText: TextFormatters.txReceipt,
+};
+
+export const rewardWithdrawTronBinding = (svc: TronRewardService): FamilyBinding => ({
+ run: async (ctx, net, input) => svc.withdraw(ctx, net, input),
+});
diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts
index cd6664f8..36696968 100644
--- a/ts/src/adapters/inbound/cli/commands/shared.ts
+++ b/ts/src/adapters/inbound/cli/commands/shared.ts
@@ -4,8 +4,7 @@
* 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 type { ChainSpec, FamilyBinding } 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";
@@ -38,28 +37,26 @@ export function amountSelector(v: { amount?: string; rawAmount?: string }, ctx:
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 --message "hello"` }],
- formatText: TextFormatters.messageSign,
- run: async (ctx, _net, input) => {
- const message = ctx.secrets.pick(input.message, "message", "message");
- return service.sign(ctx, family, ctx.activeAccount, message);
- },
- };
-}
+const messageSignFields = z.object({
+ message: z.string().min(1).optional().describe("message text to sign; provide this OR --message-stdin; exactly one is required"),
+});
+
+export const messageSignSpec: ChainSpec = {
+ path: ["message", "sign"],
+ stdin: "message",
+ network: "optional",
+ wallet: "optional",
+ auth: "required",
+ capability: "message.sign",
+ summary: "Sign an arbitrary message (TIP-191/V2 · EIP-191)",
+ baseFields: messageSignFields,
+ examples: [{ cmd: `wallet-cli message sign --message "hello"` }],
+ formatText: TextFormatters.messageSign,
+};
+
+export const messageSignBinding = (service: MessageService): FamilyBinding => ({
+ run: async (ctx, net, input) => {
+ const message = ctx.secrets.pick(input.message, "message", "message");
+ return service.sign(ctx, net.family, ctx.activeAccount, message);
+ },
+});
diff --git a/ts/src/adapters/inbound/cli/commands/tron/stake.ts b/ts/src/adapters/inbound/cli/commands/stake.ts
similarity index 54%
rename from ts/src/adapters/inbound/cli/commands/tron/stake.ts
rename to ts/src/adapters/inbound/cli/commands/stake.ts
index 4d73c36e..532ba244 100644
--- a/ts/src/adapters/inbound/cli/commands/tron/stake.ts
+++ b/ts/src/adapters/inbound/cli/commands/stake.ts
@@ -1,15 +1,16 @@
import { z } from "zod";
-import type { NetworkDescriptor } from "../../../../../domain/types/index.js";
+import type { NetworkDescriptor } from "../../../../domain/types/index.js";
import type {
- CommandDefinition,
+ ChainSpec,
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";
+ FamilyBinding,
+} 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);
@@ -26,32 +27,34 @@ type StakeExecutor = (
input: any,
) => Promise;
-function command(
+function stakeCommand(
action: string,
summary: string,
execute: StakeExecutor,
extra: z.ZodRawShape = {},
options: StakeCommandOptions = {},
-): CommandDefinition {
+): { spec: ChainSpec; binding: FamilyBinding } {
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,
- requires: options.requires,
- fields,
- input: options.refine ? fields.superRefine(options.refine) : fields,
- examples: [{ cmd: `wallet-cli stake ${action}` }],
- formatText: TextFormatters.txReceipt,
- run: async (context, network, input) => execute(context, network, input),
+ spec: {
+ path: ["stake", action],
+ network: "required", wallet: "optional", auth: "required",
+ broadcasts: true,
+ capability: options.capability ?? "staking.freeze",
+ summary,
+ requires: options.requires,
+ baseFields: fields,
+ baseRefine: options.refine,
+ examples: [{ cmd: `wallet-cli stake ${action}` }],
+ formatText: TextFormatters.txReceipt,
+ },
+ binding: { run: async (ctx, net, input) => execute(ctx, net, input) },
};
}
-export function stakeCommands(service: TronStakeService): CommandDefinition[] {
+export function stakeDefinitions(service: TronStakeService): Array<{ spec: ChainSpec; binding: FamilyBinding }> {
return [
- command(
+ stakeCommand(
"freeze",
"Stake TRX for energy/bandwidth (FreezeBalanceV2)",
(context, network, input) => service.freeze(context, network, input),
@@ -60,7 +63,7 @@ export function stakeCommands(service: TronStakeService): CommandDefinition[] {
resource: resourceField("resource type to obtain"),
},
),
- command(
+ stakeCommand(
"unfreeze",
"Unstake TRX (UnfreezeBalanceV2)",
(context, network, input) => service.unfreeze(context, network, input),
@@ -69,12 +72,12 @@ export function stakeCommands(service: TronStakeService): CommandDefinition[] {
resource: resourceField("resource type to release"),
},
),
- command(
+ stakeCommand(
"withdraw",
"Withdraw expired unfrozen TRX (WithdrawExpireUnfreeze)",
(context, network, input) => service.withdraw(context, network, input),
),
- command(
+ stakeCommand(
"cancel-unfreeze",
"Cancel all pending unstakes (roll back to frozen)",
(context, network, input) => service.cancelUnfreeze(context, network, input),
@@ -85,7 +88,7 @@ export function stakeCommands(service: TronStakeService): CommandDefinition[] {
requires: ["a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type"],
},
),
- command(
+ stakeCommand(
"delegate",
"Delegate resource to another address (DelegateResourceV2)",
(context, network, input) => service.delegate(context, network, input),
@@ -113,7 +116,7 @@ export function stakeCommands(service: TronStakeService): CommandDefinition[] {
},
},
),
- command(
+ stakeCommand(
"undelegate",
"Reclaim delegated resource (UnDelegateResourceV2)",
(context, network, input) => service.undelegate(context, network, input),
@@ -126,5 +129,43 @@ export function stakeCommands(service: TronStakeService): CommandDefinition[] {
},
{ capability: "staking.delegate" },
),
+ {
+ spec: {
+ path: ["stake", "info"],
+ network: "optional", wallet: "optional", auth: "none",
+ summary: "Staking & resource overview (staked / voting power / resource / unfreezing / withdrawable)",
+ baseFields: z.object({}),
+ examples: [{ cmd: "wallet-cli stake info" }, { cmd: "wallet-cli stake info --account main -o json" }],
+ formatText: TextFormatters.stakeInfo,
+ },
+ binding: { run: async (ctx, net) => service.info(ctx, net) },
+ },
+ {
+ spec: {
+ path: ["stake", "delegated"],
+ network: "optional", wallet: "optional", auth: "none",
+ summary: "Delegation details and max delegatable size",
+ baseFields: z.object({
+ direction: ciEnum(["out", "in"]).default("out")
+ .describe("out = delegated to others; in = delegated to me"),
+ resource: ciEnum(RESOURCES).optional()
+ .describe("filter to a single resource type; omit to show both"),
+ to: Schemas.addressFor("tron").optional()
+ .describe("only show delegation to this receiver (out only)"),
+ }),
+ baseRefine: (value, context) => {
+ if (value.to !== undefined && value.direction === "in") {
+ context.addIssue({ code: "custom", path: ["to"], message: "--to only applies to --direction out" });
+ }
+ },
+ examples: [
+ { cmd: "wallet-cli stake delegated" },
+ { cmd: "wallet-cli stake delegated --direction in" },
+ { cmd: "wallet-cli stake delegated --to TBy..." },
+ ],
+ formatText: TextFormatters.stakeDelegated,
+ },
+ binding: { run: async (ctx, net, input) => service.delegated(ctx, net, input) },
+ },
];
}
diff --git a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts
index 2901e1e3..43855755 100644
--- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts
+++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts
@@ -1,12 +1,12 @@
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 { registerTronChainCommands, type TronChainCommandDependencies } from "../../../../bootstrap/families/tron.js";
import { commandId } from "../command-id.js";
import { TextFormatters } from "../render/index.js";
+import { isChainCommand } from "../contracts/index.js";
import type { TextRenderContext } from "../contracts/index.js";
import type { ConfigService } from "../../../../application/use-cases/config-service.js";
@@ -14,16 +14,15 @@ const ctx = (over: Partial = {}): TextRenderContext => ({ com
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);
+ registerTronChainCommands(registry, {} as TronChainCommandDependencies);
const missing = registry.all()
- .filter((cmd) => typeof cmd.formatText !== "function")
- .map(commandId)
+ .filter((cmd) => typeof (isChainCommand(cmd) ? cmd.spec.formatText : cmd.formatText) !== "function")
+ .map((cmd) => commandId(isChainCommand(cmd) ? { path: cmd.spec.path } : cmd))
.sort();
expect(missing).toEqual([]);
@@ -47,6 +46,23 @@ describe("accountBalance formatter", () => {
});
});
+describe("stake/chain TRX amount formatting", () => {
+ it("groups the integer part without truncating fractional TRX", () => {
+ const stake = TextFormatters.stakeDelegated({
+ direction: "out",
+ canDelegateMaxSun: { energy: "1234456789", bandwidth: "0" },
+ delegations: [],
+ }, ctx());
+ const chain = TextFormatters.chainPrices({
+ energy: { currentSunPerUnit: 210 },
+ bandwidth: { currentSunPerUnit: 1000 },
+ memoFeeSun: "1234456789",
+ });
+ expect(stake).toContain("1,234.456789 TRX");
+ expect(chain).toContain("1,234.456789 TRX");
+ });
+});
+
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());
diff --git a/ts/src/adapters/inbound/cli/commands/tron/shared.ts b/ts/src/adapters/inbound/cli/commands/token-selector.ts
similarity index 99%
rename from ts/src/adapters/inbound/cli/commands/tron/shared.ts
rename to ts/src/adapters/inbound/cli/commands/token-selector.ts
index a95b44f7..24277b11 100644
--- a/ts/src/adapters/inbound/cli/commands/tron/shared.ts
+++ b/ts/src/adapters/inbound/cli/commands/token-selector.ts
@@ -15,4 +15,3 @@ export function tokenSelector(
});
}
}
-
diff --git a/ts/src/adapters/inbound/cli/commands/token.ts b/ts/src/adapters/inbound/cli/commands/token.ts
new file mode 100644
index 00000000..7abc1d90
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/commands/token.ts
@@ -0,0 +1,87 @@
+import { z } from "zod";
+import type { ChainSpec, FamilyBinding } from "../contracts/index.js";
+import type { TronTokenService } from "../../../../application/use-cases/tron/token-service.js";
+import { Schemas } from "../schemas/index.js";
+import { TextFormatters } from "../render/index.js";
+import { tokenSelector } from "./token-selector.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 const tokenBalanceSpec: ChainSpec = {
+ path: ["token", "balance"],
+ network: "optional", wallet: "optional", auth: "none",
+ capability: "account.balance.token",
+ summary: "Show a single token balance (--contract / --asset-id)",
+ baseFields: selectorFields,
+ baseRefine: tokenSelector,
+ examples: [{ cmd: "wallet-cli token balance --contract TR7..." }],
+ formatText: TextFormatters.tokenBalance,
+};
+
+export const tokenBalanceTronBinding = (svc: TronTokenService): FamilyBinding => ({
+ run: async (ctx, net, input) => svc.balance(ctx, net, input),
+});
+
+export const tokenInfoSpec: ChainSpec = {
+ path: ["token", "info"],
+ network: "optional", wallet: "none", auth: "none",
+ capability: "account.balance.token",
+ summary: "Show token metadata (name/symbol/decimals/totalSupply)",
+ baseFields: selectorFields,
+ baseRefine: tokenSelector,
+ examples: [{ cmd: "wallet-cli token info --contract TR7..." }],
+ formatText: TextFormatters.tokenInfo,
+};
+
+export const tokenInfoTronBinding = (svc: TronTokenService): FamilyBinding => ({
+ run: async (_ctx, net, input) => svc.info(net, input),
+});
+
+export const tokenAddSpec: ChainSpec = {
+ path: ["token", "add"],
+ network: "optional", wallet: "optional", auth: "none",
+ capability: "token.tokenbook",
+ summary: "Add a token to the address book (fetches symbol/decimals)",
+ baseFields: selectorFields,
+ baseRefine: tokenSelector,
+ examples: [{ cmd: "wallet-cli token add --contract TR7..." }],
+ formatText: TextFormatters.tokenBookAdd,
+};
+
+export const tokenAddTronBinding = (svc: TronTokenService): FamilyBinding => ({
+ run: async (ctx, net, input) => svc.add(ctx, net, input),
+});
+
+export const tokenListSpec: ChainSpec = {
+ path: ["token", "list"],
+ network: "optional", wallet: "optional", auth: "none",
+ capability: "token.tokenbook",
+ summary: "List the address book (official + user)",
+ baseFields: z.object({}),
+ examples: [{ cmd: "wallet-cli token list" }],
+ formatText: TextFormatters.tokenBookList,
+};
+
+export const tokenListTronBinding = (svc: TronTokenService): FamilyBinding => ({
+ run: async (ctx, net) => svc.list(ctx, net),
+});
+
+export const tokenRemoveSpec: ChainSpec = {
+ path: ["token", "remove"],
+ network: "optional", wallet: "optional", auth: "none",
+ capability: "token.tokenbook",
+ summary: "Remove a user-added token from the address book",
+ baseFields: selectorFields,
+ baseRefine: tokenSelector,
+ examples: [{ cmd: "wallet-cli token remove --contract TR7..." }],
+ formatText: TextFormatters.tokenBookRemove,
+};
+
+export const tokenRemoveTronBinding = (svc: TronTokenService): FamilyBinding => ({
+ run: async (ctx, net, input) => svc.remove(ctx, net, input),
+});
diff --git a/ts/src/adapters/inbound/cli/commands/tron/account.ts b/ts/src/adapters/inbound/cli/commands/tron/account.ts
deleted file mode 100644
index 4949c307..00000000
--- a/ts/src/adapters/inbound/cli/commands/tron/account.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-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: "Show native balance (TRX/SUN)",
- fields,
- input: fields,
- examples: [{ cmd: "wallet-cli account balance" }],
- 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: "Show raw account data (getAccount; TRON includes resources)",
- fields,
- input: fields,
- examples: [{ cmd: "wallet-cli account info" }],
- 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: "Show transaction history (requires TronGrid)",
- fields,
- input: fields,
- examples: [{ cmd: "wallet-cli account history --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: "Show native + token balances with best-effort USD value",
- fields,
- input: fields,
- examples: [{ cmd: "wallet-cli account portfolio" }],
- 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
deleted file mode 100644
index 14a5fa51..00000000
--- a/ts/src/adapters/inbound/cli/commands/tron/block.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * 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 { Schemas } from "../../schemas/index.js";
-import { TextFormatters } from "../../render/index.js";
-
-function blockGet(service: TronBlockService): CommandDefinition {
- const fields = z.object({ number: Schemas.uintString().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",
- positionals: [{ field: "number" }],
- summary: "Get a block (latest if omitted)", fields, input: fields,
- examples: [
- { cmd: "wallet-cli block" },
- { cmd: "wallet-cli block 12345" },
- ],
- 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
deleted file mode 100644
index 36528d21..00000000
--- a/ts/src/adapters/inbound/cli/commands/tron/contract.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-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`);
-}
-
-// call/send parameters are ABI-encoded from {type, value} entries. Validate the shape at the
-// command boundary so a malformed entry fails as invalid_value here, not as an opaque encoder/RPC
-// error deep in TronWeb. (deploy params are raw positional values — they use jsonArray, not this.)
-const typedParam = z
- .object({ type: z.string().min(1), value: z.unknown() })
- .refine((e) => e.value !== undefined, { message: "value is required" });
-
-function typedParams(raw: string | undefined): TronContractParameter[] {
- const arr = jsonArray(raw);
- if (!z.array(typedParam).safeParse(arr).success) {
- throw new UsageError(
- "invalid_value",
- '--params entries must be {"type","value"} objects with a non-empty ABI type',
- );
- }
- return arr as TronContractParameter[];
-}
-
-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 --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, typedParams(input.params),
- ),
- };
-}
-
-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: Schemas.uintString().default("0")
- .describe("native TRX attached to the call, in SUN"),
- feeLimit: Schemas.positiveIntString().default("100000000")
- .describe("maximum energy fee to burn, in SUN"),
- ...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 --contract TR7... --method "transfer(address,uint256)" --params '[...]'`,
- }],
- formatText: TextFormatters.txReceipt,
- run: async (ctx, network, input) => service.send(ctx, network, {
- ...input,
- parameters: typedParams(input.params),
- }),
- };
-}
-
-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: Schemas.positiveIntString().describe("maximum energy fee to burn, in SUN"),
- params: z.string().optional()
- .describe("constructor args as a JSON array of raw positional values, e.g. [100, \"T...\"]; types are taken from the ABI constructor; omit to pass no constructor args"),
- ...txModeFields,
- });
- return {
- path: ["contract", "deploy"], family: "tron",
- network: "required", wallet: "optional", auth: "required",
- broadcasts: true,
- capability: "contract.deploy",
- summary: "Deploy a smart contract",
- // The Ledger TRON app firmware rejects CreateSmartContract (APDU 0x6a80), even with
- // blind-signing enabled; software accounts sign and deploy it fine.
- requires: ["a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type"],
- fields,
- input: fields,
- examples: [{
- cmd: "wallet-cli contract deploy --abi '[...]' --bytecode 60... --fee-limit 1000000000 --params '[100, \"T...\"]'",
- }],
- 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: "Show contract ABI + metadata",
- fields,
- input: fields,
- examples: [{ cmd: "wallet-cli contract info --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
deleted file mode 100644
index 6d7244b9..00000000
--- a/ts/src/adapters/inbound/cli/commands/tron/index.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * 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
deleted file mode 100644
index afffe2e6..00000000
--- a/ts/src/adapters/inbound/cli/commands/tron/message.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * 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/token.ts b/ts/src/adapters/inbound/cli/commands/tron/token.ts
deleted file mode 100644
index 66efe85f..00000000
--- a/ts/src/adapters/inbound/cli/commands/tron/token.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-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: "Show a single token balance (--contract / --asset-id)",
- fields: selectorFields,
- input: selectorFields.superRefine(tokenSelector),
- examples: [{ cmd: "wallet-cli token balance --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: "Show token metadata (name/symbol/decimals/totalSupply)",
- fields: selectorFields,
- input: selectorFields.superRefine(tokenSelector),
- examples: [{ cmd: "wallet-cli token info --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 token to the address book (fetches symbol/decimals)",
- fields: selectorFields,
- input: selectorFields.superRefine(tokenSelector),
- examples: [{ cmd: "wallet-cli token add --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 address book (official + user)",
- fields,
- input: fields,
- examples: [{ cmd: "wallet-cli token list" }],
- 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 --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
deleted file mode 100644
index e68b4605..00000000
--- a/ts/src/adapters/inbound/cli/commands/tron/tx.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-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: Schemas.positiveIntString().default("100000000")
- .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 --to T... --amount 1" },
- { cmd: "wallet-cli tx send --to T... --token USDT --amount 5" },
- { cmd: "wallet-cli tx send --to T... --contract TR7... --amount 5" },
- { cmd: "wallet-cli tx send --to T... --asset-id 1002000 --raw-amount 1000000" },
- ],
- 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 --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: "Show confirmation status of a transaction",
- fields,
- input: fields,
- examples: [{ cmd: "wallet-cli tx status --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: "Show full transaction detail + receipt",
- fields,
- input: fields,
- examples: [{ cmd: "wallet-cli tx info --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/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts
new file mode 100644
index 00000000..451dd006
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/commands/tx.ts
@@ -0,0 +1,128 @@
+import { z } from "zod";
+import type { ChainSpec, FamilyBinding } from "../contracts/index.js";
+import { UsageError } from "../../../../domain/errors/index.js";
+import type { TronTransactionService } from "../../../../application/use-cases/tron/transaction-service.js";
+import { Schemas } from "../schemas/index.js";
+import {
+ amountSelector,
+ txModeFields,
+ unifiedAmountFields,
+} from "./shared.js";
+import { TextFormatters } from "../render/index.js";
+
+// baseFields today (single family). When EVM lands, move feeLimit/assetId/contract into the TRON
+// binding.fields and put gasPrice/gasLimit/nonce into the EVM binding.fields (spec §4 base/delta).
+const sendFields = 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: Schemas.positiveIntString().default("100000000")
+ .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,
+});
+
+export const txSendSpec: ChainSpec = {
+ path: ["tx", "send"],
+ network: "optional", wallet: "optional", auth: "required",
+ broadcasts: true,
+ capability: "tx.send",
+ summary: "Send native TRX or TRC20/TRC10 tokens with human --amount",
+ baseFields: sendFields,
+ baseRefine: amountSelector,
+ examples: [
+ { cmd: "wallet-cli tx send --to T... --amount 1" },
+ { cmd: "wallet-cli tx send --to T... --token USDT --amount 5" },
+ { cmd: "wallet-cli tx send --to T... --contract TR7... --amount 5" },
+ { cmd: "wallet-cli tx send --to T... --asset-id 1002000 --raw-amount 1000000" },
+ ],
+ formatText: TextFormatters.txReceipt,
+};
+
+export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => ({
+ refine: tokenOptional,
+ run: async (ctx, net, input) => svc.send(ctx, net, input),
+});
+
+const broadcastFields = z.object({
+ transaction: z.string().optional()
+ .describe("signed TRON transaction JSON; provide this OR --tx-stdin; exactly one is required"),
+});
+
+export const txBroadcastSpec: ChainSpec = {
+ path: ["tx", "broadcast"],
+ stdin: "tx",
+ network: "required", wallet: "none", auth: "none",
+ broadcasts: true,
+ capability: "tx.broadcast",
+ summary: "Broadcast a presigned transaction",
+ baseFields: broadcastFields,
+ examples: [{ cmd: "wallet-cli tx broadcast --tx-stdin < signed.json" }],
+ formatText: TextFormatters.txReceipt,
+};
+
+export const txBroadcastTronBinding = (svc: TronTransactionService): FamilyBinding => ({
+ run: async (ctx, net, input) => {
+ const raw = ctx.secrets.pick(input.transaction, "tx", "transaction");
+ try {
+ return svc.broadcast(ctx, net, JSON.parse(raw));
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ throw new UsageError("invalid_value", "TRON presigned tx must be JSON");
+ }
+ throw error;
+ }
+ },
+});
+
+const statusFields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") });
+
+export const txStatusSpec: ChainSpec = {
+ path: ["tx", "status"],
+ network: "optional", wallet: "none", auth: "none",
+ summary: "Show confirmation status of a transaction",
+ baseFields: statusFields,
+ examples: [{ cmd: "wallet-cli tx status --txid abc123" }],
+ formatText: TextFormatters.txStatus,
+};
+
+export const txStatusTronBinding = (svc: TronTransactionService): FamilyBinding => ({
+ run: async (_ctx, net, input) => svc.status(net, input.txid),
+});
+
+const infoFields = z.object({ txid: z.string().min(1).describe("TRON transaction id/hash") });
+
+export const txInfoSpec: ChainSpec = {
+ path: ["tx", "info"],
+ network: "optional", wallet: "none", auth: "none",
+ summary: "Show full transaction detail + receipt",
+ baseFields: infoFields,
+ examples: [{ cmd: "wallet-cli tx info --txid abc123" }],
+ formatText: TextFormatters.txInfo,
+};
+
+export const txInfoTronBinding = (svc: TronTransactionService): FamilyBinding => ({
+ run: async (_ctx, net, input) => svc.info(net, 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/vote.ts b/ts/src/adapters/inbound/cli/commands/vote.ts
new file mode 100644
index 00000000..7dc0be3c
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/commands/vote.ts
@@ -0,0 +1,64 @@
+import { z } from "zod";
+import type { ChainSpec, FamilyBinding } from "../contracts/index.js";
+import type { TronVoteService } from "../../../../application/use-cases/tron/vote-service.js";
+import { txModeFields } from "./shared.js";
+import { TextFormatters } from "../render/index.js";
+
+// repeatable flag: the arity layer sets yargs `array: true`, so `--for` always arrives as a
+// string[] (single or repeated) — no preprocess needed to normalize.
+const voteForField = z.array(z.string().min(1)).min(1).max(30)
+ .describe("witness address = vote count, as SR=votes; repeatable; replaces all prior votes");
+
+export const voteCastSpec: ChainSpec = {
+ path: ["vote", "cast"],
+ network: "optional", wallet: "optional", auth: "required",
+ broadcasts: true,
+ capability: "vote.cast",
+ summary: "Cast or replace your full SR vote allocation",
+ baseFields: z.object({
+ for: voteForField,
+ ...txModeFields,
+ }),
+ examples: [{ cmd: "wallet-cli vote cast --for TZ4...=600 --for TT5...=400" }],
+ formatText: TextFormatters.txReceipt,
+};
+
+export const voteCastTronBinding = (svc: TronVoteService): FamilyBinding => ({
+ run: async (ctx, net, input) => svc.cast(ctx, net, input),
+});
+
+export const voteListSpec: ChainSpec = {
+ path: ["vote", "list"],
+ network: "optional", wallet: "none", auth: "none",
+ capability: "vote.list",
+ summary: "List super representatives and candidates",
+ baseFields: z.object({
+ limit: z.coerce.number().int().positive().max(127).default(27)
+ .describe("number of ranks to return; max 127"),
+ candidates: z.boolean().default(false)
+ .describe("include non-elected candidates"),
+ }),
+ examples: [
+ { cmd: "wallet-cli vote list" },
+ { cmd: "wallet-cli vote list --candidates --limit 100" },
+ ],
+ formatText: TextFormatters.voteList,
+};
+
+export const voteListTronBinding = (svc: TronVoteService): FamilyBinding => ({
+ run: async (_ctx, net, input) => svc.list(net, input),
+});
+
+export const voteStatusSpec: ChainSpec = {
+ path: ["vote", "status"],
+ network: "optional", wallet: "optional", auth: "none",
+ capability: "vote.status",
+ summary: "Show current votes, voting power, and reward overview",
+ baseFields: z.object({}),
+ examples: [{ cmd: "wallet-cli vote status" }],
+ formatText: TextFormatters.voteStatus,
+};
+
+export const voteStatusTronBinding = (svc: TronVoteService): FamilyBinding => ({
+ run: async (ctx, net) => svc.status(ctx, net),
+});
diff --git a/ts/src/adapters/inbound/cli/commands/wallet.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.test.ts
index f73a464c..57cc524c 100644
--- a/ts/src/adapters/inbound/cli/commands/wallet.test.ts
+++ b/ts/src/adapters/inbound/cli/commands/wallet.test.ts
@@ -14,7 +14,8 @@ 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, NetworklessCommandDefinition } from "../contracts/index.js";
+import { isChainCommand } from "../contracts/index.js";
+import type { CommandDefinition, Globals } from "../contracts/index.js";
import { Derivation } from "../../../../domain/derivation/index.js";
import { WalletService } from "../../../../application/use-cases/wallet-service.js";
@@ -95,9 +96,10 @@ function buildServices(ks: Keystore) {
}
/** Resolve a command by its derived canonical id (e.g. "create", "import.mnemonic"). */
-function getCmd(registry: CommandRegistry, id: string): NetworklessCommandDefinition | null {
- const cmd = registry.all().find((c) => commandId(c) === id) ?? null;
+function getCmd(registry: CommandRegistry, id: string): CommandDefinition | null {
+ const cmd = registry.all().find((c) => commandId(isChainCommand(c) ? { path: c.spec.path } : c) === id) ?? null;
if (!cmd) throw new Error(`command not found: ${id}`);
+ if (isChainCommand(cmd)) throw new Error(`wallet command unexpectedly uses a chain definition: ${id}`);
if (cmd.network !== "none") throw new Error(`wallet command unexpectedly requires a network: ${id}`);
return cmd;
}
diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts
index 85529077..505770fa 100644
--- a/ts/src/adapters/inbound/cli/commands/wallet.ts
+++ b/ts/src/adapters/inbound/cli/commands/wallet.ts
@@ -12,6 +12,7 @@ import type { WalletService } from "../../../../application/use-cases/wallet-ser
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 { passwordPolicyErrors } from "../input/prompt/validators.js"
import { TextFormatters } from "../render/index.js"
// ── wallet import-ledger contract (module scope so it can be unit-tested) ───────
@@ -70,16 +71,16 @@ export function registerWalletCommands(reg: CommandRegistry, services: { walletS
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"),
+ .describe("human-friendly unique account label, 1-64 chars; omit to auto-generate; the mnemonic is entered interactively (hidden)"),
})
reg.add({
path: ["import", "mnemonic"],
- stdin: "mnemonic",
network: "none",
wallet: "none",
auth: "required",
passwordMode: "establish",
interactive: true,
+ secretsTtyOnly: true,
promptHints: { label: "default-label" },
summary: "Import a BIP39 mnemonic phrase",
fields: importMnemonicFields,
@@ -96,16 +97,16 @@ export function registerWalletCommands(reg: CommandRegistry, services: { walletS
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"),
+ .describe("human-friendly unique account label, 1-64 chars; omit to auto-generate; the private key is entered interactively (hidden)"),
})
reg.add({
path: ["import", "private-key"],
- stdin: "privateKey",
network: "none",
wallet: "none",
auth: "required",
passwordMode: "establish",
interactive: true,
+ secretsTtyOnly: true,
promptHints: { label: "default-label" },
summary: "Import a raw private key",
fields: importPrivateKeyFields,
@@ -313,4 +314,63 @@ export function registerWalletCommands(reg: CommandRegistry, services: { walletS
formatText: TextFormatters.walletBackup,
run: async (_ctx, _net, input) => wallets.backup(input.account, input.out),
} satisfies CommandDefinition)
+
+ // ── change-password ───────────────────────────────────────────────────────
+ // Verify old, prompt for the new one, confirm, then re-encrypt every software wallet keystore.
+ // TTY-only (secretsTtyOnly): both passwords are entered interactively — no stdin source, no argv.
+ // Sibling of `backup` — both are password-gated keystore secret operations.
+ const changePasswordFields = z.object({
+ yes: z.boolean().default(false)
+ .describe("skip the confirmation prompt; required in non-TTY use"),
+ })
+ reg.add({
+ path: ["change-password"],
+ network: "none",
+ wallet: "none",
+ auth: "required",
+ passwordMode: "verify",
+ interactive: true,
+ secretsTtyOnly: true,
+ requires: ["the new master password — entered interactively in a TTY"],
+ summary: "Change the master password (re-encrypt keystores)",
+ fields: changePasswordFields,
+ input: changePasswordFields,
+ examples: [{ cmd: "wallet-cli change-password" }],
+ formatText: TextFormatters.passwordChanged,
+ run: async (ctx, _net, input) => {
+ // old password: already verified and primed by dispatch (passwordMode: "verify"), from the TTY.
+ // secretsTtyOnly guarantees a TTY here (dispatch rejects --password-stdin / fails fast otherwise).
+ const oldPassword = ctx.secrets.read("password")
+
+ const newPassword = await ctx.prompt.hidden({
+ label: "New master password (hidden)",
+ confirm: true,
+ confirmLabel: "Confirm new password",
+ validate: (s) => { const e = passwordPolicyErrors(s); return e.length ? e.join("; ") : null },
+ })
+ if (newPassword === oldPassword) {
+ throw new UsageError("invalid_value", "the new password must differ from the current one")
+ }
+
+ if (!input.yes) {
+ if (!ctx.prompt.isTTY()) {
+ throw new UsageError("tty_required", "password change needs confirmation: pass --yes or run in a terminal")
+ }
+ const count = countSoftwareWallets(wallets)
+ const ok = await ctx.prompt.confirm({ label: `Re-encrypt ${count} software wallet(s) with the new password?` })
+ if (!ok) throw new UsageError("aborted", "password change not confirmed")
+ }
+ return wallets.changePassword(oldPassword, newPassword)
+ },
+ } satisfies CommandDefinition)
+}
+
+/** distinct software (seed/privateKey) wallets — the N in the change-password confirm prompt. */
+function countSoftwareWallets(wallets: WalletService): number {
+ const ids = new Set(
+ wallets.list()
+ .filter((a) => a.type === "seed" || a.type === "privateKey")
+ .map((a) => a.accountId.split(".")[0]),
+ )
+ return ids.size
}
diff --git a/ts/src/adapters/inbound/cli/context/index.ts b/ts/src/adapters/inbound/cli/context/index.ts
index 2f548ff5..bb49993a 100644
--- a/ts/src/adapters/inbound/cli/context/index.ts
+++ b/ts/src/adapters/inbound/cli/context/index.ts
@@ -38,7 +38,7 @@ class ExecutionContextImpl implements ExecutionContext {
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;
+ this.waitTimeoutMs = globals.waitTimeoutMs ?? deps.config.waitTimeoutMs;
}
get config(): Config {
diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts
index 00216fe7..7cbceb33 100644
--- a/ts/src/adapters/inbound/cli/contracts/command.ts
+++ b/ts/src/adapters/inbound/cli/contracts/command.ts
@@ -17,8 +17,10 @@ export interface Example {
// "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";
+/** secret/payload channel a command reads from stdin; documents the matching --*-stdin flag.
+ * (Wallet-secret entry — mnemonic/private-key/master-password — is TTY-only, so those never
+ * appear here; see `secretsTtyOnly`.) */
+export type StdinChannel = "tx" | "message";
export interface TextRenderContext {
command: string;
@@ -30,12 +32,9 @@ export interface TextRenderContext {
export type TextFormatter = (data: O, ctx: TextRenderContext) => string | null;
interface CommandDefinitionBase {
- /** 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.
+ /** full typed path (e.g. ["import","mnemonic"], ["config","get"], ["create"]).
* 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;
wallet: WalletRequirement;
@@ -50,6 +49,11 @@ interface CommandDefinitionBase {
positionals?: { field: string; placeholder?: string }[];
/** allow interactive TTY prompts (master password, secret, gap-fill, confirm). Absent ⇒ fail fast — safer for scripts/agents. */
interactive?: boolean;
+ /** secrets are entered interactively ONLY (no stdin source): every `--*-stdin` secret flag,
+ * including the global `--password-stdin`, is rejected for this command and hidden from its help.
+ * Set on the ultra-sensitive setup ops (import mnemonic/private-key, change-password) — a human
+ * moment, no agent/CI path. Does NOT affect create/backup/signing commands. */
+ secretsTtyOnly?: boolean;
/** gap-fill prompt hints, by field name: "skip" = never prompt this optional field; "default-label" = offer a generated default. */
promptHints?: Record;
capability?: string;
@@ -66,31 +70,74 @@ interface CommandDefinitionBase {
formatText?: TextFormatter;
}
-/** A networkless command never receives a chain target. */
-export interface NetworklessCommandDefinition
- extends CommandDefinitionBase {
+/** A neutral (family-less) command — wallet/config/meta operations that never receive a
+ * chain target. Networked commands are ChainCommandDefinitions. */
+export interface CommandDefinition extends CommandDefinitionBase {
network: "none";
run(ctx: ExecutionContext, net: undefined, input: I): Promise;
}
-/** Both policies resolve a concrete network; "optional" only means the CLI flag may be omitted. */
-export interface NetworkedCommandDefinition
- extends CommandDefinitionBase {
- network: Exclude;
+/** One family's slice of a chain command: how it runs + its extra flags/validation.
+ * It does NOT render — rendering is shared on the spec (Model P). O is the shared View type. */
+export interface FamilyBinding {
run(ctx: ExecutionContext, net: NetworkDescriptor, input: I): Promise;
+ /** family-specific extra flags merged onto ChainSpec.baseFields; omit when none. */
+ fields?: ZodObject;
+ /** family-specific cross-field validation; composed after ChainSpec.baseRefine. */
+ refine?: (value: any, ctx: import("zod").RefinementCtx) => void;
}
-/** Network policy discriminates the run signature, preventing unsafe `network!` assertions. */
-export type CommandDefinition =
- | NetworklessCommandDefinition
- | NetworkedCommandDefinition;
+/** Neutral, service-free declaration of a logical chain command. Generic over O, the single
+ * family-agnostic View every family's run returns. */
+export interface ChainSpec {
+ path: string[];
+ network: Exclude;
+ wallet: WalletRequirement;
+ auth: AuthRequirement;
+ broadcasts?: boolean;
+ capability?: string;
+ stdin?: StdinChannel;
+ interactive?: boolean;
+ passwordMode?: "establish" | "verify";
+ positionals?: { field: string; placeholder?: string }[];
+ promptHints?: Record;
+ requires?: string[];
+ summary?: string;
+ examples: Example[];
+ baseFields: ZodObject;
+ baseRefine?: (value: any, ctx: import("zod").RefinementCtx) => void;
+ /** shared text renderer; uses FAMILY_RENDER[net.family] for family-shaped rows. */
+ formatText?: TextFormatter;
+}
-export interface ChainModule {
- family: ChainFamily;
- registerCommands(reg: CommandRegistryLike): void;
+/** Assembled command held by the registry: one spec + a family→binding table. */
+export interface ChainCommandDefinition {
+ spec: ChainSpec;
+ families: Partial>>;
}
-/** structural view of CommandRegistry needed by ChainModule.registerCommands. */
-export interface CommandRegistryLike {
- add(cmd: CommandDefinition): void;
+/** Narrow structural command view shared by legacy definitions and assembled chain specs. */
+export type CommandExecutionSpec = Pick<
+ ChainSpec,
+ | "path"
+ | "network"
+ | "wallet"
+ | "auth"
+ | "broadcasts"
+ | "capability"
+ | "interactive"
+ | "passwordMode"
+ | "positionals"
+ | "promptHints"
+ | "requires"
+> & {
+ fields: ZodObject;
+};
+
+/** The registry stores either the legacy per-family CommandDefinition or the new one. */
+export type StoredCommand = CommandDefinition | ChainCommandDefinition;
+
+/** discriminates the two stored shapes. */
+export function isChainCommand(c: StoredCommand): c is ChainCommandDefinition {
+ return (c as ChainCommandDefinition).families !== undefined;
}
diff --git a/ts/src/adapters/inbound/cli/contracts/command.types.test.ts b/ts/src/adapters/inbound/cli/contracts/command.types.test.ts
new file mode 100644
index 00000000..be559a56
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/contracts/command.types.test.ts
@@ -0,0 +1,15 @@
+import { describe, it, expect } from "vitest";
+import { z } from "zod";
+import { isChainCommand, type ChainCommandDefinition, type CommandDefinition } from "./command.js";
+
+describe("isChainCommand", () => {
+ it("is true for a ChainCommandDefinition and false for a legacy CommandDefinition", () => {
+ const chain: ChainCommandDefinition = {
+ spec: { path: ["block"], network: "optional", wallet: "none", auth: "none", examples: [], baseFields: z.object({}) },
+ families: { tron: { run: async () => ({}) } },
+ };
+ const legacy = { path: ["block"], network: "none", wallet: "none", auth: "none", fields: z.object({}), input: z.object({}), examples: [], run: async () => ({}) } as unknown as CommandDefinition;
+ expect(isChainCommand(chain)).toBe(true);
+ expect(isChainCommand(legacy)).toBe(false);
+ });
+});
diff --git a/ts/src/adapters/inbound/cli/globals/index.ts b/ts/src/adapters/inbound/cli/globals/index.ts
index ef4a4fc8..8e127b75 100644
--- a/ts/src/adapters/inbound/cli/globals/index.ts
+++ b/ts/src/adapters/inbound/cli/globals/index.ts
@@ -53,13 +53,11 @@ export const GLOBAL_FLAG_SPECS: readonly GlobalFlagSpec[] = [
{ 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 },
+ description: "--wait polling cap, in milliseconds; on timeout return the submitted receipt", defaultValue: "config.waitTimeoutMs (built-in: 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)" },
+ // NB: mnemonic / private-key / new-password have NO stdin flag — those secrets are TTY-only
+ // (import mnemonic/private-key, change-password are interactive setup ops; see secretsTtyOnly).
{ 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,
diff --git a/ts/src/adapters/inbound/cli/help/catalog.ts b/ts/src/adapters/inbound/cli/help/catalog.ts
index 8438bd73..936b98a9 100644
--- a/ts/src/adapters/inbound/cli/help/catalog.ts
+++ b/ts/src/adapters/inbound/cli/help/catalog.ts
@@ -6,7 +6,8 @@
*/
import { z } from "zod";
import type { ChainFamily } from "../../../../domain/types/index.js";
-import type { CommandDefinition } from "../contracts/index.js";
+import { isChainCommand } from "../contracts/index.js";
+import type { ChainCommandDefinition, CommandDefinition, StdinChannel } 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";
@@ -44,7 +45,7 @@ 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[] {
+export function inputFlagsFor(cmd: { stdin?: StdinChannel }): readonly GlobalFlag[] {
return cmd.stdin ? [STDIN_FLAGS[cmd.stdin]] : [];
}
@@ -54,7 +55,7 @@ export function commandUsage(cmd: CommandDefinition): string {
}
/** never let one un-convertible schema break the whole catalog. */
-function commandInputSchema(input: CommandDefinition["input"]): unknown {
+function commandInputSchema(input: z.ZodType): unknown {
try {
return z.toJSONSchema(input as z.ZodType);
} catch {
@@ -66,21 +67,48 @@ function commandInputSchema(input: CommandDefinition["input"]): unknown {
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) }))
+ .filter((cmd) => isChainCommand(cmd)
+ ? !familyFilter || cmd.families[familyFilter] !== undefined
+ : !familyFilter)
+ .map((cmd) => isChainCommand(cmd)
+ ? {
+ id: commandId({ path: cmd.spec.path }),
+ kind: "chain",
+ families: Object.keys(cmd.families),
+ path: cmd.spec.path,
+ usage: `wallet-cli ${cmd.spec.path.join(" ")} [options]`,
+ summary: cmd.spec.summary ?? "",
+ requires: { network: cmd.spec.network, auth: cmd.spec.auth, wallet: cmd.spec.wallet },
+ ...(cmd.spec.capability ? { capability: cmd.spec.capability } : {}),
+ examples: cmd.spec.examples.map((e: { cmd: string }) => e.cmd),
+ ...(cmd.spec.stdin ? { inputFlags: inputFlagsFor(cmd.spec) } : {}),
+ inputSchema: commandInputSchema(mergedInput(cmd)),
+ }
+ : {
+ id: commandId(cmd),
+ kind: "neutral",
+ 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: { cmd: string }) => e.cmd),
+ ...(inputFlagsFor(cmd).length ? { inputFlags: inputFlagsFor(cmd) } : {}),
+ inputSchema: commandInputSchema(cmd.input),
+ })
.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 });
}
+
+function mergedInput(def: ChainCommandDefinition): z.ZodType {
+ let shape = { ...def.spec.baseFields.shape };
+ for (const binding of Object.values(def.families)) {
+ if (binding?.fields) shape = { ...shape, ...binding.fields.shape };
+ }
+ let input: z.ZodType = z.object(shape);
+ if (def.spec.baseRefine) input = input.superRefine(def.spec.baseRefine);
+ for (const binding of Object.values(def.families)) {
+ if (binding?.refine) input = input.superRefine(binding.refine);
+ }
+ return input;
+}
diff --git a/ts/src/adapters/inbound/cli/help/help.test.ts b/ts/src/adapters/inbound/cli/help/help.test.ts
index 4e5e6cdc..3988cb54 100644
--- a/ts/src/adapters/inbound/cli/help/help.test.ts
+++ b/ts/src/adapters/inbound/cli/help/help.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"
import { z } from "zod"
import { HelpService } from "./index.js"
import { CommandRegistry } from "../registry/index.js"
-import type { CommandDefinition, StreamManager } from "../contracts/index.js"
+import type { ChainSpec, StreamManager } from "../contracts/index.js"
// ── minimal fakes ─────────────────────────────────────────────────────────────
@@ -15,19 +15,18 @@ function makeStream(): StreamManager & { last: string | undefined } {
return s
}
-function chainCmd(path: string[], shape: z.ZodRawShape): CommandDefinition {
- const fields = z.object(shape)
+function chainSpec(path: string[], shape: z.ZodRawShape): ChainSpec {
return {
- path, family: "tron", network: "optional", wallet: "none", auth: "none",
- fields, input: fields, examples: [], run: async () => ({}),
- } as unknown as CommandDefinition
+ path, network: "optional", wallet: "none", auth: "none",
+ baseFields: z.object(shape), examples: [],
+ }
}
function build(): { help: HelpService; stream: ReturnType } {
const reg = new CommandRegistry()
- reg.add(chainCmd(["block"], { number: z.string().optional() })) // single-segment leaf
- reg.add(chainCmd(["tx", "info"], { txid: z.string().min(1) })) // multi-segment leaf
- reg.add(chainCmd(["tx", "send"], { to: z.string() })) // sibling under the tx group
+ reg.addChain(chainSpec(["block"], { number: z.string().optional() }), "tron", { run: async () => ({}) }) // single-segment leaf
+ reg.addChain(chainSpec(["tx", "info"], { txid: z.string().min(1) }), "tron", { run: async () => ({}) }) // multi-segment leaf
+ reg.addChain(chainSpec(["tx", "send"], { to: z.string() }), "tron", { run: async () => ({}) }) // sibling under the tx group
const stream = makeStream()
const help = new HelpService(reg, stream, "9.9.9")
return { help, stream }
@@ -59,3 +58,29 @@ describe("HelpService --json-schema", () => {
expect(out).toHaveProperty("commands") // group head → catalog, not a phantom command schema
})
})
+
+describe("HelpService ChainCommandDefinition", () => {
+ it("renders one chain leaf and one family-keyed catalog entry", () => {
+ const reg = new CommandRegistry()
+ reg.addChain({
+ path: ["block"],
+ network: "optional",
+ wallet: "none",
+ auth: "none",
+ positionals: [{ field: "number" }],
+ summary: "Get a block by number",
+ examples: [],
+ baseFields: z.object({ number: z.string().optional().describe("block number") }),
+ }, "tron", { run: async () => ({}) })
+ const stream = makeStream()
+ const help = new HelpService(reg, stream, "9.9.9")
+
+ help.handleMeta(["block", "--help"])
+ expect(stream.last).toContain("Get a block by number")
+ expect(stream.last!.match(/^ number\s/mg)).toHaveLength(1)
+
+ help.handleMeta(["--json-schema"])
+ const catalog = JSON.parse(stream.last!)
+ expect(catalog.commands).toContainEqual(expect.objectContaining({ id: "block", families: ["tron"] }))
+ })
+})
diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts
index de175982..2512504f 100644
--- a/ts/src/adapters/inbound/cli/help/index.ts
+++ b/ts/src/adapters/inbound/cli/help/index.ts
@@ -6,9 +6,10 @@
* 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 { z, type ZodObject, type ZodRawShape } from "zod"
import type { ChainFamily, ExitCode } from "../../../../domain/types/index.js"
-import type { CommandDefinition, StreamManager } from "../contracts/index.js"
+import { isChainCommand } from "../contracts/index.js"
+import type { ChainCommandDefinition, ChainSpec, CommandDefinition, StoredCommand, 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"
@@ -31,13 +32,14 @@ export class HelpService {
this.streams.result(this.version)
return 0
}
- const positionals = tokens.filter((t) => !t.startsWith("-"))
+ const positionals = metaPositionals(tokens)
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)))
+ const input = isChainCommand(concrete) ? mergedFields(concrete) : concrete.input
+ this.streams.result(JSON.stringify(z.toJSONSchema(input)))
return 0
}
// no concrete command → machine catalog (every command + flags), optionally scoped to a
@@ -47,21 +49,13 @@ export class HelpService {
}
if (concrete) {
- this.streams.result(this.#renderCommand(concrete))
+ this.streams.result(isChainCommand(concrete) ? this.#renderChainCommand(concrete) : 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
}
@@ -76,22 +70,15 @@ export class HelpService {
}
/** 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 {
+ #resolveConcrete(family: ChainFamily | undefined, path: string[]): StoredCommand | null {
if (path.length === 0) return null
- if (family) return this.registry.resolveForFamily(path, family)
+ const chain = this.registry.resolveChain(path)
+ if (chain && (!family || chain.families[family])) return chain
+ const chainHeadLeaf = this.registry.resolveChain([path[0]!])
+ if (chainHeadLeaf && (!family || chainHeadLeaf.families[family])) return chainHeadLeaf
+ if (family) return null
const neutral = this.registry.resolveNeutral(path)
if (neutral) return neutral
- // unique chain leaf: if the full logical path has exactly one impl (single family), render/emit
- // that command directly — so `block` and `tx info` behave alike without a family prefix. Once a
- // path has multiple families (e.g. tron + evm), it's ambiguous → fall through to the family-scoped
- // catalog instead.
- const exact = this.registry.resolveCandidates(path)
- if (exact.length === 1) return exact[0]!
- // single-segment chain leaf (e.g. `block`): resolve by its HEAD so `block 123` and even
- // `block ` still 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
}
@@ -115,7 +102,10 @@ export class HelpService {
["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"],
+ ["stake", "Stake / delegate resources & query state", "tron"],
+ ["vote", "Vote for super representatives", "tron"],
+ ["reward", "Query / withdraw voting rewards", "tron"],
+ ["chain", "Query chain params, prices & node info", ""],
["message", "Sign arbitrary messages", ""],
["block", "Get a block (latest if omitted)", ""],
] as const
@@ -128,6 +118,7 @@ export class HelpService {
["delete", "Delete a wallet / account", ""],
["config", "Show / get / set configuration values", ""],
["networks", "List known networks", ""],
+ ["change-password", "Change the master password (re-encrypt keystores)", ""],
] as const
const sections = [common, management, commands] as const
const nameWidth = Math.max(...sections.flat().map(([name]) => name.length)) + 2
@@ -201,28 +192,6 @@ export class HelpService {
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,
- requires: base.requires,
- positionals: base.positionals,
- })
- }
-
#renderCommand(cmd: CommandDefinition): string {
return this.#renderLeaf({
path: cmd.path,
@@ -236,6 +205,24 @@ export class HelpService {
examples: cmd.examples,
requires: cmd.requires,
positionals: cmd.positionals,
+ secretsTtyOnly: cmd.secretsTtyOnly,
+ })
+ }
+
+ #renderChainCommand(def: ChainCommandDefinition): string {
+ const { spec } = def
+ return this.#renderLeaf({
+ path: spec.path,
+ summary: spec.summary,
+ network: spec.network,
+ auth: spec.auth,
+ wallet: spec.wallet,
+ broadcasts: spec.broadcasts,
+ fields: introspectFields(mergedFields(def)),
+ inputFlags: spec.stdin ? inputFlagsFor(spec) : [],
+ examples: spec.examples,
+ requires: spec.requires,
+ positionals: spec.positionals,
})
}
@@ -243,7 +230,7 @@ export class HelpService {
#renderLeaf(c: {
path: string[]
summary?: string
- network: CommandDefinition["network"]
+ network: ChainSpec["network"] | "none"
auth: CommandDefinition["auth"]
wallet: CommandDefinition["wallet"]
broadcasts?: boolean
@@ -252,6 +239,7 @@ export class HelpService {
examples: CommandDefinition["examples"]
requires?: string[]
positionals?: { field: string; placeholder?: string }[]
+ secretsTtyOnly?: boolean
}): string {
const positionals = (c.positionals ?? []).map((p) => {
const field = c.fields.find((f) => f.name === p.field)
@@ -271,7 +259,9 @@ export class HelpService {
const requires: string[] = [...(c.requires ?? [])]
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.auth === "required") requires.push(c.secretsTtyOnly
+ ? "the master password — entered interactively in a TTY"
+ : "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 run `wallet-cli use ` to change the active account)")
if (requires.length) {
lines.push("", "Requires:")
@@ -298,7 +288,7 @@ export class HelpService {
lines.push("", "Global options:")
// curated per command: --network only when the command selects a network; --password-stdin
// only when it requires unlock; --account only when the command acts as an account.
- for (const g of globalFlagsForText(c.network, c.auth, c.wallet, c.broadcasts ?? false)) lines.push(globalFlagLine(g))
+ for (const g of globalFlagsForText(c.network, c.auth, c.wallet, c.broadcasts ?? false, c.secretsTtyOnly ?? false)) lines.push(globalFlagLine(g))
if (c.examples.length) {
lines.push("", "Examples:")
@@ -307,13 +297,12 @@ export class HelpService {
return lines.join("\n")
}
- /** chain groups = first path segment of every family-bound command. */
+ /** chain groups = first path segment of every assembled chain 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]
+ const group = isChainCommand(c) ? c.spec.path[0] : undefined
if (group && !seen.has(group)) (seen.add(group), out.push(group))
}
return out
@@ -323,21 +312,22 @@ export class HelpService {
return this.#chainGroups().includes(group)
}
- /** chain group sub-commands, deduped across families by logical path. */
+ /** chain group sub-commands, one row per logical chain definition. */
#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 }))
+ if (isChainCommand(c) && c.spec.path[0] === group) {
+ out.push({ path: c.spec.path, summary: c.spec.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)
+ return this.registry.all().filter((c): c is CommandDefinition =>
+ !isChainCommand(c) && c.path[0] === head && c.path.length > 1,
+ )
}
#isNeutralGroup(head: string): boolean {
@@ -350,6 +340,30 @@ export class HelpService {
}
}
+function mergedFields(def: ChainCommandDefinition): ZodObject {
+ let shape = { ...def.spec.baseFields.shape }
+ for (const b of Object.values(def.families)) if (b?.fields) shape = { ...shape, ...b.fields.shape }
+ return z.object(shape)
+}
+
+function metaPositionals(tokens: string[]): string[] {
+ const valueFlags = new Set(
+ GLOBAL_FLAGS
+ .filter((flag) => flag.type !== "boolean")
+ .flatMap((flag) => [flag.flag, flag.alias].filter((name): name is string => name !== undefined)),
+ )
+ const positionals: string[] = []
+ for (let i = 0; i < tokens.length; i += 1) {
+ const token = tokens[i]!
+ if (token.startsWith("-")) {
+ if (valueFlags.has(token)) i += 1
+ continue
+ }
+ positionals.push(token)
+ }
+ return positionals
+}
+
/** "--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}>`
@@ -373,15 +387,16 @@ function formatDefault(v: unknown): string {
// only for ✍️ broadcast commands; --account only when the command acts as an account (also surfaced,
// with fuller semantics, under Requires). The full GLOBAL_FLAGS array still backs the --json-schema catalog.
function globalFlagsForText(
- network: CommandDefinition["network"],
+ network: ChainSpec["network"] | "none",
auth: CommandDefinition["auth"],
wallet: CommandDefinition["wallet"],
broadcasts: boolean,
+ secretsTtyOnly: boolean,
): GlobalFlag[] {
return GLOBAL_FLAGS.filter((g) => {
if (g.flag === "--account") return wallet !== "none"
if (g.flag === "--network") return network !== "none"
- if (g.flag === "--password-stdin") return auth === "required"
+ if (g.flag === "--password-stdin") return auth === "required" && !secretsTtyOnly
if (g.flag === "--wait" || g.flag === "--wait-timeout") return broadcasts
return true
})
@@ -401,7 +416,10 @@ const GROUP_DESCRIPTIONS: Record = {
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 Stake 2.0).",
+ stake: "Stake / delegate resources & query state (TRON Stake 2.0).",
+ vote: "Vote for super representatives (SR).",
+ reward: "Query and withdraw voting/block rewards.",
+ chain: "Query on-chain parameters, resource prices, and node status.",
message: "Sign arbitrary messages.",
block: "Get a block (latest if omitted).",
}
diff --git a/ts/src/adapters/inbound/cli/input/secret/index.ts b/ts/src/adapters/inbound/cli/input/secret/index.ts
index c5f8d465..8b471c99 100644
--- a/ts/src/adapters/inbound/cli/input/secret/index.ts
+++ b/ts/src/adapters/inbound/cli/input/secret/index.ts
@@ -87,14 +87,10 @@ export class SecretResolver implements ISecretResolver {
throw new UsageError("missing_option", `--${inlineFlag} or --${flagOf(kind)}-stdin is required`);
}
+ /** Wallet secrets (mnemonic / private key) are TTY-only: a hidden interactive prompt, or fail.
+ * There is no `--*-stdin` source for them — importing an existing secret is a human moment. */
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)"
@@ -107,7 +103,7 @@ export class SecretResolver implements ISecretResolver {
this.#primed.set(kind, trimmed);
return trimmed;
}
- throw new UsageError("missing_option", `--${flagOf(kind)}-stdin is required (or run in a terminal)`);
+ throw new UsageError("tty_required", `${kind} entry is interactive; run in a terminal`);
}
async primePassword(plan: { mode: "set" | "verify"; verify?: (pw: string) => boolean }): Promise {
diff --git a/ts/src/adapters/inbound/cli/input/secret/secret.test.ts b/ts/src/adapters/inbound/cli/input/secret/secret.test.ts
index af1d9f43..bb99b96f 100644
--- a/ts/src/adapters/inbound/cli/input/secret/secret.test.ts
+++ b/ts/src/adapters/inbound/cli/input/secret/secret.test.ts
@@ -22,24 +22,22 @@ class Backend implements PromptBackend {
const PW = "Abcdef1!";
-describe("resolveSecret", () => {
- it("prompts (hidden) when no stdin source and validates mnemonic", async () => {
+describe("resolveSecret (TTY-only)", () => {
+ // mnemonic / private key have NO --*-stdin source; they are entered via a hidden TTY prompt,
+ // which re-prompts until the value validates.
+ it("prompts (hidden) and validates the mnemonic, retrying until valid", 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 () => {
+ it("prompts (hidden) and validates a private key, retrying until valid", async () => {
const k = "a".repeat(64);
- const r = new SecretResolver(streams(k + "\n"), { privateKey: "-" }, new Prompter(new Backend([])));
+ const r = new SecretResolver(streams(), {}, new Prompter(new Backend(["zzz", k])));
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 () => {
+ it("errors with tty_required when there is no terminal", async () => {
const r = new SecretResolver(streams(), {}, new Prompter(new Backend([], false)));
- await expect(r.resolveSecret("mnemonic")).rejects.toMatchObject({ code: "missing_option" });
+ await expect(r.resolveSecret("mnemonic")).rejects.toMatchObject({ code: "tty_required" });
});
});
diff --git a/ts/src/adapters/inbound/cli/output/envelope.test.ts b/ts/src/adapters/inbound/cli/output/envelope.test.ts
index 208d0ceb..171e15d2 100644
--- a/ts/src/adapters/inbound/cli/output/envelope.test.ts
+++ b/ts/src/adapters/inbound/cli/output/envelope.test.ts
@@ -1,16 +1,15 @@
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 command = "current";
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);
+ const env = OutputEnvelope.success(command, undefined, data, m);
expect((env.data as { addresses: Record }).addresses).toEqual({ tron: "Ttron" });
});
@@ -19,13 +18,13 @@ describe("OutputEnvelope.success — result payload passthrough", () => {
{ accountId: "a.0", addresses: { tron: "Ttron0" } },
{ accountId: "a.1", addresses: { tron: "Ttron1" } },
];
- const env = OutputEnvelope.success(cmd, undefined, data, m);
+ const env = OutputEnvelope.success(command, 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);
+ const env = OutputEnvelope.success(command, 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
index 7f79718a..14fce602 100644
--- a/ts/src/adapters/inbound/cli/output/envelope.ts
+++ b/ts/src/adapters/inbound/cli/output/envelope.ts
@@ -4,8 +4,7 @@
* 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";
+import type { ChainView, ErrorEnvelope, Meta, ResultEnvelope } from "../contracts/index.js";
type CliErrorEnvelopeShape = { code: string; message: string; details?: object };
@@ -34,7 +33,7 @@ function meta(durationMs: number, warnings: string[]): Meta {
export const OutputEnvelope = {
success(
- cmd: CommandDefinition,
+ command: string,
net: NetworkDescriptor | undefined,
data: unknown,
m: { durationMs: number; warnings: string[] },
@@ -42,7 +41,7 @@ export const OutputEnvelope = {
const env: ResultEnvelope = {
schema: SCHEMA_VERSION,
success: true,
- command: commandId(cmd),
+ command,
data: data ?? {},
meta: meta(m.durationMs, m.warnings),
};
@@ -51,7 +50,7 @@ export const OutputEnvelope = {
},
error(
- commandId: string,
+ command: string,
net: NetworkDescriptor | undefined,
err: CliErrorEnvelopeShape,
m: { durationMs: number; warnings: string[] },
@@ -59,7 +58,7 @@ export const OutputEnvelope = {
const env: ErrorEnvelope = {
schema: SCHEMA_VERSION,
success: false,
- command: commandId,
+ command,
error: err,
meta: meta(m.durationMs, m.warnings),
};
diff --git a/ts/src/adapters/inbound/cli/output/index.ts b/ts/src/adapters/inbound/cli/output/index.ts
index d529d8eb..7b8c41a2 100644
--- a/ts/src/adapters/inbound/cli/output/index.ts
+++ b/ts/src/adapters/inbound/cli/output/index.ts
@@ -11,7 +11,7 @@
*/
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 { StreamManager, TextFormatter } from "../contracts/index.js";
import type { CliError } from "../../../../domain/errors/index.js";
import { OutputEnvelope, toJson } from "./envelope.js";
import { renderGenericText } from "../render/index.js";
@@ -19,7 +19,7 @@ import { sanitizeText } from "../render/scalars.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;
+ success(command: string, net: NetworkDescriptor | undefined, data: unknown, formatText?: TextFormatter, 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. */
@@ -38,9 +38,9 @@ abstract class BaseOutputFormatter {
}
class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter {
- success(cmd: CommandDefinition, net: NetworkDescriptor | undefined, data: unknown): string {
+ success(command: string, 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()));
+ return toJson(OutputEnvelope.success(command, net, data, this.meta()));
}
error(err: CliError, ctx?: { commandId?: string; net?: NetworkDescriptor }): void {
@@ -56,9 +56,9 @@ class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter
class HumanOutputFormatter extends BaseOutputFormatter implements OutputFormatter {
// Text mode: strip terminal control bytes from every frame so a hostile wallet label or remote
// token/RPC metadata value cannot inject ANSI/OSC sequences (CLI-OUT-001). JSON mode stays raw.
- 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 });
+ success(command: string, net: NetworkDescriptor | undefined, data: unknown, formatText?: TextFormatter, accountLabel?: string): string {
+ const env = OutputEnvelope.success(command, net, data, this.meta());
+ const custom = formatText?.(env.data, { command: env.command, net, accountLabel });
return sanitizeText(custom ?? renderGenericText(env.command, net, env.data));
}
diff --git a/ts/src/adapters/inbound/cli/output/output.test.ts b/ts/src/adapters/inbound/cli/output/output.test.ts
index dc62abfa..6b11fc80 100644
--- a/ts/src/adapters/inbound/cli/output/output.test.ts
+++ b/ts/src/adapters/inbound/cli/output/output.test.ts
@@ -14,7 +14,7 @@ function capture(output: "text" | "json") {
return { sm, out, err };
}
-const cmd = { family: "tron", path: ["account", "balance"] } as unknown as CommandDefinition;
+const cmd = { path: ["account", "balance"] } as unknown as CommandDefinition;
const net: NetworkDescriptor = {
id: "tron:nile", family: "tron", chainId: "nile", aliases: ["nile"], capabilities: [],
};
@@ -23,9 +23,9 @@ 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" }));
+ const env = JSON.parse(f.success(commandId(cmd), net, { balance: "1" }));
expect(env.success).toBe(true);
- expect(env.command).toBe("tron.account.balance");
+ expect(env.command).toBe("account.balance");
expect(env.chain).toMatchObject({ network: "tron:nile", chainId: "nile" });
expect(env.data).toEqual({ balance: "1" });
expect(env.meta).toMatchObject({ warnings: [] });
@@ -59,8 +59,8 @@ 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");
+ const text = f.success(commandId(cmd), net, { balance: "1" });
+ expect(text).toContain("account.balance");
expect(text).toContain("balance");
});
@@ -90,14 +90,14 @@ describe("createOutputFormatter (text)", () => {
"Run `backup` soon and store the file offline.",
]),
} as unknown as CommandDefinition;
- const text = f.success(walletCmd, undefined, {
+ const text = f.success(commandId(walletCmd), undefined, {
status: "created",
accountId: "wlt_abc.0",
label: "main",
type: "seed",
active: true,
addresses: { tron: "T1234567890abcdef", evm: "0x1234567890abcdef" },
- });
+ }, walletCmd.formatText);
expect(text).toContain("Created wallet");
expect(text).toContain("main");
expect(text).toContain("Run `backup`");
@@ -112,13 +112,13 @@ describe("createOutputFormatter (text)", () => {
"Private key was read from hidden input and was not printed.",
]),
} as unknown as CommandDefinition;
- const text = f.success(walletCmd, undefined, {
+ const text = f.success(commandId(walletCmd), undefined, {
status: "existing",
accountId: "wlt_abc.0",
label: "main",
type: "seed",
addresses: { tron: "T1234567890abcdef", evm: "0x1234567890abcdef" },
- });
+ }, walletCmd.formatText);
// 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");
@@ -129,7 +129,7 @@ describe("createOutputFormatter (text)", () => {
const { sm } = capture("text");
const f = createOutputFormatter("text", sm, 0);
// a hostile label / remote metadata value carrying ANSI CSI, OSC, and a bare C1 CSI byte.
- const text = f.success(cmd, net, { balance: "1\x1b[31mHACKED\x1b]0;pwn\x07\x9bK" });
+ const text = f.success(commandId(cmd), net, { balance: "1\x1b[31mHACKED\x1b]0;pwn\x07\x9bK" });
expect(text).not.toContain("\x1b");
expect(text).not.toContain("\x9b");
expect(text).not.toContain("\x07");
@@ -139,7 +139,7 @@ describe("createOutputFormatter (text)", () => {
it("preserves newlines while stripping control bytes", () => {
const { sm } = capture("text");
const f = createOutputFormatter("text", sm, 0);
- const text = f.success(cmd, net, { balance: "1" });
+ const text = f.success(commandId(cmd), net, { balance: "1" });
expect(text).toContain("\n"); // layout line breaks must remain intact
});
@@ -157,7 +157,7 @@ describe("createOutputFormatter (text)", () => {
it("json mode keeps data raw (no sanitization)", () => {
const { sm } = capture("json");
const f = createOutputFormatter("json", sm, 0);
- const env = JSON.parse(f.success(cmd, net, { balance: "1\x1b[31m" }));
+ const env = JSON.parse(f.success(commandId(cmd), net, { balance: "1\x1b[31m" }));
expect(env.data.balance).toBe("1\x1b[31m"); // machine-parseable output stays byte-exact
});
@@ -165,7 +165,7 @@ describe("createOutputFormatter (text)", () => {
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, {
+ const text = f.success(commandId(backupCmd), undefined, {
accountId: "wlt_abc.0",
secretType: "mnemonic",
out: "/tmp/main-backup.json",
@@ -173,7 +173,7 @@ describe("createOutputFormatter (text)", () => {
bytes: 512,
mnemonic: "test test test test test test test test test test test junk",
privateKey: "00".repeat(32),
- });
+ }, backupCmd.formatText);
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
index 7fda1186..363e4b4b 100644
--- a/ts/src/adapters/inbound/cli/registry/index.ts
+++ b/ts/src/adapters/inbound/cli/registry/index.ts
@@ -1,43 +1,52 @@
/**
- * 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.
+ * CommandRegistry — holds all commands; resolves one from a positional path; 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.
+ * Two command kinds, in two maps:
+ * - neutral (CommandDefinition): full path, no chain target (create, import mnemonic, config get…).
+ * - chain (ChainCommandDefinition): one logical path + a family→binding table, 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";
+import type {
+ ChainCommandDefinition,
+ ChainSpec,
+ CommandDefinition,
+ FamilyBinding,
+ StoredCommand,
+} from "../contracts/index.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();
+export class CommandRegistry {
+ #byPath = new Map();
+ #chainByPath = new Map();
add(cmd: CommandDefinition): void {
- // Family commands are selected through a resolved network, so this combination cannot be
- // dispatched. Keep this routing invariant here instead of coupling it to the run signature.
- if (cmd.family && cmd.network === "none") {
- throw new Error(`family command must resolve a network: ${commandId(cmd)}`);
+ const key = cmd.path.join(".");
+ if (this.#byPath.has(key)) throw new Error(`duplicate command ${key}`);
+ this.#byPath.set(key, cmd);
+ }
+
+ addChain(spec: ChainSpec, family: ChainFamily, binding: FamilyBinding): void {
+ const key = spec.path.join(".");
+ const existing = this.#chainByPath.get(key);
+ if (!existing) {
+ this.#chainByPath.set(key, { spec, families: { [family]: binding } });
+ return;
}
- const key = keyOf(cmd);
- if (this.#byKey.has(key)) throw new Error(`duplicate command ${key}`);
- this.#byKey.set(key, cmd);
+ if (existing.spec !== spec) throw new Error(`chain command ${key} registered with two different specs`);
+ if (existing.families[family]) throw new Error(`duplicate command ${family}:${key}`);
+ existing.families[family] = binding;
+ }
+
+ resolveChain(path: string[]): ChainCommandDefinition | null {
+ return this.#chainByPath.get(path.join(".")) ?? null;
}
families(): ChainFamily[] {
const set = new Set();
- for (const cmd of this.#byKey.values()) if (cmd.family) set.add(cmd.family);
+ for (const cmd of this.#chainByPath.values()) {
+ for (const family of Object.keys(cmd.families)) set.add(family as ChainFamily);
+ }
return [...set];
}
@@ -45,37 +54,23 @@ export class CommandRegistry implements CommandRegistryLike {
* (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);
+ for (const cmd of this.#chainByPath.values()) {
+ if (!cmd.spec.capability) continue;
+ for (const family of Object.keys(cmd.families) as ChainFamily[]) {
+ const set = out.get(family) ?? new Set();
+ set.add(cmd.spec.capability);
+ out.set(family, set);
+ }
}
return new Map([...out].map(([f, s]) => [f, [...s]]));
}
- /** Resolve a neutral command (no family) by its full path. */
+ /** Resolve a neutral command 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()];
+ return this.#byPath.get(path.join(".")) ?? null;
}
- tree(): CommandTreeMeta {
- const commands = this.all().map((c) => ({ path: c.path, id: commandId(c), family: c.family, summary: c.summary }));
- return { commands };
+ all(): StoredCommand[] {
+ return [...this.#byPath.values(), ...this.#chainByPath.values()];
}
}
diff --git a/ts/src/adapters/inbound/cli/registry/registry.chain.test.ts b/ts/src/adapters/inbound/cli/registry/registry.chain.test.ts
new file mode 100644
index 00000000..c5cb3d45
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/registry/registry.chain.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from "vitest";
+import { z } from "zod";
+import { CommandRegistry } from "./index.js";
+import type { ChainSpec, FamilyBinding } from "../contracts/index.js";
+
+const spec: ChainSpec = { path: ["block"], network: "optional", wallet: "none", auth: "none", examples: [], baseFields: z.object({}) };
+const tron: FamilyBinding = { run: async () => ({ block: {} }) };
+
+describe("CommandRegistry.addChain", () => {
+ it("creates a ChainCommandDefinition resolvable by path", () => {
+ const reg = new CommandRegistry();
+ reg.addChain(spec, "tron", tron);
+ const def = reg.resolveChain(["block"]);
+ expect(def).not.toBeNull();
+ expect(def!.spec).toBe(spec);
+ expect(def!.families.tron).toBe(tron);
+ });
+
+ it("rejects a duplicate (path, family) binding", () => {
+ const reg = new CommandRegistry();
+ reg.addChain(spec, "tron", tron);
+ expect(() => reg.addChain(spec, "tron", tron)).toThrow(/duplicate/);
+ });
+
+ it("resolveChain returns null for an unknown path", () => {
+ expect(new CommandRegistry().resolveChain(["nope"])).toBeNull();
+ });
+});
diff --git a/ts/src/adapters/inbound/cli/registry/registry.test.ts b/ts/src/adapters/inbound/cli/registry/registry.test.ts
index 4651f243..3d872ea9 100644
--- a/ts/src/adapters/inbound/cli/registry/registry.test.ts
+++ b/ts/src/adapters/inbound/cli/registry/registry.test.ts
@@ -1,17 +1,14 @@
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 {
+function command(path: string[]): CommandDefinition {
const fields = z.object({});
return {
- family,
path,
- network: "optional",
- wallet: "optional",
+ network: "none",
+ wallet: "none",
auth: "none",
fields,
input: fields,
@@ -20,42 +17,17 @@ function command(family: ChainFamily, path: string[]): CommandDefinition {
};
}
-describe("CommandRegistry logical resolution", () => {
- it("rejects a family command that cannot resolve a network", () => {
+describe("CommandRegistry neutral resolution", () => {
+ it("resolves a neutral command by its full path", () => {
const reg = new CommandRegistry();
- const fields = z.object({});
- expect(() => reg.add({
- family: "tron",
- path: ["invalid"],
- network: "none",
- wallet: "none",
- auth: "none",
- fields,
- input: fields,
- examples: [],
- run: async () => ({}),
- })).toThrow("family command must resolve a network: tron.invalid");
+ reg.add(command(["import", "mnemonic"]));
+ expect(reg.resolveNeutral(["import", "mnemonic"])?.path).toEqual(["import", "mnemonic"]);
+ expect(reg.resolveNeutral(["import", "missing"])).toBeNull();
});
- it("returns every implementation for a logical path", () => {
+ it("rejects a duplicate 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();
+ reg.add(command(["create"]));
+ expect(() => reg.add(command(["create"]))).toThrow("duplicate command create");
});
});
diff --git a/ts/src/adapters/inbound/cli/render/account.ts b/ts/src/adapters/inbound/cli/render/account.ts
new file mode 100644
index 00000000..085e3d9a
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/render/account.ts
@@ -0,0 +1,148 @@
+import type { TextFormatter, TextRenderContext } from "../contracts/index.js"
+import { RESOURCES, resourceOfRpcCode, type Resource } from "../../../../domain/resources/index.js"
+import { fromBaseUnits } from "../../../../domain/amounts/index.js"
+import { formatScalar, formatInt, formatUsd, formatSun, formatTime, num, quote } from "./scalars.js"
+import { type Obj, type Pair, asObj, query, receipt, table, ok, fail, warn } from "./layout.js"
+
+/** humanize a raw base-unit balance: scale by `decimals` when known, else show the raw integer. */
+function humanBalance(d: Obj): string {
+ return d.decimals !== undefined ? fromBaseUnits(String(d.balance ?? "0"), num(d.decimals, 0)) : formatScalar(d.balance)
+}
+
+export const AccountFormatters = {
+ accountBalance: ((data, ctx) => {
+ const d = asObj(data)
+ const symbol = d.symbol ? ` ${String(d.symbol)}` : ""
+ return query([identity(ctx, d.address), ["Balance", `${humanBalance(d)}${symbol}`]])
+ }) 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 holdings = (Array.isArray(d.holdings) ? d.holdings : []).map(asObj)
+ const rows = holdings.map((h) => [
+ String(h.symbol ?? ""),
+ h.balanceUnavailable ? "unavailable" : 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}`]
+ for (const h of holdings) {
+ if (h.balanceUnavailable) lines.push(`${warn()} ${String(h.symbol ?? "")} balance unavailable (${String(h.reason ?? "")})`)
+ }
+ if (d.priceUnavailable) lines.push(`${warn()} price warning (${String(d.priceReason ?? "")})`)
+ return lines.join("\n")
+ }) satisfies TextFormatter,
+
+ tokenBalance: ((data, ctx) => {
+ const d = asObj(data)
+ return query([identity(ctx, d.address), ["Name", String(d.name ?? "")], ["Symbol", String(d.symbol ?? "")], ["Balance", humanBalance(d)]])
+ }) 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,
+}
+
+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"
+ const amount = safeUnsignedBigInt(f.amount ?? 0)
+ // An unsafe JS number has already lost precision. Omit the summary instead of presenting a
+ // plausible but incorrect total; the raw account payload remains available in JSON mode.
+ if (amount === null) return ""
+ sums.set(r, (sums.get(r) ?? 0n) + amount)
+ }
+ const total = RESOURCES.reduce((t, r) => t + (sums.get(r) ?? 0n), 0n)
+ if (total === 0n) return ""
+ const parts = RESOURCES.map((r) => `${r} ${formatSun(sums.get(r) ?? 0n)}`).join(" + ")
+ return `${formatSun(total)} TRX (${parts})`
+}
+
+function safeUnsignedBigInt(value: unknown): bigint | null {
+ if (typeof value === "bigint") return value >= 0n ? value : null
+ if (typeof value === "number") {
+ return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null
+ }
+ if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value)
+ return null
+}
+
+function historyRow(r: Obj): string[] {
+ const ts = r.time ?? r.block_timestamp ?? r.timestamp
+ const type = r.type ?? r.transfer_type ?? r.direction ?? ""
+ 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 ?? "")]
+}
diff --git a/ts/src/adapters/inbound/cli/render/chain.ts b/ts/src/adapters/inbound/cli/render/chain.ts
new file mode 100644
index 00000000..a8c148f5
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/render/chain.ts
@@ -0,0 +1,70 @@
+import type { TextFormatter } from "../contracts/index.js"
+import { formatDecimal, formatInt, formatSun } from "./scalars.js"
+import { asObj, query, table } from "./layout.js"
+
+const KNOWN_UNITS: Record = {
+ getEnergyFee: "SUN",
+ getTransactionFee: "SUN",
+ getCreateAccountFee: "SUN",
+ getCreateNewAccountFeeInSystemContract: "SUN",
+ getWitnessPayPerBlock: "SUN",
+ getMemoFee: "SUN",
+ getMaintenanceTimeInterval: "ms",
+}
+
+function parameterValue(key: string, value: unknown): string {
+ const unit = KNOWN_UNITS[key]
+ return unit ? `${formatInt(value)} ${unit}` : String(value ?? "")
+}
+
+function timestamp(v: unknown): string {
+ const n = Number(v)
+ if (!Number.isFinite(n) || n <= 0) return "—"
+ return new Date(n).toISOString().replace("T", " ").slice(0, 19)
+}
+
+export const ChainFormatters = {
+ chainParams: ((data) => {
+ const d = asObj(data)
+ if ("key" in d) {
+ const key = String(d.key ?? "")
+ return query([
+ ["Key", key],
+ ["Value", parameterValue(key, d.value)],
+ ])
+ }
+ const params = Array.isArray(d.params) ? d.params.map(asObj) : []
+ return table(
+ ["Key", "Value"],
+ params.map((p) => [String(p.key ?? ""), parameterValue(String(p.key ?? ""), p.value)]),
+ )
+ }) satisfies TextFormatter,
+
+ chainPrices: ((data) => {
+ const d = asObj(data)
+ const energy = asObj(d.energy)
+ const bandwidth = asObj(d.bandwidth)
+ return query([
+ ["Energy price", `${formatInt(energy.currentSunPerUnit)} SUN / unit (current)`],
+ ["Bandwidth price", `${formatInt(bandwidth.currentSunPerUnit)} SUN / unit (current)`],
+ ["Memo fee", `${formatDecimal(formatSun(d.memoFeeSun))} TRX`],
+ ])
+ }) satisfies TextFormatter,
+
+ chainNode: ((data) => {
+ const d = asObj(data)
+ const head = asObj(d.headBlock)
+ const solid = asObj(d.solidBlock)
+ const peers = asObj(d.peers)
+ const headTimestamp = Number(head.timestamp ?? 0)
+ const ageSeconds = headTimestamp > 0 ? Math.max(0, Math.round((Date.now() - headTimestamp) / 1000)) : null
+ const sync = d.inSync ? "in sync" : "lagging"
+ return query([
+ ["Endpoint", d.endpoint === null ? "—" : String(d.endpoint ?? "—")],
+ ["Version", d.version === null ? "—" : String(d.version ?? "—")],
+ ["Head block", `#${formatInt(head.number)} ${timestamp(head.timestamp)} (${ageSeconds === null ? "—" : `~${ageSeconds}s ago — ${sync}`})`],
+ ["Solid block", d.solidBlock === null ? "—" : `#${formatInt(solid.number)} (${formatInt(d.lagBlocks)} blocks behind head)`],
+ ["Peers", d.peers === null ? "—" : `${formatInt(peers.connected)} connected / ${formatInt(peers.active)} active`],
+ ])
+ }) satisfies TextFormatter,
+}
diff --git a/ts/src/adapters/inbound/cli/render/family.ts b/ts/src/adapters/inbound/cli/render/family.ts
new file mode 100644
index 00000000..59592a3c
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/render/family.ts
@@ -0,0 +1,51 @@
+import type { TxInfoView } from "../../../../domain/types/index.js"
+import type { TextRenderContext } from "../contracts/index.js"
+import { ChainFamily } from "../../../../domain/family/index.js"
+import { formatScalar, formatInt, formatSun } from "./scalars.js"
+import { type Pair } from "./layout.js"
+
+/**
+ * Per-family render hooks — the one table that folds the scattered `r.family === tron ? … : …`
+ * branches. Adding a chain = one entry here (alongside its FAMILIES + FamilyDef entries).
+ */
+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 function familyAddressLabel(family: string): string {
+ return FAMILY_RENDER[family as ChainFamily]?.addressLabel ?? `${family} address`
+}
+
+/** the active chain family for a chain-command renderer. Chain commands always resolve a network
+ * before running, so `ctx.net` is present; the tron fallback only guards a shape that can't occur. */
+export function renderFamily(ctx?: TextRenderContext): ChainFamily {
+ return ctx?.net?.family ?? "tron"
+}
diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts
index a54b90aa..041647ba 100644
--- a/ts/src/adapters/inbound/cli/render/index.ts
+++ b/ts/src/adapters/inbound/cli/render/index.ts
@@ -1,230 +1,39 @@
-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, unknown } 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).
+ * Text-mode renderers, split by command domain:
+ * family.ts — FAMILY_RENDER per-family hook table (+ renderFamily)
+ * wallet.ts — wallet create/import/list/… receipts
+ * account.ts — account/token queries (balance, info, history, portfolio, token book)
+ * tx.ts — tx/stake/contract signing receipts + tx status/info
+ * stake.ts — stake queries (info, delegated)
+ * vote.ts — SR voting list/status views
+ * reward.ts — voting/block reward views
+ * chain.ts — chain queries (params, prices, node)
+ * misc.ts — config, networks, contract call/info, message sign, block
+ * This barrel reassembles the one TextFormatters table command specs import.
*/
-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`],
- ],
- },
-}
-
-/** humanize a raw base-unit balance: scale by `decimals` when known, else show the raw integer. */
-function humanBalance(d: Obj): string {
- return d.decimals !== undefined ? fromBaseUnits(String(d.balance ?? "0"), num(d.decimals, 0)) : formatScalar(d.balance)
-}
+import type { NetworkDescriptor } from "../../../../domain/types/index.js"
+import { formatScalar } from "./scalars.js"
+import { type Obj, ok } from "./layout.js"
+import { WalletFormatters } from "./wallet.js"
+import { AccountFormatters } from "./account.js"
+import { TxFormatters } from "./tx.js"
+import { StakeFormatters } from "./stake.js"
+import { VoteFormatters } from "./vote.js"
+import { RewardFormatters } from "./reward.js"
+import { ChainFormatters } from "./chain.js"
+import { MiscFormatters } from "./misc.js"
+
+export { FAMILY_RENDER, renderFamily } from "./family.js"
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)
- const scope = d.scope === "account" ? "account" : "wallet"
- return receipt(ok(), `Deleted ${scope} ${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", "Fee model"],
- (Array.isArray(data) ? data : []).map(asObj).map((n) => [String(n.id ?? ""), String(n.family ?? ""), String(n.chainId ?? ""), String(n.feeModel ?? "")]),
- )) satisfies TextFormatter,
-
- accountBalance: ((data, ctx) => {
- const d = asObj(data)
- const symbol = d.symbol ? ` ${String(d.symbol)}` : ""
- return query([identity(ctx, d.address), ["Balance", `${humanBalance(d)}${symbol}`]])
- }) 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 holdings = (Array.isArray(d.holdings) ? d.holdings : []).map(asObj)
- const rows = holdings.map((h) => [
- String(h.symbol ?? ""),
- h.balanceUnavailable ? "unavailable" : 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}`]
- for (const h of holdings) {
- if (h.balanceUnavailable) lines.push(`${warn()} ${String(h.symbol ?? "")} balance unavailable (${String(h.reason ?? "")})`)
- }
- if (d.priceUnavailable) lines.push(`${warn()} price warning (${String(d.priceReason ?? "")})`)
- return lines.join("\n")
- }) satisfies TextFormatter,
-
- tokenBalance: ((data, ctx) => {
- const d = asObj(data)
- return query([identity(ctx, d.address), ["Name", String(d.name ?? "")], ["Symbol", String(d.symbol ?? "")], ["Balance", humanBalance(d)]])
- }) 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, ctx?: TextRenderContext) => renderTxReceipt(r, ctx)) satisfies TextFormatter,
- txStatus: ((r) => {
- // `state` is computed by the command (tron: getTransactionById + receipt result) — no family branch.
- const status = {
- confirmed: `confirmed ${ok()}`,
- failed: `failed ${fail()}`,
- pending: `pending ${pending()}`,
- not_found: `not found ${unknown()}`,
- }[r.state]
- return query([
- ["TxID", r.txid],
- ["Status", status],
- ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`],
- ])
- }) satisfies TextFormatter,
- txInfo: ((r, ctx) => {
- return query(FAMILY_RENDER[renderFamily(ctx)].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,
+ ...WalletFormatters,
+ ...AccountFormatters,
+ ...TxFormatters,
+ ...StakeFormatters,
+ ...VoteFormatters,
+ ...RewardFormatters,
+ ...ChainFormatters,
+ ...MiscFormatters,
}
export function renderGenericText(command: string, net: NetworkDescriptor | undefined, data: unknown): string {
@@ -246,389 +55,3 @@ export function renderGenericText(command: string, net: NetworkDescriptor | unde
}
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")
-}
-
-/** Tree view: each seed wallet is a group headed by its seed id (the `--seed` handle for `derive`),
- * its accounts listed under `├─/└─` connectors as `[index] label`. Non-HD accounts group by type.
- * Plain text only — the text-mode frame is control-byte-stripped (CLI-OUT-001) so ANSI colour
- * can't survive here anyway; the active account is marked with a trailing `(active)`. */
-function renderWalletList(items: Obj[]): string {
- if (items.length === 0) return "No wallets found."
- // group seeds by their seed id (wlt_x); non-HD accounts by type. Insertion order preserved.
- const groups = new Map()
- for (const d of items) {
- const isSeed = d.type === "seed"
- const seedId = String(d.accountId ?? "").split(".")[0] ?? ""
- const key = isSeed ? `hd:${seedId}` : `type:${String(d.type)}`
- const header = isSeed ? `HD ${seedId}` : typeLabel(d.type)
- ;(groups.get(key) ?? groups.set(key, { hd: isSeed, header, rows: [] }).get(key)!).rows.push(d)
- }
- const leftOf = (d: Obj): string => (d.type === "seed" ? `[${d.index ?? "?"}] ${displayName(d)}` : displayName(d))
- const leftW = Math.max(...items.map((d) => leftOf(d).length))
- const addrW = Math.max(...items.map((d) => firstAddress(d).length))
- const row = (d: Obj, last: boolean): string =>
- `${last ? "└─ " : "├─ "}${leftOf(d).padEnd(leftW)} ${firstAddress(d).padEnd(addrW)} ${d.active ? "(active)" : ""}`.replace(/\s+$/, "")
- const blocks: string[] = []
- for (const g of groups.values()) {
- const rows = g.hd ? [...g.rows].sort((a, b) => Number(a.index) - Number(b.index)) : g.rows
- blocks.push([g.header, ...rows.map((d, i) => row(d, i === rows.length - 1))].join("\n"))
- }
- return blocks.join("\n\n")
-}
-
-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"
- const amount = safeUnsignedBigInt(f.amount ?? 0)
- // An unsafe JS number has already lost precision. Omit the summary instead of presenting a
- // plausible but incorrect total; the raw account payload remains available in JSON mode.
- if (amount === null) return ""
- sums.set(r, (sums.get(r) ?? 0n) + amount)
- }
- const total = RESOURCES.reduce((t, r) => t + (sums.get(r) ?? 0n), 0n)
- if (total === 0n) return ""
- const parts = RESOURCES.map((r) => `${r} ${formatSun(sums.get(r) ?? 0n)}`).join(" + ")
- return `${formatSun(total)} TRX (${parts})`
-}
-
-function safeUnsignedBigInt(value: unknown): bigint | null {
- if (typeof value === "bigint") return value >= 0n ? value : null
- if (typeof value === "number") {
- return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null
- }
- if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value)
- return null
-}
-
-function 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`; the active family comes from `ctx.net` (see renderFamily) — no
- * `family` in the payload, no stringly command-id matching, no alias probing. */
-function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string {
- const family = renderFamily(ctx)
- if (r.mode === "dry-run") {
- return receipt(pending(), `Dry run ${actionLabel(r.kind)}`, [
- ["Fee", formatFee(r.fee, family)],
- ["Tx", summarizeTx(r.tx)],
- ])
- }
- if (r.mode === "sign-only") {
- return receipt(ok(), `Signed ${actionLabel(r.kind)}`, [
- ["Fee", formatFee(r.fee, family)],
- ["Signed", summarizeTx(r.signed)],
- ])
- }
- const txid = String(r.txId ?? r.hash ?? "")
- const stage = r.stage ?? "submitted"
- const summary = receiptSummary(r, family)
- 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)
- const networkFlag = ctx?.net ? ` --network ${ctx.net.id}` : ""
- return txid ? `${body}\n! Track it: wallet-cli tx info${networkFlag} --txid ${txid}` : body
- }
-
- // confirmed / failed (after --wait): real on-chain block / fee / energy / result.
- if (r.blockNumber !== undefined && r.blockNumber !== null) pairs.push(["Block", `#${formatInt(r.blockNumber)}`])
- if (r.energyUsed !== undefined && r.energyUsed !== null) pairs.push(["Energy", formatInt(r.energyUsed)])
- if (r.feeSun !== undefined && r.feeSun !== null) pairs.push(["Fee", `${formatSun(r.feeSun)} TRX`])
- 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, family: ChainFamily): 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, family)
- 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, family: ChainFamily): 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[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`
-}
-
-/** the active chain family for a chain-command renderer. Chain commands always resolve a network
- * before running, so `ctx.net` is present; the tron fallback only guards a shape that can't occur. */
-function renderFamily(ctx?: TextRenderContext): ChainFamily {
- return ctx?.net?.family ?? "tron"
-}
-
-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/misc.ts b/ts/src/adapters/inbound/cli/render/misc.ts
new file mode 100644
index 00000000..ed794524
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/render/misc.ts
@@ -0,0 +1,94 @@
+import type { TextFormatter } from "../contracts/index.js"
+import { formatScalar, formatInt, formatUtc, num, methodName } from "./scalars.js"
+import { type Obj, type Pair, asObj, kv, query, receipt, table, ok } from "./layout.js"
+
+export const MiscFormatters = {
+ config: ((data) => renderConfig(asObj(data))) satisfies TextFormatter,
+ networks: ((data) =>
+ table(
+ ["Network", "Family", "Chain", "Fee model"],
+ (Array.isArray(data) ? data : []).map(asObj).map((n) => [String(n.id ?? ""), String(n.family ?? ""), String(n.chainId ?? ""), String(n.feeModel ?? "")]),
+ )) 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,
+}
+
+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)
+}
+
+function formatResult(v: unknown): string {
+ if (Array.isArray(v)) return v.map((x) => formatScalar(x)).join(", ")
+ return formatScalar(v)
+}
diff --git a/ts/src/adapters/inbound/cli/render/reward.ts b/ts/src/adapters/inbound/cli/render/reward.ts
new file mode 100644
index 00000000..c39d8712
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/render/reward.ts
@@ -0,0 +1,23 @@
+import type { TextFormatter } from "../contracts/index.js"
+import { formatAtWithRelative, formatSun } from "./scalars.js"
+import { type Obj, asObj, query } from "./layout.js"
+
+export const RewardFormatters = {
+ rewardBalance: ((data, ctx) => {
+ const d = asObj(data)
+ return query([
+ ctx.accountLabel ? ["Label", ctx.accountLabel] : ["Address", String(d.address ?? "")],
+ ["Claimable", `${formatSun(d.rewardSun)} TRX`],
+ ["Withdraw status", withdrawStatus(d)],
+ ])
+ }) satisfies TextFormatter,
+}
+
+function withdrawStatus(d: Obj): string {
+ if (d.withdrawableNow === true || d.withdrawableAt === null || d.withdrawableAt === undefined) {
+ return "available now"
+ }
+ const at = Number(d.withdrawableAt)
+ if (!Number.isFinite(at) || at <= 0) return "available now"
+ return `available from ${formatAtWithRelative(at)}`
+}
diff --git a/ts/src/adapters/inbound/cli/render/scalars.ts b/ts/src/adapters/inbound/cli/render/scalars.ts
index 8bedfbf6..8bab4484 100644
--- a/ts/src/adapters/inbound/cli/render/scalars.ts
+++ b/ts/src/adapters/inbound/cli/render/scalars.ts
@@ -17,6 +17,15 @@ export function formatInt(v: unknown): string {
return Number.isFinite(n) ? Math.trunc(n).toLocaleString("en-US") : String(v ?? "");
}
+/** Group a decimal string's integer part without coercing or dropping fractional digits. */
+export function formatDecimal(v: unknown): string {
+ const raw = String(v ?? "");
+ const match = /^(-?)(\d+)(\.\d+)?$/.exec(raw);
+ if (!match) return raw;
+ const [, sign, integer, fraction = ""] = match;
+ return `${sign}${integer!.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}${fraction}`;
+}
+
export function formatUsd(v: unknown): string {
const n = Number(v);
return Number.isFinite(n) ? n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(v ?? "");
@@ -37,6 +46,23 @@ export function formatTime(v: unknown): string {
return `${mm}-${dd} ${hh}:${mi}`;
}
+/** epoch-ms → "YYYY-MM-DD HH:MM (in ~3 days)" / "(~2h ago)" — local time + a coarse relative hint.
+ * Shared by the reward / stake / delegated views so time reads consistently across the CLI.
+ * `now` is injectable for deterministic tests. Empty string for a missing/non-positive value. */
+export function formatAtWithRelative(v: unknown, now: number = Date.now()): string {
+ const n = Number(v);
+ if (!Number.isFinite(n) || n <= 0) return "";
+ const d = new Date(n);
+ const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+ const at = `${date} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+ const delta = n - now;
+ const mag = Math.abs(delta);
+ const unit = mag >= 86_400_000
+ ? `${Math.round(mag / 86_400_000)} day(s)`
+ : mag >= 3_600_000 ? `${Math.round(mag / 3_600_000)}h` : `${Math.max(1, Math.round(mag / 60_000))}m`;
+ return `${at} (${delta >= 0 ? `in ~${unit}` : `~${unit} ago`})`;
+}
+
/** block timestamp -> "YYYY-MM-DD HH:MM:SS UTC". */
export function formatUtc(v: unknown): string {
const n = Number(v);
@@ -58,6 +84,11 @@ export function quote(s: string): string {
return `"${s}"`;
}
+/** contract method display name: strip the signature's parameter list, e.g. "transfer(address,uint256)" -> "transfer". */
+export function methodName(sig: string): string {
+ return sig.replace(/\(.*/, "") || sig;
+}
+
// Neutralize terminal control-sequence injection from untrusted labels / remote metadata.
// Strips C0 (except the newline layout uses for line breaks), DEL, and C1 bytes: removing the
// ESC (0x1B) and C1 introducers degrades any ANSI CSI / OSC payload to harmless literal text.
diff --git a/ts/src/adapters/inbound/cli/render/stake.ts b/ts/src/adapters/inbound/cli/render/stake.ts
new file mode 100644
index 00000000..a75929b2
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/render/stake.ts
@@ -0,0 +1,54 @@
+import type { TextFormatter } from "../contracts/index.js"
+import { formatAtWithRelative, formatDecimal, formatInt, formatSun } from "./scalars.js"
+import { type Pair, asObj, kv, query } from "./layout.js"
+
+const trx = (sun: unknown) => `${formatDecimal(formatSun(sun))} TRX`
+
+export const StakeFormatters = {
+ stakeInfo: ((data, ctx) => {
+ const d = asObj(data)
+ const staked = asObj(d.staked); const vp = asObj(d.votingPower)
+ const res = asObj(d.resource); const energy = asObj(res.energy); const bandwidth = asObj(res.bandwidth)
+ const unfreeze = asObj(d.unfreeze)
+ const unfreezing = Array.isArray(d.unfreezing) ? d.unfreezing.map(asObj) : []
+ const totalStaked = BigInt(String(staked.energySun ?? "0")) + BigInt(String(staked.bandwidthSun ?? "0"))
+ const lines: Pair[] = [
+ ["Label", ctx.accountLabel ?? ""],
+ ["Staked", `${trx(totalStaked.toString())} (for energy ${trx(staked.energySun)} + for bandwidth ${trx(staked.bandwidthSun)})`],
+ ["Voting power", `${formatInt(vp.total)} TP (used ${formatInt(vp.used)} / available ${formatInt(vp.available)})`],
+ ["Energy", `used ${formatInt(energy.used)} / ${formatInt(energy.limit)}`],
+ ["Bandwidth", `used ${formatInt(bandwidth.used)} / ${formatInt(bandwidth.limit)}`],
+ ["Unfreezing", `${unfreezing.length} pending (max ${formatInt(unfreeze.max)} at a time, ${formatInt(unfreeze.remaining)} more allowed)`],
+ ]
+ const body = query(lines)
+ const pendings = unfreezing.map((u, i) => ` ${i + 1}) ${trx(u.amountSun)} withdrawable ${formatAtWithRelative(u.withdrawableAt)}`)
+ const tail = kv([["Withdrawable", `${trx(d.withdrawableSun)} now`]], "")
+ return [body, ...pendings, tail].join("\n")
+ }) satisfies TextFormatter,
+
+ stakeDelegated: ((data, ctx) => {
+ const d = asObj(data)
+ const out = d.direction === "out"
+ const rows = (Array.isArray(d.delegations) ? d.delegations.map(asObj) : [])
+ const head = query([
+ ["Label", ctx.accountLabel ?? ""],
+ ["Direction", out ? "out (delegated to others)" : "in (delegated to me)"],
+ ])
+ const sections: string[] = [head]
+ if (out && d.canDelegateMaxSun) {
+ const max = asObj(d.canDelegateMaxSun)
+ sections.push("", "Max delegatable", kv([
+ ["Energy", `${trx(max.energy)}`],
+ ["Bandwidth", `${trx(max.bandwidth)}`],
+ ], " "))
+ }
+ const lockCol = out ? "Locked until" : "Guaranteed until"
+ const lockText = (v: unknown) => v ? formatAtWithRelative(v) : out ? "not locked" : "none — reclaimable anytime"
+ const table = rows.map((r) => [String(out ? r.receiver : r.from), String(r.resource), trx(r.amountSun), lockText(r.lockedUntil)])
+ const header = [out ? "Receiver" : "From", "Resource", "Amount", lockCol]
+ const widths = header.map((h, i) => Math.max(h.length, ...table.map((row) => row[i]!.length)))
+ const fmtRow = (row: string[]) => ` ${row.map((c, i) => c.padEnd(widths[i]!)).join(" ")}`.trimEnd()
+ sections.push("", `Delegations (${rows.length})`, fmtRow(header), ...table.map(fmtRow))
+ return sections.join("\n")
+ }) satisfies TextFormatter,
+}
diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts
new file mode 100644
index 00000000..ba71e89a
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/render/tx.ts
@@ -0,0 +1,217 @@
+import type { 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 { fromBaseUnits } from "../../../../domain/amounts/index.js"
+import { formatScalar, formatInt, formatSun, num, shorten, methodName } from "./scalars.js"
+import { type Pair, asObj, query, receipt, ok, fail, pending, unknown } from "./layout.js"
+import { FAMILY_RENDER, renderFamily } from "./family.js"
+
+export const TxFormatters = {
+ txReceipt: ((r, ctx?: TextRenderContext) => renderTxReceipt(r, ctx)) satisfies TextFormatter,
+ txStatus: ((r) => {
+ // `state` is computed by the command (tron: getTransactionById + receipt result) — no family branch.
+ const status = {
+ confirmed: `confirmed ${ok()}`,
+ failed: `failed ${fail()}`,
+ pending: `pending ${pending()}`,
+ not_found: `not found ${unknown()}`,
+ }[r.state]
+ return query([
+ ["TxID", r.txid],
+ ["Status", status],
+ ["Block", r.blockNumber === undefined ? "" : `#${formatInt(r.blockNumber)}`],
+ ])
+ }) satisfies TextFormatter,
+ txInfo: ((r, ctx) => {
+ return query(FAMILY_RENDER[renderFamily(ctx)].txInfoRows(r))
+ }) satisfies TextFormatter,
+}
+
+/** Default-mode broadcast/dry-run/sign-only receipt for tx/stake/contract signing commands.
+ * Narrows on the typed `kind`; the active family comes from `ctx.net` (see renderFamily) — no
+ * `family` in the payload, no stringly command-id matching, no alias probing. */
+function renderTxReceipt(r: TxReceiptView, ctx?: TextRenderContext): string {
+ const family = renderFamily(ctx)
+ if (r.mode === "dry-run") {
+ return receipt(pending(), `Dry run ${actionLabel(r.kind)}`, [
+ ["Fee", formatFee(r.fee, family)],
+ ["Tx", summarizeTx(r.tx)],
+ ])
+ }
+ if (r.mode === "sign-only") {
+ return receipt(ok(), `Signed ${actionLabel(r.kind)}`, [
+ ["Fee", formatFee(r.fee, family)],
+ ["Signed", summarizeTx(r.signed)],
+ ])
+ }
+ const txid = String(r.txId ?? r.hash ?? "")
+ const stage = r.stage ?? "submitted"
+ const summary = receiptSummary(r, family)
+ 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", submittedStatus(r.kind)])
+ const body = receipt(pending(), summary, pairs)
+ const networkFlag = ctx?.net ? ` --network ${ctx.net.id}` : ""
+ return txid ? `${body}\n! Track it: wallet-cli tx info${networkFlag} --txid ${txid}` : body
+ }
+
+ // confirmed / failed (after --wait): real on-chain block / fee / energy / result.
+ if (r.blockNumber !== undefined && r.blockNumber !== null) pairs.push(["Block", `#${formatInt(r.blockNumber)}`])
+ if (r.energyUsed !== undefined && r.energyUsed !== null) pairs.push(["Energy", formatInt(r.energyUsed)])
+ if (r.feeSun !== undefined && r.feeSun !== null) pairs.push(["Fee", `${formatSun(r.feeSun)} TRX`])
+ 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", successStatus(r.kind)])
+ return receipt(ok(), summary, pairs)
+}
+
+function submittedStatus(kind: TxReceiptKind): string {
+ switch (kind) {
+ case "vote-cast":
+ return "pending — tallied at next maintenance cycle (~6h)"
+ case "reward-withdraw":
+ return "pending — next withdrawal available in ~24h"
+ default:
+ return "pending — not yet on-chain"
+ }
+}
+
+function successStatus(kind: TxReceiptKind): string {
+ switch (kind) {
+ case "vote-cast":
+ return "success — tallied at next maintenance cycle (~6h)"
+ case "reward-withdraw":
+ return "success — next withdrawal available in ~24h"
+ default:
+ return "success"
+ }
+}
+
+/** the verb-phrase summary for a broadcast receipt, by action kind. */
+function receiptSummary(r: TxReceiptView, family: ChainFamily): 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 "vote-cast": {
+ const count = Array.isArray(r.votes) ? r.votes.length : 0
+ const across = `across ${formatInt(count)} witness${count === 1 ? "" : "es"}`
+ return r.totalVotes === undefined ? `Voted ${across}` : `Voted ${formatInt(r.totalVotes)} TP ${across}`
+ }
+ case "reward-withdraw":
+ return "Withdrew voting/block rewards"
+ case "send": {
+ const amount = receiptAmount(r, family)
+ 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.kind === "vote-cast" && Array.isArray(r.votes)) rows.push(["Votes", r.votes.map((vote) => `${vote.witness}=${formatInt(vote.count)}`).join(", ")])
+ else if (r.kind === "reward-withdraw") rows.push(["Amount", `${formatSun(r.rewardSun ?? r.withdrawnSun ?? 0)} TRX`])
+ 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, family: ChainFamily): 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[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"
+ case "vote-cast":
+ return "vote cast"
+ case "reward-withdraw":
+ return "reward withdraw"
+ }
+}
+
+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/vote.ts b/ts/src/adapters/inbound/cli/render/vote.ts
new file mode 100644
index 00000000..097c9652
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/render/vote.ts
@@ -0,0 +1,58 @@
+import type { TextFormatter, TextRenderContext } from "../contracts/index.js"
+import { formatInt, formatSun } from "./scalars.js"
+import { type Obj, asObj, query, table } from "./layout.js"
+
+export const VoteFormatters = {
+ voteList: ((data) => {
+ const witnesses = rowsOf(asObj(data).witnesses)
+ return table(["Rank", "Name", "Votes", "APR", "Reward ratio", "Address"], witnesses.map((w) => [
+ formatInt(w.rank),
+ String(w.name ?? ""),
+ formatInt(w.voteCount),
+ pct(w.aprPct),
+ pct(w.rewardRatioPct),
+ String(w.address ?? ""),
+ ]))
+ }) satisfies TextFormatter,
+ voteStatus: ((data, ctx) => renderVoteStatus(asObj(data), ctx)) satisfies TextFormatter,
+}
+
+function renderVoteStatus(data: Obj, ctx: TextRenderContext): string {
+ const votingPower = asObj(data.votingPower)
+ const votes = rowsOf(data.votes)
+ const lines = [
+ query([
+ ctx.accountLabel ? ["Label", ctx.accountLabel] : ["Address", String(data.address ?? "")],
+ ["Voting power", `${formatInt(votingPower.total)} TP (used ${formatInt(votingPower.used)} / available ${formatInt(votingPower.available)})`],
+ ["Claimable", `${formatSun(data.claimableRewardSun)} TRX`],
+ ]),
+ "",
+ `Current votes (${votes.length})`,
+ table(["Name", "Votes", "APR", "Reward ratio", "Address"], votes.map((vote) => [
+ String(vote.name ?? ""),
+ formatInt(vote.count),
+ pct(vote.aprPct),
+ pct(vote.rewardRatioPct),
+ String(vote.witness ?? ""),
+ ])),
+ ]
+ // votes on a 0%-reward-ratio SR earn nothing — surface a `!` line straight from the data
+ // (same style as tx.ts's `! Track it:`); the json warning is emitted separately via scope.warn.
+ for (const vote of votes) {
+ if (vote.rewardRatioPct === 0) {
+ lines.push(`! ${formatInt(vote.count)} votes on ${String(vote.name ?? vote.witness ?? "")} earn nothing — 0% reward ratio`)
+ }
+ }
+ return lines.join("\n")
+}
+
+function rowsOf(value: unknown): Obj[] {
+ return (Array.isArray(value) ? value : []).map(asObj)
+}
+
+function pct(value: unknown): string {
+ if (value === null || value === undefined) return "—"
+ const n = Number(value)
+ if (!Number.isFinite(n)) return "—"
+ return `${String(n)}%`
+}
diff --git a/ts/src/adapters/inbound/cli/render/wallet.ts b/ts/src/adapters/inbound/cli/render/wallet.ts
new file mode 100644
index 00000000..6677c5fe
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/render/wallet.ts
@@ -0,0 +1,166 @@
+import type { TextFormatter } from "../contracts/index.js"
+import { sourceLabel } from "../../../../domain/sources/index.js"
+import { quote } from "./scalars.js"
+import { type Obj, type Pair, asObj, receipt, titled, ok, warn } from "./layout.js"
+import { familyAddressLabel } from "./family.js"
+
+export const WalletFormatters = {
+ 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)
+ const scope = d.scope === "account" ? "account" : "wallet"
+ return receipt(ok(), `Deleted ${scope} ${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,
+ passwordChanged: ((data) => {
+ const d = asObj(data)
+ const list = Array.isArray(d.wallets) ? d.wallets.map(String).join(", ") : ""
+ return receipt(ok(), `Master password changed — re-encrypted ${String(d.count ?? 0)} software wallet(s)`, [
+ ["Wallets", list],
+ ["Note", "Ledger / watch-only accounts are unaffected"],
+ ])
+ }) satisfies TextFormatter,
+}
+
+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")
+}
+
+/** Tree view: each seed wallet is a group headed by its seed id (the `--seed` handle for `derive`),
+ * its accounts listed under `├─/└─` connectors as `[index] label`. Non-HD accounts group by type.
+ * Plain text only — the text-mode frame is control-byte-stripped (CLI-OUT-001) so ANSI colour
+ * can't survive here anyway; the active account is marked with a trailing `(active)`. */
+function renderWalletList(items: Obj[]): string {
+ if (items.length === 0) return "No wallets found."
+ // group seeds by their seed id (wlt_x); non-HD accounts by type. Insertion order preserved.
+ const groups = new Map()
+ for (const d of items) {
+ const isSeed = d.type === "seed"
+ const seedId = String(d.accountId ?? "").split(".")[0] ?? ""
+ const key = isSeed ? `hd:${seedId}` : `type:${String(d.type)}`
+ const header = isSeed ? `HD ${seedId}` : typeLabel(d.type)
+ ;(groups.get(key) ?? groups.set(key, { hd: isSeed, header, rows: [] }).get(key)!).rows.push(d)
+ }
+ const leftOf = (d: Obj): string => (d.type === "seed" ? `[${d.index ?? "?"}] ${displayName(d)}` : displayName(d))
+ const leftW = Math.max(...items.map((d) => leftOf(d).length))
+ const addrW = Math.max(...items.map((d) => firstAddress(d).length))
+ const row = (d: Obj, last: boolean): string =>
+ `${last ? "└─ " : "├─ "}${leftOf(d).padEnd(leftW)} ${firstAddress(d).padEnd(addrW)} ${d.active ? "(active)" : ""}`.replace(/\s+$/, "")
+ const blocks: string[] = []
+ for (const g of groups.values()) {
+ const rows = g.hd ? [...g.rows].sort((a, b) => Number(a.index) - Number(b.index)) : g.rows
+ blocks.push([g.header, ...rows.map((d, i) => row(d, i === rows.length - 1))].join("\n"))
+ }
+ return blocks.join("\n\n")
+}
+
+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 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")
+ }
+}
diff --git a/ts/src/adapters/inbound/cli/shell/index.ts b/ts/src/adapters/inbound/cli/shell/index.ts
index e08b5fae..8421a41a 100644
--- a/ts/src/adapters/inbound/cli/shell/index.ts
+++ b/ts/src/adapters/inbound/cli/shell/index.ts
@@ -5,9 +5,10 @@
*/
import yargs, { type Argv } from "yargs"
import { randomBytes } from "node:crypto"
-import { z } from "zod"
+import { z, type RefinementCtx, type ZodObject, type ZodRawShape, type ZodType } from "zod"
import type { AccountDescriptor, NetworkDescriptor } from "../../../../domain/types/index.js";
-import type { CommandDefinition, ExecutionContext, Globals, SessionRef, StreamManager } from "../contracts/index.js";
+import { isChainCommand } from "../contracts/index.js";
+import type { ChainCommandDefinition, ChainSpec, CommandDefinition, CommandExecutionSpec, ExecutionContext, Globals, SessionRef, StreamManager } from "../contracts/index.js";
import { CommandRegistry } from "../registry/index.js"
import { CapabilityRegistry } from "../../../../application/services/capability/index.js"
import { buildExecutionContext, type RuntimeDeps } from "../context/index.js"
@@ -37,7 +38,7 @@ const GLOBAL_OPTS = globalYargsOptions()
// Interactivity (password/secret prompts, gap-fill, account select, delete confirm) is declared
// per command via `interactive`. Every other command runs as if non-TTY: missing input fails fast
// rather than prompting — safer for scripts/agents.
-export function isInteractiveCommand(cmd: CommandDefinition): boolean {
+export function isInteractiveCommand(cmd: Pick): boolean {
return cmd.interactive === true
}
@@ -53,9 +54,9 @@ export function buildCli(opts: ShellOptions): Argv {
.options(GLOBAL_OPTS as any)
const all = opts.registry.all()
- // Two kinds, discriminated by family: neutral (full path) vs chain (logical path + --network).
+ // Two kinds: neutral (full path) vs chain (logical path + family binding chosen by --network).
const neutralByHead = new Map()
- for (const c of all.filter((c) => !c.family)) {
+ for (const c of all.filter((c): c is CommandDefinition => !isChainCommand(c))) {
const head = c.path[0]!
const bucket = neutralByHead.get(head) ?? []
bucket.push(c)
@@ -86,16 +87,22 @@ export function buildCli(opts: ShellOptions): Argv {
}
}
- const chainCommands = all.filter((c) => c.family)
- const chainGroups = [...new Set(chainCommands.map((c) => c.path[0]).filter(Boolean) as string[])]
- const fieldsOfLogicalGroup = (group: string) => chainCommands.filter((c) => c.path[0] === group).map((c) => c.fields)
+ const assembledChainCommands = all.filter(isChainCommand)
+ const chainGroups = [...new Set(assembledChainCommands.map((c) => c.spec.path[0]).filter(Boolean) as string[])]
+ const fieldsOfLogicalGroup = (group: string) => [
+ ...assembledChainCommands.filter((c) => c.spec.path[0] === group).flatMap((c) => [
+ c.spec.baseFields,
+ ...Object.values(c.families).flatMap((binding) => binding?.fields ? [binding.fields] : []),
+ ]),
+ ]
for (const group of chainGroups) {
- const leaves = chainCommands.filter((c) => c.path[0] === group)
- if (leaves.every((c) => c.path.length === 1)) {
+ const assembledLeaves = assembledChainCommands.filter((c) => c.spec.path[0] === group)
+ const paths = assembledLeaves.map((c) => c.spec.path)
+ if (paths.every((path) => path.length === 1)) {
// single-segment chain leaf (e.g. `block []`): no sub-verb; bind its own positional.
cli.command(
`${group} [number]`,
- leaves[0]?.summary ?? "",
+ assembledLeaves[0]?.spec.summary ?? "",
(y) => applyCommands(y, fieldsOfLogicalGroup(group)),
(argv) => {
// a non-numeric positional (e.g. `block get`) is a mistyped subcommand, not a block height:
@@ -141,22 +148,49 @@ async function dispatchNeutral(opts: ShellOptions, path: string[], argv: any): P
}
async function dispatchLogical(opts: ShellOptions, path: string[], argv: any): Promise {
- const { registry, globals, targetResolver } = opts
- const candidates = registry.resolveCandidates(path)
- if (candidates.length === 0) {
- throw new UsageError("unknown_command", `unknown command: ${path.join(" ")}`)
- }
+ const chain = opts.registry.resolveChain(path)
+ if (chain) return executeChainCommand(opts, chain, argv)
+ throw new UsageError("unknown_command", `unknown command: ${path.join(" ")}`)
+}
+
+async function executeChainCommand(opts: ShellOptions, def: ChainCommandDefinition, argv: any): Promise {
+ const { globals, deps, targetResolver, caps, streams, formatter, session } = opts
+ const { spec } = def
+ const id = commandId({ path: spec.path })
+ session.current = { commandId: id }
- const { network } = targetResolver.resolveNetwork(globals)
- const cmd = candidates.find((c) => c.family === network.family)
- if (!cmd) {
- const families = [...new Set(candidates.map((c) => c.family).filter(Boolean))].join(", ")
+ const target = targetResolver.resolve(spec, globals)
+ const net = requireResolvedNetwork(spec, target.network)
+ const binding = def.families[net.family]
+ if (!binding) {
+ const families = Object.keys(def.families).join(", ")
throw new UsageError(
"network_family_mismatch",
- `command ${path.join(" ")} supports ${families || "no chain"} but selected network ${network.id} is ${network.family}`,
+ `command ${spec.path.join(" ")} supports ${families} but selected network ${net.id} is ${net.family}`,
)
}
- await executeCommand(opts, cmd, argv)
+ session.current = { commandId: id, net }
+
+ const effectiveFields = binding.fields ? spec.baseFields.extend(binding.fields.shape) : spec.baseFields
+ const effectiveInput = composeRefines(effectiveFields, spec.baseRefine, binding.refine)
+ const executionSpec = withFields(spec, effectiveFields)
+ assertKnownFlags(executionSpec, argv)
+ deps.prompter.setInteractive(isInteractiveCommand(spec))
+ caps.check(spec, net)
+
+ if (spec.passwordMode) {
+ const initialized = deps.keystore.isInitialized()
+ const mode = spec.passwordMode === "establish" ? (initialized ? "verify" : "set") : "verify"
+ await deps.secrets.primePassword({ mode, verify: (pw) => deps.keystore.verifyPassword(pw) })
+ }
+
+ await gapFillRequiredFields(executionSpec, argv, deps.prompter, () => deps.keystore.list().map((d) => ({ value: d.accountId, label: accountChoiceLabel(d) })))
+ const input = parseInputSchema(effectiveInput, argv)
+
+ const ctx = buildExecutionContext(globals, deps)
+ if (spec.wallet !== "none") void ctx.activeAccount
+ const data = await binding.run(ctx, net, input)
+ streams.result(formatter.success(id, net, data, spec.formatText, activeAccountLabel(spec, ctx, deps)))
}
async function executeCommand(opts: ShellOptions, cmd: CommandDefinition, argv: any): Promise {
@@ -174,6 +208,18 @@ async function executeCommand(opts: ShellOptions, cmd: CommandDefinition, argv:
caps.check(cmd, net)
+ // secretsTtyOnly (import mnemonic/private-key, change-password): the most sensitive setup ops
+ // read every secret interactively only. Reject any stdin password source and require a real TTY
+ // BEFORE priming, so no secret is ever taken from a pipe and the error is honest.
+ if (cmd.secretsTtyOnly) {
+ if (deps.secrets.has("password")) {
+ throw new UsageError("invalid_option", `${commandId(cmd)} reads the master password interactively only; --password-stdin is not accepted`)
+ }
+ if (!deps.prompter.isTTY()) {
+ throw new UsageError("tty_required", `${commandId(cmd)} reads secrets interactively; run in a terminal`)
+ }
+ }
+
// auth: opt-in interactive password priming via passwordMode. Commands without passwordMode
// (tx/contract/stake/message/derive) demand the password LAZILY — the keystore throws
// auth_required only when a sign/decrypt actually happens, so read-only paths like
@@ -192,15 +238,13 @@ async function executeCommand(opts: ShellOptions, cmd: CommandDefinition, argv:
const ctx = buildExecutionContext(globals, deps)
if (cmd.wallet !== "none") void ctx.activeAccount // resolve account (default active) up front; throws missing_wallet_address if none exists
- const data = cmd.network === "none"
- ? await cmd.run(ctx, undefined, input)
- : await cmd.run(ctx, requireResolvedNetwork(cmd, net), input)
- streams.result(formatter.success(cmd, net, data, activeAccountLabel(cmd, ctx, deps)))
+ const data = await cmd.run(ctx, undefined, input)
+ streams.result(formatter.success(commandId(cmd), net, data, cmd.formatText, activeAccountLabel(cmd, ctx, deps)))
}
/** Runtime counterpart to CommandDefinition's network-policy discrimination. */
function requireResolvedNetwork(
- cmd: CommandDefinition,
+ cmd: Pick,
net: NetworkDescriptor | undefined,
): NetworkDescriptor {
if (net) return net
@@ -210,7 +254,7 @@ function requireResolvedNetwork(
/** Central account-label resolution for text receipts: the user's --account label (or active
* account's label) so command formatters can show "main" instead of a shortened address.
* Best-effort — wallet:"none" commands have no account, and resolution never fails the command. */
-function activeAccountLabel(cmd: CommandDefinition, ctx: ExecutionContext, deps: RuntimeDeps): string | undefined {
+function activeAccountLabel(cmd: Pick, ctx: ExecutionContext, deps: RuntimeDeps): string | undefined {
if (cmd.wallet === "none") return undefined
try {
return deps.keystore.describe(ctx.activeAccount).label
@@ -237,7 +281,7 @@ function accountChoiceLabel(d: AccountDescriptor): string {
* zod.
*/
export async function gapFillRequiredFields(
- cmd: CommandDefinition,
+ cmd: Pick,
argv: any,
prompter: Pick,
accountChoices?: () => Array<{ value: string; label: string }>,
@@ -291,7 +335,7 @@ function randomWalletLabel(): string {
* and zod would silently strip them). Allowed = positionals + globals + THIS command's fields
* (a sibling command's flag in the same namespace is unknown here). → invalid_option, exit 2.
*/
-function assertKnownFlags(cmd: CommandDefinition, argv: any): void {
+function assertKnownFlags(cmd: Pick, argv: any): void {
const allowed = new Set(["_", "$0", "group", "verb", "source", "key", "value"])
const add = (name: string) => {
allowed.add(name)
@@ -309,7 +353,11 @@ function assertKnownFlags(cmd: CommandDefinition, argv: any): void {
}
function parseInput(cmd: CommandDefinition, argv: any): unknown {
- const result = (cmd.input as z.ZodType).safeParse(argv)
+ return parseInputSchema(cmd.input, argv)
+}
+
+function parseInputSchema(schema: ZodType, argv: any): unknown {
+ const result = schema.safeParse(argv)
if (result.success) return result.data
const issue = result.error.issues[0]!
const key = issue.path[0]
@@ -320,3 +368,18 @@ function parseInput(cmd: CommandDefinition, argv: any): unknown {
}
throw new UsageError("invalid_value", `invalid --${camelToKebab(field)}: ${issue.message}`)
}
+
+function withFields(spec: ChainSpec, fields: ZodObject): CommandExecutionSpec {
+ return { ...spec, fields }
+}
+
+function composeRefines(
+ fields: ZodObject,
+ baseRefine?: (value: any, ctx: RefinementCtx) => void,
+ familyRefine?: (value: any, ctx: RefinementCtx) => void,
+): ZodType {
+ let schema: ZodType = fields
+ if (baseRefine) schema = schema.superRefine(baseRefine)
+ if (familyRefine) schema = schema.superRefine(familyRefine)
+ return schema
+}
diff --git a/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts
new file mode 100644
index 00000000..b2fe73c7
--- /dev/null
+++ b/ts/src/adapters/inbound/cli/shell/shell.chain.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi } from "vitest";
+import { mkdtempSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { z } from "zod";
+import { buildCli, type ShellOptions } from "./index.js";
+import type { ChainSpec, SessionRef } from "../contracts/index.js";
+import { CommandRegistry } from "../registry/index.js";
+import { CapabilityRegistry } from "../../../../application/services/capability/index.js";
+import { TargetResolver } from "../../../../application/services/target/index.js";
+import { StreamManager } from "../stream/index.js";
+import { createOutputFormatter } from "../output/index.js";
+import { ConfigLoader, NetworkRegistry } from "../../../outbound/config/index.js";
+import { AtomicFileStore } from "../../../outbound/persistence/fs/index.js";
+import { Keystore } from "../../../outbound/keystore/index.js";
+import { SecretResolver } from "../input/secret/index.js";
+import { Prompter } from "../input/prompt/index.js";
+
+describe("ChainCommandDefinition dispatch", () => {
+ it("routes a positional through the selected family binding", async () => {
+ const tmpRoot = mkdtempSync(join(tmpdir(), "wallet-cli-chain-test-"));
+ const store = new AtomicFileStore();
+ const backend = {
+ isTTY: () => false,
+ async question() { return ""; },
+ async readKey() { return { name: "return" }; },
+ write() {},
+ beginRaw() {},
+ endRaw() {},
+ };
+ const prompter = new Prompter(backend);
+ const out: string[] = [];
+ const streams = new StreamManager("json", false, (s) => out.push(s));
+ const secrets = new SecretResolver(streams, {}, prompter);
+ const keystore = new Keystore(tmpRoot, store, () => secrets.masterPassword());
+ const config = ConfigLoader.load();
+ const networkRegistry = new NetworkRegistry(config);
+ const formatter = createOutputFormatter("json", streams, Date.now());
+ const registry = new CommandRegistry();
+ const spec: ChainSpec = {
+ path: ["block"],
+ network: "optional",
+ wallet: "none",
+ auth: "none",
+ positionals: [{ field: "number" }],
+ examples: [],
+ baseFields: z.object({ number: z.string().optional() }),
+ };
+ const run = vi.fn(async (_ctx, _net, input) => ({ block: { number: input.number } }));
+ registry.addChain(spec, "tron", { run });
+
+ const globals = { output: "json" as const, verbose: false, network: "tron:mainnet" };
+ const deps = { config, networkRegistry, streams, secrets, keystore, prompter, formatter };
+ const shellOpts: ShellOptions = {
+ registry,
+ globals,
+ deps,
+ targetResolver: new TargetResolver({ networkRegistry, keystore }),
+ caps: new CapabilityRegistry(),
+ streams,
+ formatter,
+ session: {} as SessionRef,
+ };
+
+ await buildCli(shellOpts).parseAsync(["block", "123"]);
+
+ expect(run).toHaveBeenCalledOnce();
+ expect(run.mock.calls[0]![2]).toMatchObject({ number: "123" });
+ expect(JSON.parse(out[0]!).data).toEqual({ block: { number: "123" } });
+ });
+});
diff --git a/ts/src/adapters/outbound/chain/tron/tron.ts b/ts/src/adapters/outbound/chain/tron/tron.ts
index 8176c884..a06e2890 100644
--- a/ts/src/adapters/outbound/chain/tron/tron.ts
+++ b/ts/src/adapters/outbound/chain/tron/tron.ts
@@ -14,10 +14,14 @@ import type {
TronContractParameter,
TronContractMetadata,
TronAccount,
+ TronDelegatedResource,
TronGateway,
+ TronNodeInfo,
TronTokenInfo,
TronTx,
TronTxInfo,
+ TronVote,
+ TronWitness,
} from "../../../../application/ports/chain/tron-gateway.js";
import { ChainError, TransportError, UsageError } from "../../../../domain/errors/index.js";
import { redactErrorMessage } from "../../../../domain/errors/redact.js";
@@ -103,7 +107,7 @@ export class TronRpcClient implements TronGateway, Broadcaster {
// ── account / query ──────────────────────────────────────────────────────────
async getAccount(address: string): Promise {
return this.#wrap("getAccount", async () => {
- const response = await fetch(`${this.#fullHost}/walletsolidity/getaccount`, {
+ const response = await fetch(`${this.#fullHost}/wallet/getaccount`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ address: this.#tw.address.toHex(address) }),
@@ -127,7 +131,10 @@ export class TronRpcClient implements TronGateway, Broadcaster {
return this.#wrap("getTransaction", async () => parseTronTx(await this.#tw.trx.getTransaction(txid)));
}
async getTransactionInfoById(txid: string): Promise {
- return this.#wrap("getTransactionInfo", async () => parseTronTxInfo(await this.#tw.trx.getTransactionInfo(txid)));
+ // Full-node (unconfirmed) info: available ~one block after inclusion (~3s), not after
+ // solidification (~19 blocks / ~60s). So `--wait` confirms at "mined in a block" rather than
+ // "irreversible" — the receipt (fee/energy/result) is already final by then. Same response shape.
+ return this.#wrap("getTransactionInfo", async () => parseTronTxInfo(await this.#tw.trx.getUnconfirmedTransactionInfo(txid)));
}
decodeTransaction(transaction: TronTx): DecodedTronTransaction {
return decodeTronTransaction(transaction);
@@ -268,6 +275,64 @@ export class TronRpcClient implements TronGateway, Broadcaster {
);
}
+ // ── voting / witnesses ─────────────────────────────────────────────────────
+ async buildVoteWitness(owner: string, votes: TronVote[]): Promise {
+ const voteInfo = Object.fromEntries(
+ votes.map((vote) => [vote.witness, this.#safeNumber(vote.count, "vote count")]),
+ );
+ return this.#wrap("voteWitness", async () =>
+ assertBuiltTx(await this.#tw.transactionBuilder.vote(voteInfo, owner), "VoteWitnessContract"),
+ );
+ }
+ async buildWithdrawBalance(owner: string): Promise {
+ return this.#wrap("withdrawBlockRewards", async () =>
+ assertBuiltTx(await this.#tw.transactionBuilder.withdrawBlockRewards(owner), "WithdrawBalanceContract"),
+ );
+ }
+ async getWitnesses(limit: number): Promise {
+ const capped = Math.min(Math.max(Math.trunc(limit), 1), 127);
+ return this.#wrap("getNowWitnessList", async () => {
+ const response = await fetch(`${this.#fullHost}/wallet/getpaginatednowwitnesslist`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ offset: 0, limit: capped, visible: true }),
+ signal: AbortSignal.timeout(this.#timeoutMs),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record;
+ const witnesses = Array.isArray(raw.witnesses) ? raw.witnesses : [];
+ return witnesses.map(normalizeWitness).filter((w): w is TronWitness => w !== null);
+ });
+ }
+ async getBrokerage(address: string): Promise {
+ return this.#wrap("getBrokerage", async () => {
+ const response = await fetch(`${this.#fullHost}/wallet/getBrokerage`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ address: this.#tw.address.toHex(address) }),
+ signal: AbortSignal.timeout(this.#timeoutMs),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record;
+ if (raw.brokerage === undefined) throw new Error("brokerage not found");
+ const brokerage = Number(raw.brokerage);
+ return Number.isFinite(brokerage) ? brokerage : 0;
+ });
+ }
+ async getReward(address: string): Promise {
+ return this.#wrap("getReward", async () => {
+ const response = await fetch(`${this.#fullHost}/wallet/getReward`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ address: this.#tw.address.toHex(address) }),
+ signal: AbortSignal.timeout(this.#timeoutMs),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const raw = normalizeAccountValue(parseLosslessJson(await response.text())) as Record;
+ return quantityString(raw.reward);
+ });
+ }
+
// ── prices ─────────────────────────────────────────────────────────────────────
async getEnergyPrices(): Promise {
return this.#wrap("getEnergyPrices", () => this.#tw.trx.getEnergyPrices());
@@ -276,6 +341,53 @@ export class TronRpcClient implements TronGateway, Broadcaster {
return this.#wrap("getBandwidthPrices", () => this.#tw.trx.getBandwidthPrices());
}
+ // ── chain info ────────────────────────────────────────────────────────────────
+ async getChainParameters(): Promise> {
+ return this.#wrap("getChainParameters", () => this.#tw.trx.getChainParameters());
+ }
+ async getNodeInfo(): Promise {
+ return this.#wrap("getNodeInfo", () => this.#tw.trx.getNodeInfo() as Promise);
+ }
+
+ // ── staking queries (Stake 2.0) ───────────────────────────────────────────────
+ async getDelegatedResourceV2(from: string, to: string): Promise {
+ return this.#wrap("getDelegatedResourceV2", async () => {
+ const res = await this.#tw.trx.getDelegatedResourceV2(from, to);
+ return (res.delegatedResource ?? []).map((d) => ({
+ from: tronHexToBase58(d.from),
+ to: tronHexToBase58(d.to),
+ balanceForEnergySun: String(d.frozen_balance_for_energy ?? 0),
+ balanceForBandwidthSun: String(d.frozen_balance_for_bandwidth ?? 0),
+ expireTimeForEnergy: d.expire_time_for_energy ? Number(d.expire_time_for_energy) : null,
+ expireTimeForBandwidth: d.expire_time_for_bandwidth ? Number(d.expire_time_for_bandwidth) : null,
+ }));
+ });
+ }
+ async getDelegatedIndexV2(address: string): Promise<{ fromAccounts: string[]; toAccounts: string[] }> {
+ return this.#wrap("getDelegatedResourceAccountIndexV2", async () => {
+ const res = await this.#tw.trx.getDelegatedResourceAccountIndexV2(address);
+ return {
+ fromAccounts: (res.fromAccounts ?? []).map((a) => tronHexToBase58(a)),
+ toAccounts: (res.toAccounts ?? []).map((a) => tronHexToBase58(a)),
+ };
+ });
+ }
+ async getCanDelegatedMaxSize(address: string, resource: RpcResourceCode): Promise {
+ return this.#wrap("getCanDelegatedMaxSize", async () =>
+ String((await this.#tw.trx.getCanDelegatedMaxSize(address, resource)).max_size ?? 0),
+ );
+ }
+ async getCanWithdrawUnfreezeAmount(address: string): Promise {
+ return this.#wrap("getCanWithdrawUnfreezeAmount", async () =>
+ String((await this.#tw.trx.getCanWithdrawUnfreezeAmount(address)).amount ?? 0),
+ );
+ }
+ async getAvailableUnfreezeCount(address: string): Promise {
+ return this.#wrap("getAvailableUnfreezeCount", async () =>
+ Number((await this.#tw.trx.getAvailableUnfreezeCount(address)).count ?? 0),
+ );
+ }
+
// ── contract ──────────────────────────────────────────────────────────────────
async triggerConstantContract(
contract: string, fn: string, params: TronContractParameter[], owner = TRON_READ_OWNER,
@@ -350,11 +462,56 @@ const ACCOUNT_QUANTITY_KEYS = new Set([
"balance",
"frozen_amount",
"frozen_balance",
+ "latest_withdraw_time",
+ "reward",
"total_supply",
"unfreeze_amount",
"value",
+ "vote_count",
+ "voteCount",
]);
+function quantityString(value: unknown): string {
+ if (typeof value === "bigint") return value >= 0n ? value.toString() : "0";
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? String(value) : "0";
+ if (typeof value === "string" && /^\d+$/.test(value)) return value;
+ return "0";
+}
+
+function optionalNumber(value: unknown): number | undefined {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : undefined;
+}
+
+function decodeAsciiHex(value: unknown): string | undefined {
+ const raw = String(value ?? "");
+ const hex = raw.replace(/^0x/, "");
+ if (!hex || !/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) return raw || undefined;
+ try {
+ const text = Buffer.from(hex, "hex").toString("utf8").replace(/\0+$/, "");
+ return text || undefined;
+ } catch {
+ return raw || undefined;
+ }
+}
+
+function normalizeWitness(value: unknown): TronWitness | null {
+ if (!value || typeof value !== "object") return null;
+ const raw = value as Record;
+ const address = hexToBase58(raw.address);
+ if (!address) return null;
+ return {
+ address,
+ voteCount: quantityString(raw.voteCount ?? raw.vote_count),
+ url: decodeAsciiHex(raw.url),
+ totalProduced: optionalNumber(raw.totalProduced),
+ totalMissed: optionalNumber(raw.totalMissed),
+ latestBlockNum: optionalNumber(raw.latestBlockNum),
+ latestSlotNum: optionalNumber(raw.latestSlotNum),
+ isJobs: typeof raw.isJobs === "boolean" ? raw.isJobs : undefined,
+ };
+}
+
/** Parse node account JSON without first coercing 64-bit quantities through JS number. */
export function parseTronAccountResponse(text: string): TronAccount {
return normalizeAccountValue(parseLosslessJson(text)) as TronAccount;
diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts
index c497993a..47f59f79 100644
--- a/ts/src/adapters/outbound/config/builtins.ts
+++ b/ts/src/adapters/outbound/config/builtins.ts
@@ -26,6 +26,11 @@ export const CAP_SUMMARIES: Record = {
"contract.deploy": "deploy a smart contract",
"staking.freeze": "freeze/unfreeze (Stake 2.0)",
"staking.delegate": "delegate/undelegate resource (Stake 2.0)",
+ "vote.cast": "cast/replace SR votes",
+ "vote.list": "list super representatives",
+ "vote.status": "current SR votes and voting power",
+ "reward.balance": "claimable voting/block reward",
+ "reward.withdraw": "withdraw voting/block rewards",
}
export const BUILTIN_NETWORKS: Record = {
@@ -62,4 +67,5 @@ export const DEFAULT_CONFIG = {
defaultNetwork: "tron:mainnet",
defaultOutput: "text" as const,
timeoutMs: 60000,
+ waitTimeoutMs: 60000,
}
diff --git a/ts/src/adapters/outbound/config/index.ts b/ts/src/adapters/outbound/config/index.ts
index 57bbb352..da2fa2fd 100644
--- a/ts/src/adapters/outbound/config/index.ts
+++ b/ts/src/adapters/outbound/config/index.ts
@@ -33,6 +33,7 @@ export class ConfigLoader {
let defaultNetwork: string | undefined = DEFAULT_CONFIG.defaultNetwork;
let defaultOutput: OutputMode = DEFAULT_CONFIG.defaultOutput;
let timeoutMs = DEFAULT_CONFIG.timeoutMs;
+ let waitTimeoutMs = DEFAULT_CONFIG.waitTimeoutMs;
let price: Config["price"];
const path = ConfigLoader.configPath(env);
@@ -43,6 +44,7 @@ export class ConfigLoader {
}
if (raw.defaultOutput === "json" || raw.defaultOutput === "text") defaultOutput = raw.defaultOutput;
if (typeof raw.timeoutMs === "number") timeoutMs = raw.timeoutMs;
+ if (typeof raw.waitTimeoutMs === "number") waitTimeoutMs = raw.waitTimeoutMs;
if (raw.price && typeof raw.price === "object") {
const p = raw.price as Record;
const provider = p.provider === "none" ? "none" : "coingecko";
@@ -55,7 +57,7 @@ export class ConfigLoader {
}
}
}
- return { defaultNetwork, defaultOutput, timeoutMs, networks, price };
+ return { defaultNetwork, defaultOutput, timeoutMs, waitTimeoutMs, networks, price };
}
}
diff --git a/ts/src/adapters/outbound/keystore/index.ts b/ts/src/adapters/outbound/keystore/index.ts
index 2d3c5d4a..2c5b63f1 100644
--- a/ts/src/adapters/outbound/keystore/index.ts
+++ b/ts/src/adapters/outbound/keystore/index.ts
@@ -14,7 +14,7 @@ import { Derivation } from "../../../domain/derivation/index.js";
import { familyOf, CHAIN_FAMILIES } from "../../../domain/family/index.js";
import { SOURCE_KINDS, sourceFamily } from "../../../domain/sources/index.js";
import { AtomicFileStore } from "../persistence/fs/index.js";
-import { UsageError, WalletError } from "../../../domain/errors/index.js";
+import { ExecutionError, UsageError, WalletError } from "../../../domain/errors/index.js";
import {
accountIndices,
accountRefOf,
@@ -370,6 +370,53 @@ export class Keystore {
}
}
+ /** verify old → decrypt every software blob → re-encrypt with new → staged write incl. verifier.
+ * Ledger/watch wallets hold no blob and are untouched. */
+ changePassword(oldPassword: string, newPassword: string): { wallets: string[]; count: number } {
+ return this.store.withLock(this.walletsPath, () => {
+ const verifier = this.store.readJson(this.#verifierPath());
+ if (!verifier) {
+ throw new WalletError("no_software_wallet", "no software wallet; no master password set; nothing to change");
+ }
+ CryptoEnvelope.decrypt(verifier, oldPassword); // MAC mismatch → auth_failed (wrong old password)
+
+ const file = this.#read();
+ const entries: Array<{ path: string; value: unknown }> = [];
+ const labels: string[] = [];
+ for (const w of file.wallets) {
+ const s = w.source;
+ if (s.type !== "seed" && s.type !== "privateKey") continue;
+ const dir = s.type === "seed" ? "vaults" as const : "keys" as const;
+ const id = s.type === "seed" ? s.vaultId : s.keyId;
+ const path = this.#blobPath(dir, id);
+ const blob = this.store.readJson(path);
+ if (!blob) {
+ throw new WalletError(
+ "invalid_value",
+ `missing ${dir === "vaults" ? "vault" : "key"} blob ${id}`,
+ );
+ }
+ const plaintext = CryptoEnvelope.decrypt(blob, oldPassword);
+ entries.push({ path, value: CryptoEnvelope.encrypt(plaintext, newPassword, id, blob.type) });
+ labels.push(file.labels[accountRefOf(w, s.type === "seed" ? 0 : null)] ?? w.id);
+ }
+ if (entries.length === 0) {
+ throw new WalletError("no_software_wallet", "no software wallet keystore to re-encrypt");
+ }
+ entries.push({
+ path: this.#verifierPath(),
+ value: CryptoEnvelope.encrypt(randomBytes(32), newPassword, "verifier", "verifier"),
+ });
+ try {
+ this.store.writeJsonAll(entries);
+ } catch {
+ // staged temps were unlinked by writeJsonAll; every keystore file is unchanged.
+ throw new ExecutionError("io_error", "failed to write re-encrypted keystores; rolled back, the old password remains in effect");
+ }
+ return { wallets: labels, count: labels.length };
+ });
+ }
+
// ── secrets ────────────────────────────────────────────────────────────────
decryptSeed(vaultId: string): Bytes {
return this.#decryptSeedFromVault(vaultId);
diff --git a/ts/src/adapters/outbound/keystore/keystore.test.ts b/ts/src/adapters/outbound/keystore/keystore.test.ts
index 245e61bf..ff48037f 100644
--- a/ts/src/adapters/outbound/keystore/keystore.test.ts
+++ b/ts/src/adapters/outbound/keystore/keystore.test.ts
@@ -341,3 +341,54 @@ describe("password sentinel queries", () => {
expect(ks.verifyPassword("wrong")).toBe(false);
});
});
+
+describe("changePassword", () => {
+ it("re-encrypts every software blob and the verifier with the new password", () => {
+ const root = mkdtempSync(join(tmpdir(), "ks-change-password-"));
+ const store = new AtomicFileStore();
+ const ks = new Keystore(root, store, () => "OldPw1!aa");
+ ks.import({ secret: MNEMONIC, type: "seed", label: "seed" });
+ const keyRef = ks.import({
+ secret: "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
+ type: "privateKey",
+ label: "hot",
+ }).accountId;
+ const source = ks.resolveAccount(keyRef).wallet.source;
+ const keyId = source.type === "privateKey" ? source.keyId : "";
+
+ const receipt = ks.changePassword("OldPw1!aa", "NewPw2@bb");
+ expect(receipt.count).toBe(2);
+ expect(receipt.wallets).toHaveLength(2);
+ expect(ks.verifyPassword("OldPw1!aa")).toBe(false);
+ expect(ks.verifyPassword("NewPw2@bb")).toBe(true);
+ const ks2 = new Keystore(root, store, () => "NewPw2@bb");
+ expect(() => ks2.decryptKey(keyId)).not.toThrow();
+ }, 15_000);
+
+ it("rejects a wrong old password without touching any file", () => {
+ const root = mkdtempSync(join(tmpdir(), "ks-change-password-"));
+ const ks = new Keystore(root, new AtomicFileStore(), () => "OldPw1!aa");
+ ks.import({ secret: MNEMONIC, type: "seed" });
+ expect(() => ks.changePassword("WrongPw1!x", "NewPw2@bb")).toThrow(/incorrect master password/);
+ expect(ks.verifyPassword("OldPw1!aa")).toBe(true);
+ });
+
+ it("throws no_software_wallet when only watch/ledger wallets exist", () => {
+ const root = mkdtempSync(join(tmpdir(), "ks-change-password-"));
+ const ksWatchOnly = new Keystore(root, new AtomicFileStore(), () => "OldPw1!aa");
+ ksWatchOnly.registerWatch({ family: "tron", address: "Twatch-only" });
+ expect(() => ksWatchOnly.changePassword("OldPw1!aa", "NewPw2@bb")).toThrow(/no software wallet/);
+ });
+
+ it("maps a write failure to io_error and leaves the keystore usable under the old password", () => {
+ const root = mkdtempSync(join(tmpdir(), "ks-change-password-"));
+ const store = new AtomicFileStore();
+ const ks = new Keystore(root, store, () => "OldPw1!aa");
+ ks.import({ secret: MNEMONIC, type: "seed" });
+ store.writeJsonAll = () => { throw new Error("disk full"); };
+ expect(() => ks.changePassword("OldPw1!aa", "NewPw2@bb")).toThrowError(
+ expect.objectContaining({ code: "io_error" }),
+ );
+ expect(ks.verifyPassword("OldPw1!aa")).toBe(true);
+ }, 15_000);
+});
diff --git a/ts/src/adapters/outbound/persistence/fs/index.ts b/ts/src/adapters/outbound/persistence/fs/index.ts
index 95f2bfb8..6724cd17 100644
--- a/ts/src/adapters/outbound/persistence/fs/index.ts
+++ b/ts/src/adapters/outbound/persistence/fs/index.ts
@@ -51,6 +51,24 @@ export class AtomicFileStore {
renameSync(tmp, path); // atomic replace on same filesystem
}
+ /** transactional-ish multi-file write: stage every temp first, then rename all into place.
+ * A failure while staging unlinks the temps and leaves every target untouched. */
+ writeJsonAll(entries: Array<{ path: string; value: unknown }>): void {
+ const staged: Array<{ tmp: string; path: string }> = [];
+ try {
+ for (const { path, value } of entries) {
+ mkdirSync(dirname(path), { recursive: true });
+ const tmp = `${path}.${process.pid}.${this.#counter++}.tmp`;
+ staged.push({ tmp, path });
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
+ }
+ } catch (e) {
+ for (const { tmp } of staged) { try { unlinkSync(tmp); } catch { /* best-effort */ } }
+ throw e;
+ }
+ for (const { tmp, path } of staged) renameSync(tmp, path); // atomic per file
+ }
+
writeText(path: string, text: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${this.#counter++}.tmp`;
diff --git a/ts/src/application/ports/chain/tron-gateway.ts b/ts/src/application/ports/chain/tron-gateway.ts
index 8f728564..6ac8bb19 100644
--- a/ts/src/application/ports/chain/tron-gateway.ts
+++ b/ts/src/application/ports/chain/tron-gateway.ts
@@ -30,6 +30,14 @@ export interface TronFrozenBalance {
[key: string]: unknown;
}
+export interface TronVoteAllocation {
+ vote_address?: unknown;
+ voteAddress?: unknown;
+ vote_count?: unknown;
+ voteCount?: unknown;
+ [key: string]: unknown;
+}
+
/** Account payload normalized at the adapter boundary; all SUN/token quantities are strings. */
export interface TronAccount {
balance?: string;
@@ -39,9 +47,27 @@ export interface TronAccount {
frozen?: TronFrozenBalance[];
frozenV2?: TronFrozenBalance[];
unfrozenV2?: TronFrozenBalance[];
+ votes?: TronVoteAllocation[];
[key: string]: unknown;
}
+export interface TronWitness {
+ address: string;
+ voteCount: string;
+ url?: string;
+ totalProduced?: number;
+ totalMissed?: number;
+ latestBlockNum?: number;
+ latestSlotNum?: number;
+ isJobs?: boolean;
+ [key: string]: unknown;
+}
+
+export interface TronVote {
+ witness: string;
+ count: string;
+}
+
export interface TronTokenInfo {
contract?: string;
name?: unknown;
@@ -94,6 +120,26 @@ export interface TronFeeEstimate extends FeeReport {
energy: number;
}
+/** one V2 delegation record; addresses base58, SUN quantities strings, expiry epoch-ms or null. */
+export interface TronDelegatedResource {
+ from: string;
+ to: string;
+ balanceForEnergySun: string;
+ balanceForBandwidthSun: string;
+ expireTimeForEnergy: number | null;
+ expireTimeForBandwidth: number | null;
+}
+
+/** getnodeinfo subset the CLI consumes; best-effort — public gateways may omit fields. */
+export interface TronNodeInfo {
+ block?: string; // "Num:84120345,ID:…"
+ solidityBlock?: string;
+ currentConnectCount?: number;
+ activeConnectCount?: number;
+ configNodeInfo?: { codeVersion?: string; p2pVersion?: string; [key: string]: unknown };
+ [key: string]: unknown;
+}
+
/** TRON-specific application boundary; chain-specific capabilities remain explicit. */
export interface TronGateway extends Broadcaster {
getNativeBalance(address: string): Promise;
@@ -102,6 +148,15 @@ export interface TronGateway extends Broadcaster {
getBlock(number?: string): Promise;
getTransactionById(txid: string): Promise;
getTransactionInfoById(txid: string): Promise;
+ getChainParameters(): Promise>;
+ getEnergyPrices(): Promise;
+ getBandwidthPrices(): Promise;
+ getNodeInfo(): Promise;
+ getDelegatedResourceV2(from: string, to: string): Promise;
+ getDelegatedIndexV2(address: string): Promise<{ fromAccounts: string[]; toAccounts: string[] }>;
+ getCanDelegatedMaxSize(address: string, resource: RpcResourceCode): Promise;
+ getCanWithdrawUnfreezeAmount(address: string): Promise;
+ getAvailableUnfreezeCount(address: string): Promise;
decodeTransaction(transaction: TronTx): DecodedTronTransaction;
getTrc20Balance(contract: string, address: string): Promise;
getTokenInfo(contract: string): Promise;
@@ -145,6 +200,11 @@ export interface TronGateway extends Broadcaster {
resource: RpcResourceCode,
receiver: string,
): Promise;
+ buildVoteWitness(owner: string, votes: TronVote[]): Promise;
+ buildWithdrawBalance(owner: string): Promise;
+ getWitnesses(limit: number): Promise;
+ getBrokerage(address: string): Promise;
+ getReward(address: string): Promise;
triggerConstantContract(
contract: string,
method: string,
diff --git a/ts/src/application/ports/wallet-repository.ts b/ts/src/application/ports/wallet-repository.ts
index f2363002..55e4ecbb 100644
--- a/ts/src/application/ports/wallet-repository.ts
+++ b/ts/src/application/ports/wallet-repository.ts
@@ -29,6 +29,7 @@ export interface WalletRepository extends AccountStore {
label: string;
};
setActive(refOrLabel: string): { accountId: AccountRef; previous: AccountRef | null };
+ changePassword(oldPassword: string, newPassword: string): { wallets: string[]; count: number };
delete(refOrWallet: string): {
accountId: AccountRef;
scope: "account" | "wallet";
@@ -37,4 +38,3 @@ export interface WalletRepository extends AccountStore {
};
revealMnemonic(vaultId: string): { mnemonic: string; passphraseSet: boolean };
}
-
diff --git a/ts/src/application/use-cases/config-service.test.ts b/ts/src/application/use-cases/config-service.test.ts
index e8b9d52d..af7b1591 100644
--- a/ts/src/application/use-cases/config-service.test.ts
+++ b/ts/src/application/use-cases/config-service.test.ts
@@ -4,7 +4,7 @@ import type { ConfigDocumentRepository } from "../ports/config-document-reposito
import type { NetworkRegistry } from "../ports/network-registry.js";
import type { Config } from "../../domain/types/index.js";
-const effective = { timeoutMs: 60_000, networks: {} } as unknown as Config;
+const effective = { timeoutMs: 60_000, waitTimeoutMs: 60_000, networks: {} } as unknown as Config;
const networks = {} as NetworkRegistry;
function service(): { svc: ConfigService; update: ReturnType } {
@@ -29,3 +29,29 @@ describe("ConfigService timeoutMs validation", () => {
});
});
});
+
+describe("ConfigService waitTimeoutMs", () => {
+ it("shows waitTimeoutMs in the full view and single-key read", () => {
+ const { svc } = service();
+ expect(svc.execute({}, effective, networks)).toMatchObject({ waitTimeoutMs: 60_000 });
+ expect(svc.execute({ key: "waitTimeoutMs" }, effective, networks)).toMatchObject({
+ key: "waitTimeoutMs",
+ value: 60_000,
+ });
+ });
+
+ it("accepts 0 and positive integers, rejects negatives and non-numbers", () => {
+ const { svc, update } = service();
+ expect(svc.execute({ key: "waitTimeoutMs", value: "0" }, effective, networks)).toMatchObject({
+ key: "waitTimeoutMs",
+ value: 0,
+ });
+ expect(svc.execute({ key: "waitTimeoutMs", value: "120000" }, effective, networks)).toMatchObject({
+ key: "waitTimeoutMs",
+ value: 120000,
+ });
+ expect(() => svc.execute({ key: "waitTimeoutMs", value: "-1" }, effective, networks)).toThrow(/non-negative/);
+ expect(() => svc.execute({ key: "waitTimeoutMs", value: "abc" }, effective, networks)).toThrow(/non-negative/);
+ expect(update).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/ts/src/application/use-cases/config-service.ts b/ts/src/application/use-cases/config-service.ts
index 7c1e10cf..1b9aabbb 100644
--- a/ts/src/application/use-cases/config-service.ts
+++ b/ts/src/application/use-cases/config-service.ts
@@ -3,8 +3,8 @@ import type { NetworkRegistry } from "../ports/network-registry.js";
import { UsageError } from "../../domain/errors/index.js";
import type { ConfigDocumentRepository } from "../ports/config-document-repository.js";
-export const CONFIG_KEYS = ["defaultNetwork", "defaultOutput", "timeoutMs", "networks"] as const;
-export const WRITABLE_CONFIG_KEYS = ["defaultNetwork", "defaultOutput", "timeoutMs"] as const;
+export const CONFIG_KEYS = ["defaultNetwork", "defaultOutput", "timeoutMs", "waitTimeoutMs", "networks"] as const;
+export const WRITABLE_CONFIG_KEYS = ["defaultNetwork", "defaultOutput", "timeoutMs", "waitTimeoutMs"] as const;
export type ConfigKey = (typeof CONFIG_KEYS)[number];
export type WritableConfigKey = (typeof WRITABLE_CONFIG_KEYS)[number];
@@ -25,6 +25,7 @@ export class ConfigService {
defaultNetwork: effective.defaultNetwork,
defaultOutput: effective.defaultOutput,
timeoutMs: effective.timeoutMs,
+ waitTimeoutMs: effective.waitTimeoutMs,
networks: Object.keys(effective.networks),
};
if (input.key === undefined) return view;
@@ -53,6 +54,13 @@ export class ConfigService {
}
return value;
}
+ if (key === "waitTimeoutMs") {
+ const value = Number(raw);
+ if (!Number.isInteger(value) || value < 0) {
+ throw new UsageError("invalid_value", "waitTimeoutMs must be a non-negative integer");
+ }
+ return value;
+ }
if (key === "defaultOutput") {
if (raw !== "text" && raw !== "json") {
throw new UsageError("invalid_value", "defaultOutput must be 'text' or 'json'");
diff --git a/ts/src/application/use-cases/tron/chain-service.test.ts b/ts/src/application/use-cases/tron/chain-service.test.ts
new file mode 100644
index 00000000..a4f83044
--- /dev/null
+++ b/ts/src/application/use-cases/tron/chain-service.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect } from "vitest";
+import { TronChainService } from "./chain-service.js";
+import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js";
+import type { NetworkDescriptor } from "../../../domain/types/index.js";
+
+const net = {
+ id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [],
+ httpEndpoint: "https://nile.trongrid.io",
+} as NetworkDescriptor;
+
+function svc(gateway: Record) {
+ return new TronChainService({ get: () => gateway } as unknown as ChainGatewayProvider);
+}
+
+describe("TronChainService.params", () => {
+ const gateway = { getChainParameters: async () => [{ key: "getEnergyFee", value: 210 }, { key: "getMemoFee", value: 1000000 }] };
+ it("lists all parameters", async () => {
+ expect(await svc(gateway).params(net)).toEqual({ params: [{ key: "getEnergyFee", value: 210 }, { key: "getMemoFee", value: 1000000 }] });
+ });
+ it("returns a single key, and not_found for unknown keys", async () => {
+ expect(await svc(gateway).params(net, "getEnergyFee")).toEqual({ key: "getEnergyFee", value: 210 });
+ await expect(svc(gateway).params(net, "getNope")).rejects.toMatchObject({ code: "not_found" });
+ });
+});
+
+describe("TronChainService.prices", () => {
+ it("parses price history strings; current = last segment; memo fee from params", async () => {
+ const gateway = {
+ getEnergyPrices: async () => "0:100,1670515200000:210",
+ getBandwidthPrices: async () => "0:10,1614456000000:1000",
+ getChainParameters: async () => [{ key: "getMemoFee", value: 1000000 }],
+ };
+ expect(await svc(gateway).prices(net)).toEqual({
+ energy: { currentSunPerUnit: 210, history: [{ since: 0, price: 100 }, { since: 1670515200000, price: 210 }] },
+ bandwidth: { currentSunPerUnit: 1000, history: [{ since: 0, price: 10 }, { since: 1614456000000, price: 1000 }] },
+ memoFeeSun: "1000000",
+ });
+ });
+});
+
+describe("TronChainService.node", () => {
+ it("derives head/solid/lag/inSync; missing best-effort fields become null", async () => {
+ const now = Date.now();
+ const gateway = {
+ getNodeInfo: async () => ({
+ block: "Num:84120345,ID:abc", solidityBlock: "Num:84120326,ID:def",
+ currentConnectCount: 30, activeConnectCount: 27,
+ configNodeInfo: { codeVersion: "4.7.7", p2pVersion: "11111" },
+ }),
+ getBlock: async () => ({ block_header: { raw_data: { number: 84120345, timestamp: now - 2000 } } }),
+ };
+ const view = await svc(gateway).node(net);
+ expect(view).toMatchObject({
+ endpoint: "https://nile.trongrid.io",
+ version: "java-tron 4.7.7",
+ p2pVersion: "11111",
+ headBlock: { number: 84120345, timestamp: now - 2000 },
+ solidBlock: { number: 84120326 },
+ lagBlocks: 19,
+ inSync: true,
+ peers: { connected: 30, active: 27 },
+ });
+ });
+ it("nulls unexposed fields (public gateway)", async () => {
+ const gateway = {
+ getNodeInfo: async () => ({}),
+ getBlock: async () => ({ block_header: { raw_data: { number: 100, timestamp: Date.now() - 60_000 } } }),
+ };
+ const view = await svc(gateway).node(net);
+ expect(view.version).toBeNull();
+ expect(view.peers).toBeNull();
+ expect(view.inSync).toBe(false);
+ });
+});
diff --git a/ts/src/application/use-cases/tron/chain-service.ts b/ts/src/application/use-cases/tron/chain-service.ts
new file mode 100644
index 00000000..cbddb34d
--- /dev/null
+++ b/ts/src/application/use-cases/tron/chain-service.ts
@@ -0,0 +1,71 @@
+import type { NetworkDescriptor } from "../../../domain/types/index.js";
+import { UsageError } from "../../../domain/errors/index.js";
+import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js";
+
+/** TRON blocks every ~3s; a head older than 3 intervals means the node lags the chain. */
+const IN_SYNC_WINDOW_MS = 9_000;
+
+/** "ts:price,ts:price,…" (node price timeline) → structured history; current = last segment. */
+function parsePriceTimeline(raw: string): { currentSunPerUnit: number; history: Array<{ since: number; price: number }> } {
+ const history = raw
+ .split(",")
+ .map((seg) => seg.split(":"))
+ .filter((p) => p.length === 2)
+ .map(([since, price]) => ({ since: Number(since), price: Number(price) }));
+ return { currentSunPerUnit: history.at(-1)?.price ?? 0, history };
+}
+
+/** getnodeinfo "Num:84120345,ID:…" → 84120345 (null if unparsable). */
+function blockNum(s: unknown): number | null {
+ const m = /Num:(\d+)/.exec(String(s ?? ""));
+ return m ? Number(m[1]) : null;
+}
+
+export class TronChainService {
+ constructor(private readonly gateways: ChainGatewayProvider) {}
+
+ async params(network: NetworkDescriptor, key?: string) {
+ const params = await this.gateways.get(network, "tron").getChainParameters();
+ if (key === undefined) return { params };
+ const hit = params.find((p) => p.key === key);
+ if (!hit) throw new UsageError("not_found", `unknown chain parameter: ${key}`);
+ return { key: hit.key, value: hit.value };
+ }
+
+ async prices(network: NetworkDescriptor) {
+ const gateway = this.gateways.get(network, "tron");
+ const [energyRaw, bandwidthRaw, params] = await Promise.all([
+ gateway.getEnergyPrices(),
+ gateway.getBandwidthPrices(),
+ gateway.getChainParameters(),
+ ]);
+ const memo = params.find((p) => p.key === "getMemoFee")?.value;
+ return {
+ energy: parsePriceTimeline(energyRaw),
+ bandwidth: parsePriceTimeline(bandwidthRaw),
+ memoFeeSun: String(memo ?? 0),
+ };
+ }
+
+ async node(network: NetworkDescriptor) {
+ const gateway = this.gateways.get(network, "tron");
+ const [info, head] = await Promise.all([gateway.getNodeInfo(), gateway.getBlock()]);
+ const header = ((head as Record)?.block_header?.raw_data ?? {}) as { number?: number; timestamp?: number };
+ const headNumber = Number(header.number ?? 0);
+ const headTimestamp = Number(header.timestamp ?? 0);
+ const solidNumber = blockNum(info.solidityBlock);
+ const codeVersion = info.configNodeInfo?.codeVersion;
+ return {
+ endpoint: network.httpEndpoint ?? null,
+ version: codeVersion ? `java-tron ${codeVersion}` : null,
+ p2pVersion: info.configNodeInfo?.p2pVersion ?? null,
+ headBlock: { number: headNumber, timestamp: headTimestamp },
+ solidBlock: solidNumber === null ? null : { number: solidNumber },
+ lagBlocks: solidNumber === null ? null : headNumber - solidNumber,
+ inSync: headTimestamp > 0 && Date.now() - headTimestamp <= IN_SYNC_WINDOW_MS,
+ peers: info.currentConnectCount === undefined
+ ? null
+ : { connected: Number(info.currentConnectCount), active: Number(info.activeConnectCount ?? 0) },
+ };
+ }
+}
diff --git a/ts/src/application/use-cases/tron/reward-service.test.ts b/ts/src/application/use-cases/tron/reward-service.test.ts
new file mode 100644
index 00000000..7a5316e9
--- /dev/null
+++ b/ts/src/application/use-cases/tron/reward-service.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "vitest";
+import { TronRewardService } from "./reward-service.js";
+import type { NetworkDescriptor } from "../../../domain/types/index.js";
+import type { TransactionScope } from "../../contracts/execution-scope.js";
+import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js";
+import type { TronGateway } from "../../ports/chain/tron-gateway.js";
+import type { TxPipeline } from "../../services/pipeline/index.js";
+
+const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] };
+const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7";
+
+const scope: TransactionScope = {
+ activeAccount: "wlt_test.0",
+ resolveAddress: () => OWNER,
+ timeoutMs: 60_000,
+ wait: false,
+ waitTimeoutMs: 60_000,
+ emit: () => {},
+ warn: () => {},
+};
+
+function service(gateway: Partial, pipeline?: Partial, now = 1_700_000_000_000) {
+ const g = gateway as TronGateway;
+ return new TronRewardService(
+ { get: () => g } as unknown as ChainGatewayProvider,
+ { run: async () => ({ stage: "submitted", txId: "txid" }), ...pipeline } as unknown as TxPipeline,
+ () => now,
+ );
+}
+
+describe("TronRewardService.balance", () => {
+ it("returns claimable reward and available-now status for first withdrawal", async () => {
+ const svc = service({
+ getReward: async () => "123456789",
+ getAccount: async () => ({}),
+ });
+ await expect(svc.balance(scope, NET)).resolves.toEqual({
+ address: OWNER,
+ rewardSun: "123456789",
+ withdrawableNow: true,
+ withdrawableAt: null,
+ });
+ });
+
+ it("returns the next withdrawable timestamp inside the 24h interval", async () => {
+ const now = 1_700_000_000_000;
+ const latest = String(now - 6 * 60 * 60 * 1000);
+ const svc = service({
+ getReward: async () => "1",
+ getAccount: async () => ({ latest_withdraw_time: latest }),
+ }, undefined, now);
+ await expect(svc.balance(scope, NET)).resolves.toMatchObject({
+ withdrawableNow: false,
+ withdrawableAt: Number(latest) + 24 * 60 * 60 * 1000,
+ });
+ });
+});
+
+describe("TronRewardService.withdraw", () => {
+ it("rejects when no reward is claimable", async () => {
+ const svc = service({
+ getReward: async () => "0",
+ getAccount: async () => ({}),
+ });
+ await expect(svc.withdraw(scope, NET, {})).rejects.toMatchObject({ code: "no_reward" });
+ });
+
+ it("rejects inside the 24h withdraw interval", async () => {
+ const now = 1_700_000_000_000;
+ const svc = service({
+ getReward: async () => "10",
+ getAccount: async () => ({ latest_withdraw_time: String(now - 1_000) }),
+ }, undefined, now);
+ await expect(svc.withdraw(scope, NET, {})).rejects.toMatchObject({ code: "withdraw_too_frequent" });
+ });
+
+ it("echoes submitted rewardSun and confirmed withdrawnSun as rewardSun", async () => {
+ const baseGateway = {
+ getReward: async () => "10",
+ getAccount: async () => ({}),
+ buildWithdrawBalance: async () => ({}),
+ };
+ const submitted = service(baseGateway).withdraw(scope, NET, {});
+ await expect(submitted).resolves.toMatchObject({
+ kind: "reward-withdraw",
+ stage: "submitted",
+ txId: "txid",
+ rewardSun: "10",
+ });
+
+ const confirmed = service(baseGateway, {
+ run: async () => ({ stage: "confirmed", txId: "txid", withdrawnSun: "12" }),
+ }).withdraw(scope, NET, {});
+ await expect(confirmed).resolves.toMatchObject({
+ kind: "reward-withdraw",
+ stage: "confirmed",
+ txId: "txid",
+ withdrawnSun: "12",
+ rewardSun: "12",
+ });
+ });
+});
diff --git a/ts/src/application/use-cases/tron/reward-service.ts b/ts/src/application/use-cases/tron/reward-service.ts
new file mode 100644
index 00000000..10f9404a
--- /dev/null
+++ b/ts/src/application/use-cases/tron/reward-service.ts
@@ -0,0 +1,80 @@
+import type { NetworkDescriptor } from "../../../domain/types/index.js";
+import { UsageError } from "../../../domain/errors/index.js";
+import type { TransactionScope } from "../../contracts/execution-scope.js";
+import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js";
+import type { TronAccount } from "../../ports/chain/tron-gateway.js";
+import type { TxPipeline } from "../../services/pipeline/index.js";
+import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js";
+import { tronConfirmation } from "../../services/tron-confirmation.js";
+
+const WITHDRAW_INTERVAL_MS = 24 * 60 * 60 * 1000;
+
+export class TronRewardService {
+ constructor(
+ private readonly gateways: ChainGatewayProvider,
+ private readonly pipeline: TxPipeline,
+ private readonly now: () => number = () => Date.now(),
+ ) {}
+
+ async balance(scope: Pick, network: NetworkDescriptor) {
+ const gateway = this.gateways.get(network, "tron");
+ const address = scope.resolveAddress("tron");
+ const [rewardSun, account] = await Promise.all([
+ gateway.getReward(address),
+ gateway.getAccount(address),
+ ]);
+ return {
+ address,
+ rewardSun,
+ ...withdrawStatus(account, this.now()),
+ };
+ }
+
+ async withdraw(scope: TransactionScope, network: NetworkDescriptor, input: TransactionModeInput) {
+ const gateway = this.gateways.get(network, "tron");
+ const address = scope.resolveAddress("tron");
+ const [rewardSun, account] = await Promise.all([
+ gateway.getReward(address),
+ gateway.getAccount(address),
+ ]);
+ if (toUnsignedBigInt(rewardSun) === 0n) {
+ throw new UsageError("no_reward", "no voting/block reward is currently claimable");
+ }
+ const status = withdrawStatus(account, this.now());
+ if (!status.withdrawableNow) {
+ throw new UsageError("withdraw_too_frequent", `reward can be withdrawn after ${new Date(status.withdrawableAt!).toISOString()}`);
+ }
+ const outcome = await this.pipeline.run({
+ ctx: scope,
+ net: network,
+ account: scope.activeAccount,
+ broadcaster: gateway,
+ ...transactionMode(input),
+ confirm: tronConfirmation(gateway, scope),
+ build: (owner) => gateway.buildWithdrawBalance(owner),
+ estimate: async () => ({ feeModel: "tron-resource", note: "reward withdrawal uses bandwidth only" }),
+ });
+ const data = outcomeData(outcome);
+ return {
+ kind: "reward-withdraw" as const,
+ ...data,
+ rewardSun: String(data.withdrawnSun ?? rewardSun),
+ };
+ }
+}
+
+function withdrawStatus(account: TronAccount, now: number): { withdrawableNow: boolean; withdrawableAt: number | null } {
+ const latest = toUnsignedBigInt(account.latest_withdraw_time);
+ if (latest === 0n) return { withdrawableNow: true, withdrawableAt: null };
+ const at = Number(latest) + WITHDRAW_INTERVAL_MS;
+ return at <= now
+ ? { withdrawableNow: true, withdrawableAt: null }
+ : { withdrawableNow: false, withdrawableAt: at };
+}
+
+function toUnsignedBigInt(value: unknown): bigint {
+ if (typeof value === "bigint") return value >= 0n ? value : 0n;
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : 0n;
+ if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
+ return 0n;
+}
diff --git a/ts/src/application/use-cases/tron/stake-service.query.test.ts b/ts/src/application/use-cases/tron/stake-service.query.test.ts
new file mode 100644
index 00000000..aa3b4555
--- /dev/null
+++ b/ts/src/application/use-cases/tron/stake-service.query.test.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect } from "vitest";
+import { TronStakeService } from "./stake-service.js";
+import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js";
+import type { TxPipeline } from "../../services/pipeline/index.js";
+import type { NetworkDescriptor } from "../../../domain/types/index.js";
+
+const net = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] } as NetworkDescriptor;
+const OWNER = "TQkDWJimyBEhkFcqEfCWNbb6tMDwmH1234";
+
+function svc(gateway: Record) {
+ const gateways = { get: () => gateway } as unknown as ChainGatewayProvider;
+ return new TronStakeService(gateways, {} as TxPipeline);
+}
+const scope = (addr: string) => ({ resolveAddress: () => addr }) as never;
+
+describe("TronStakeService.info", () => {
+ it("aggregates staked split, voting power, resources, unfreezing and slots", async () => {
+ const gateway = {
+ getAccount: async () => ({
+ frozenV2: [{ type: "ENERGY", amount: "1000000000" }, { amount: "500000000" }],
+ unfrozenV2: [
+ { unfreeze_amount: "500000000", unfreeze_expire_time: 1784073600000 },
+ { type: "ENERGY", unfreeze_amount: "300000000", unfreeze_expire_time: 1784160000000 },
+ ],
+ }),
+ getAccountResources: async () => ({
+ EnergyUsed: 12000, EnergyLimit: 65000, NetUsed: 100, NetLimit: 1000, freeNetUsed: 500, freeNetLimit: 500,
+ tronPowerLimit: 1500, tronPowerUsed: 1000,
+ }),
+ getCanWithdrawUnfreezeAmount: async () => "0",
+ getAvailableUnfreezeCount: async () => 30,
+ };
+ const view = await svc(gateway).info(scope(OWNER), net);
+ expect(view).toEqual({
+ address: OWNER,
+ staked: { energySun: "1000000000", bandwidthSun: "500000000" },
+ votingPower: { total: 1500, used: 1000, available: 500 },
+ resource: { energy: { used: 12000, limit: 65000 }, bandwidth: { used: 600, limit: 1500 } },
+ unfreezing: [
+ { amountSun: "500000000", withdrawableAt: 1784073600000 },
+ { amountSun: "300000000", withdrawableAt: 1784160000000 },
+ ],
+ withdrawableSun: "0",
+ unfreeze: { used: 2, max: 32, remaining: 30 },
+ });
+ });
+});
+
+describe("TronStakeService.delegated", () => {
+ const record = {
+ from: OWNER, to: "TBy6mQ7Y3nJ8sD2fWpXk4LhVc9Ra1Zt5Ub",
+ balanceForEnergySun: "500000000", balanceForBandwidthSun: "0",
+ expireTimeForEnergy: 1783468800000, expireTimeForBandwidth: null,
+ };
+ it("out: lists per-receiver rows with canDelegateMaxSun", async () => {
+ const gateway = {
+ getDelegatedIndexV2: async () => ({ fromAccounts: [], toAccounts: [record.to] }),
+ getDelegatedResourceV2: async () => [record],
+ getCanDelegatedMaxSize: async (_a: string, r: string) => (r === "ENERGY" ? "900000000" : "300000000"),
+ };
+ const view = await svc(gateway).delegated(scope(OWNER), net, { direction: "out" });
+ expect(view).toEqual({
+ address: OWNER,
+ direction: "out",
+ canDelegateMaxSun: { energy: "900000000", bandwidth: "300000000" },
+ delegations: [{ receiver: record.to, resource: "energy", amountSun: "500000000", lockedUntil: 1783468800000 }],
+ });
+ });
+ it("in: rows keyed by `from`, no canDelegateMaxSun, resource filter applies", async () => {
+ const gateway = {
+ getDelegatedIndexV2: async () => ({ fromAccounts: [record.from], toAccounts: [] }),
+ getDelegatedResourceV2: async () => [{ ...record, balanceForBandwidthSun: "50000000" }],
+ };
+ const view = await svc(gateway).delegated(scope(record.to), net, { direction: "in", resource: "bandwidth" });
+ expect(view).toEqual({
+ address: record.to,
+ direction: "in",
+ delegations: [{ from: OWNER, resource: "bandwidth", amountSun: "50000000", lockedUntil: null }],
+ });
+ });
+});
+
+describe("TronStakeService.votingPower", () => {
+ it("derives available from limit - used", async () => {
+ const gateway = { getAccountResources: async () => ({ tronPowerLimit: 1500, tronPowerUsed: 1000 }) };
+ expect(await svc(gateway).votingPower(net, OWNER)).toEqual({ total: 1500, used: 1000, available: 500 });
+ });
+});
diff --git a/ts/src/application/use-cases/tron/stake-service.ts b/ts/src/application/use-cases/tron/stake-service.ts
index 277dc647..5e431a28 100644
--- a/ts/src/application/use-cases/tron/stake-service.ts
+++ b/ts/src/application/use-cases/tron/stake-service.ts
@@ -1,7 +1,7 @@
import type { NetworkDescriptor, TxReceiptKind, UnsignedTx } from "../../../domain/types/index.js";
import { UsageError } from "../../../domain/errors/index.js";
import { toRpcCode, type Resource } from "../../../domain/resources/index.js";
-import type { TransactionScope } from "../../contracts/execution-scope.js";
+import type { AccountScope, TransactionScope } from "../../contracts/execution-scope.js";
import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js";
import type { TronGateway } from "../../ports/chain/tron-gateway.js";
import type { TxPipeline } from "../../services/pipeline/index.js";
@@ -19,12 +19,122 @@ export interface StakeDelegateInput extends StakeAmountInput {
lockPeriod?: string;
}
+export interface StakeInfoView {
+ address: string;
+ staked: { energySun: string; bandwidthSun: string };
+ votingPower: { total: number; used: number; available: number };
+ resource: {
+ energy: { used: number; limit: number };
+ bandwidth: { used: number; limit: number };
+ };
+ unfreezing: Array<{ amountSun: string; withdrawableAt: number }>;
+ withdrawableSun: string;
+ unfreeze: { used: number; max: number; remaining: number };
+}
+
+export interface StakeDelegatedView {
+ address: string;
+ direction: "out" | "in";
+ canDelegateMaxSun?: { energy: string; bandwidth: string };
+ delegations: Array<{
+ receiver?: string;
+ from?: string;
+ resource: Resource;
+ amountSun: string;
+ lockedUntil: number | null;
+ }>;
+}
+
export class TronStakeService {
constructor(
private readonly gateways: ChainGatewayProvider,
private readonly pipeline: TxPipeline,
) {}
+ /** voting power (TP) — 1 TP = 1 staked TRX. Public: vote status (Leon) consumes this. */
+ async votingPower(network: NetworkDescriptor, address: string): Promise<{ total: number; used: number; available: number }> {
+ const res = await this.gateways.get(network, "tron").getAccountResources(address);
+ const total = Number((res as Record).tronPowerLimit ?? 0);
+ const used = Number((res as Record).tronPowerUsed ?? 0);
+ return { total, used, available: total - used };
+ }
+
+ async info(scope: AccountScope, network: NetworkDescriptor): Promise {
+ const address = scope.resolveAddress("tron");
+ const gateway = this.gateways.get(network, "tron");
+ const [account, resources, withdrawableSun, remaining] = await Promise.all([
+ gateway.getAccount(address),
+ gateway.getAccountResources(address),
+ gateway.getCanWithdrawUnfreezeAmount(address),
+ gateway.getAvailableUnfreezeCount(address),
+ ]);
+ // frozenV2: `type` absent = BANDWIDTH (node omits default-enum fields).
+ const sum = (entries: Array<{ type?: unknown; amount?: string }>, type: "ENERGY" | "BANDWIDTH") =>
+ entries
+ .filter((f) => (f.type ?? "BANDWIDTH") === type)
+ .reduce((acc, f) => acc + BigInt(f.amount ?? "0"), 0n)
+ .toString();
+ const frozen = account.frozenV2 ?? [];
+ const unfrozen = account.unfrozenV2 ?? [];
+ const r = resources as Record;
+ const total = Number(r.tronPowerLimit ?? 0);
+ const used = Number(r.tronPowerUsed ?? 0);
+ return {
+ address,
+ staked: { energySun: sum(frozen, "ENERGY"), bandwidthSun: sum(frozen, "BANDWIDTH") },
+ votingPower: { total, used, available: total - used },
+ resource: {
+ energy: { used: Number(r.EnergyUsed ?? 0), limit: Number(r.EnergyLimit ?? 0) },
+ // bandwidth = free allowance + staked allowance (node reports them separately).
+ bandwidth: {
+ used: Number(r.NetUsed ?? 0) + Number(r.freeNetUsed ?? 0),
+ limit: Number(r.NetLimit ?? 0) + Number(r.freeNetLimit ?? 0),
+ },
+ },
+ unfreezing: unfrozen.map((u) => ({
+ amountSun: String((u as Record).unfreeze_amount ?? "0"),
+ withdrawableAt: Number((u as Record).unfreeze_expire_time ?? 0),
+ })),
+ withdrawableSun,
+ unfreeze: { used: unfrozen.length, max: 32, remaining },
+ };
+ }
+
+ async delegated(
+ scope: AccountScope,
+ network: NetworkDescriptor,
+ input: { direction: "out" | "in"; resource?: Resource; to?: string },
+ ): Promise {
+ const address = scope.resolveAddress("tron");
+ const gateway = this.gateways.get(network, "tron");
+ const index = await gateway.getDelegatedIndexV2(address);
+ const out = input.direction === "out";
+ const counterparties = out ? (input.to ? [input.to] : index.toAccounts) : index.fromAccounts;
+ const records = (
+ await Promise.all(
+ counterparties.map((cp) => gateway.getDelegatedResourceV2(out ? address : cp, out ? cp : address)),
+ )
+ ).flat();
+ // one record carries both directions' balances; emit one row per resource with balance > 0.
+ const rows = records.flatMap((rec) => {
+ const counterparty = out ? rec.to : rec.from;
+ const key = out ? "receiver" : "from";
+ const both = [
+ { resource: "energy" as const, amountSun: rec.balanceForEnergySun, lockedUntil: rec.expireTimeForEnergy },
+ { resource: "bandwidth" as const, amountSun: rec.balanceForBandwidthSun, lockedUntil: rec.expireTimeForBandwidth },
+ ];
+ return both
+ .filter((b) => b.amountSun !== "0" && (!input.resource || b.resource === input.resource))
+ .map((b) => ({ [key]: counterparty, ...b }));
+ });
+ if (!out) return { address, direction: input.direction, delegations: rows };
+ const [energy, bandwidth] = await Promise.all([
+ gateway.getCanDelegatedMaxSize(address, "ENERGY"),
+ gateway.getCanDelegatedMaxSize(address, "BANDWIDTH"),
+ ]);
+ return { address, direction: input.direction, canDelegateMaxSun: { energy, bandwidth }, delegations: rows };
+ }
+
freeze(scope: TransactionScope, network: NetworkDescriptor, input: StakeAmountInput) {
return this.transact("stake-freeze", scope, network, input,
(gateway, owner) => gateway.buildFreezeV2(owner, input.amountSun, toRpcCode(input.resource)));
diff --git a/ts/src/application/use-cases/tron/vote-service.test.ts b/ts/src/application/use-cases/tron/vote-service.test.ts
new file mode 100644
index 00000000..6d10a604
--- /dev/null
+++ b/ts/src/application/use-cases/tron/vote-service.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest";
+import { TronVoteService } from "./vote-service.js";
+import { TronStakeService } from "./stake-service.js";
+import type { NetworkDescriptor } from "../../../domain/types/index.js";
+import type { TransactionScope } from "../../contracts/execution-scope.js";
+import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js";
+import type { TronGateway } from "../../ports/chain/tron-gateway.js";
+import type { TxPipeline } from "../../services/pipeline/index.js";
+
+const NET: NetworkDescriptor = { id: "tron:nile", family: "tron", chainId: "nile", aliases: [], capabilities: [] };
+const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7";
+const SR1 = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb";
+const SR2 = "TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g";
+
+const scope: TransactionScope = {
+ activeAccount: "wlt_test.0",
+ resolveAddress: () => OWNER,
+ timeoutMs: 60_000,
+ wait: false,
+ waitTimeoutMs: 60_000,
+ emit: () => {},
+ warn: () => {},
+};
+
+function service(gateway: Partial, pipeline?: Partial) {
+ const g = gateway as TronGateway;
+ const gateways = { get: () => g } as unknown as ChainGatewayProvider;
+ const pipe = { run: async () => ({ stage: "submitted", txId: "txid" }), ...pipeline } as unknown as TxPipeline;
+ // voting power now comes from the injected stake service (reads getAccountResources).
+ return new TronVoteService(gateways, pipe, new TronStakeService(gateways, pipe));
+}
+
+describe("TronVoteService.cast", () => {
+ it("rejects a full vote allocation above voting power", async () => {
+ const svc = service({
+ getAccountResources: async () => ({ tronPowerLimit: 100, tronPowerUsed: 0 } as never),
+ });
+ await expect(svc.cast(scope, NET, { for: [`${SR1}=101`] })).rejects.toMatchObject({
+ code: "insufficient_voting_power",
+ });
+ });
+
+ it("echoes parsed votes and total votes after pipeline submission", async () => {
+ const svc = service({
+ getAccountResources: async () => ({ tronPowerLimit: 1000, tronPowerUsed: 0 } as never),
+ buildVoteWitness: async () => ({}),
+ });
+ await expect(svc.cast(scope, NET, { for: [`${SR1}=6`, `${SR2}=4`] })).resolves.toMatchObject({
+ kind: "vote-cast",
+ stage: "submitted",
+ txId: "txid",
+ totalVotes: 10,
+ votes: [
+ { witness: SR1, count: 6 },
+ { witness: SR2, count: 4 },
+ ],
+ });
+ });
+});
+
+describe("TronVoteService.list/status", () => {
+ it("sorts witnesses by vote count and converts brokerage to reward ratio", async () => {
+ const svc = service({
+ getWitnesses: async () => [
+ { address: SR1, voteCount: "100", url: "https://alpha.example" },
+ { address: SR2, voteCount: "200", url: "https://beta.example" },
+ ],
+ getBrokerage: async (address) => address === SR2 ? 100 : 20,
+ });
+ await expect(svc.list(NET, { limit: 2 })).resolves.toEqual({
+ witnesses: [
+ { rank: 1, name: "beta.example", address: SR2, voteCount: "200", rewardRatioPct: 0, brokeragePct: 100, aprPct: null },
+ { rank: 2, name: "alpha.example", address: SR1, voteCount: "100", rewardRatioPct: 80, brokeragePct: 20, aprPct: null },
+ ],
+ });
+ });
+
+ it("reports current voting power, reward, votes, and warns via scope.warn on a zero reward ratio", async () => {
+ const warnings: string[] = [];
+ const warnScope = { ...scope, warn: (m: string) => warnings.push(m) };
+ const svc = service({
+ getAccount: async () => ({ votes: [{ vote_address: SR2, vote_count: "400" }] }),
+ getAccountResources: async () => ({ tronPowerLimit: 1500, tronPowerUsed: 400 } as never),
+ getReward: async () => "12345678",
+ getWitnesses: async () => [{ address: SR2, voteCount: "200", url: "https://beta.example" }],
+ getBrokerage: async () => 100,
+ });
+ const result = await svc.status(warnScope, NET);
+ expect(result).toMatchObject({
+ address: OWNER,
+ votingPower: { total: 1500, used: 400, available: 1100 },
+ claimableRewardSun: "12345678",
+ votes: [{ witness: SR2, name: "beta.example", count: 400, rewardRatioPct: 0, brokeragePct: 100, aprPct: null }],
+ });
+ // zero-ratio warning now goes through the standard warn channel, not a data key.
+ expect(result).not.toHaveProperty("__walletCliWarnings");
+ expect(warnings).toEqual([`400 votes on ${SR2} (beta.example) earn nothing: reward ratio is 0%`]);
+ });
+});
diff --git a/ts/src/application/use-cases/tron/vote-service.ts b/ts/src/application/use-cases/tron/vote-service.ts
new file mode 100644
index 00000000..59b77461
--- /dev/null
+++ b/ts/src/application/use-cases/tron/vote-service.ts
@@ -0,0 +1,270 @@
+import type { NetworkDescriptor, UnsignedTx } from "../../../domain/types/index.js";
+import { UsageError } from "../../../domain/errors/index.js";
+import { addressCodec } from "../../../domain/family/index.js";
+import { tronHexToBase58 } from "../../../domain/address/index.js";
+import type { TransactionScope } from "../../contracts/execution-scope.js";
+import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js";
+import type {
+ TronAccount,
+ TronGateway,
+ TronVote,
+ TronVoteAllocation,
+ TronWitness,
+} from "../../ports/chain/tron-gateway.js";
+import type { TxPipeline } from "../../services/pipeline/index.js";
+import { outcomeData, transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js";
+import { tronConfirmation } from "../../services/tron-confirmation.js";
+import { TronStakeService } from "./stake-service.js";
+
+const MAX_VOTE_ITEMS = 30;
+const MAX_REWARDED_RANK = 127;
+const ELECTED_SR_COUNT = 27;
+/** cap on concurrent getBrokerage RPCs so `vote list --limit 127` doesn't open 127 sockets at once. */
+const BROKERAGE_CONCURRENCY = 8;
+
+export interface VoteCastInput extends TransactionModeInput {
+ for: string[];
+}
+
+export interface VoteListInput {
+ limit: number;
+ candidates?: boolean;
+}
+
+interface VoteReadScope {
+ readonly activeAccount: TransactionScope["activeAccount"];
+ resolveAddress(family: "tron"): string;
+ /** surface a non-fatal warning (→ meta.warnings + stderr), same channel as --wait warnings. */
+ warn(message: string): void;
+}
+
+/** per-request brokerage cache: dedupes repeat addresses (a current vote's SR also in the list). */
+type BrokerageCache = Map>;
+
+interface WitnessView {
+ rank: number;
+ name: string;
+ address: string;
+ voteCount: string;
+ rewardRatioPct: number | null;
+ brokeragePct: number | null;
+ aprPct: number | null;
+}
+
+interface CurrentVoteView {
+ witness: string;
+ name: string;
+ count: number;
+ rewardRatioPct: number | null;
+ brokeragePct: number | null;
+ aprPct: number | null;
+}
+
+export class TronVoteService {
+ constructor(
+ private readonly gateways: ChainGatewayProvider,
+ private readonly pipeline: TxPipeline,
+ /** voting power (TP) is read authoritatively from the node via the stake service —
+ * the same source `stake info` uses — rather than recomputed from raw account balances. */
+ private readonly stake: TronStakeService,
+ ) {}
+
+ async cast(scope: TransactionScope, network: NetworkDescriptor, input: VoteCastInput) {
+ const gateway = this.gateways.get(network, "tron");
+ const votes = parseVoteInputs(input.for);
+ const totalVotes = votes.reduce((sum, vote) => sum + BigInt(vote.count), 0n);
+ if (totalVotes > BigInt(Number.MAX_SAFE_INTEGER)) {
+ throw new UsageError("invalid_value", "total votes exceed the safe-integer limit for this client");
+ }
+ const owner = scope.resolveAddress("tron");
+ const votingPower = await this.stake.votingPower(network, owner);
+ if (totalVotes > BigInt(votingPower.total)) {
+ throw new UsageError(
+ "insufficient_voting_power",
+ `total votes ${totalVotes.toString()} exceed voting power ${votingPower.total} TP`,
+ );
+ }
+ const outcome = await this.pipeline.run({
+ ctx: scope,
+ net: network,
+ account: scope.activeAccount,
+ broadcaster: gateway,
+ ...transactionMode(input),
+ confirm: tronConfirmation(gateway, scope),
+ build: (ownerAddress) => gateway.buildVoteWitness(ownerAddress, votes),
+ estimate: async (_tx: UnsignedTx) => ({ feeModel: "tron-resource", note: "voting uses bandwidth only" }),
+ });
+ return {
+ kind: "vote-cast" as const,
+ ...outcomeData(outcome),
+ votes: votes.map((vote) => ({ witness: vote.witness, count: Number(vote.count) })),
+ totalVotes: Number(totalVotes),
+ };
+ }
+
+ async list(network: NetworkDescriptor, input: VoteListInput) {
+ const gateway = this.gateways.get(network, "tron");
+ const limit = Math.min(input.limit, input.candidates ? MAX_REWARDED_RANK : ELECTED_SR_COUNT);
+ const witnesses = (await gateway.getWitnesses(limit))
+ .sort((a, b) => compareUnsigned(b.voteCount, a.voteCount))
+ .slice(0, limit);
+ const cache: BrokerageCache = new Map();
+ return {
+ witnesses: await mapWithLimit(witnesses, BROKERAGE_CONCURRENCY, (witness, index) =>
+ this.witnessView(gateway, witness, index + 1, cache),
+ ),
+ };
+ }
+
+ async status(scope: VoteReadScope, network: NetworkDescriptor) {
+ const gateway = this.gateways.get(network, "tron");
+ const address = scope.resolveAddress("tron");
+ const [account, claimableRewardSun, witnesses, votingPower] = await Promise.all([
+ gateway.getAccount(address),
+ gateway.getReward(address),
+ gateway.getWitnesses(MAX_REWARDED_RANK).catch((): TronWitness[] => []),
+ this.stake.votingPower(network, address),
+ ]);
+ const witnessMap = new Map(witnesses.map((witness, index) => [witness.address, { witness, rank: index + 1 }]));
+ const currentVotes = normalizeAccountVotes(account);
+ const cache: BrokerageCache = new Map();
+ const votes = await mapWithLimit(currentVotes, BROKERAGE_CONCURRENCY, async (vote): Promise => {
+ const known = witnessMap.get(vote.witness);
+ const brokeragePct = await brokerageOf(gateway, vote.witness, cache);
+ return {
+ witness: vote.witness,
+ name: known ? witnessName(known.witness) : vote.witness,
+ count: Number(vote.count),
+ brokeragePct,
+ rewardRatioPct: rewardRatioOf(brokeragePct),
+ aprPct: null,
+ };
+ });
+ // zero-reward-ratio votes earn nothing: warn via the standard channel (→ meta.warnings + stderr).
+ for (const vote of votes) {
+ if (vote.rewardRatioPct === 0) scope.warn(zeroRatioWarning(vote));
+ }
+ return {
+ address,
+ votingPower,
+ claimableRewardSun,
+ votes,
+ };
+ }
+
+ private async witnessView(gateway: TronGateway, witness: TronWitness, rank: number, cache: BrokerageCache): Promise {
+ const brokeragePct = await brokerageOf(gateway, witness.address, cache);
+ return {
+ rank,
+ name: witnessName(witness),
+ address: witness.address,
+ voteCount: witness.voteCount,
+ rewardRatioPct: rewardRatioOf(brokeragePct),
+ brokeragePct,
+ aprPct: null,
+ };
+ }
+}
+
+function parseVoteInputs(values: string[]): TronVote[] {
+ if (values.length === 0) throw new UsageError("invalid_value", "--for must include at least one SR=votes entry");
+ if (values.length > MAX_VOTE_ITEMS) throw new UsageError("invalid_value", "--for accepts at most 30 entries");
+ const codec = addressCodec("tron");
+ const seen = new Set();
+ return values.map((entry) => {
+ const separator = entry.lastIndexOf("=");
+ if (separator <= 0 || separator === entry.length - 1) {
+ throw new UsageError("invalid_value", `invalid --for entry '${entry}'; expected SR=votes`);
+ }
+ const witness = entry.slice(0, separator).trim();
+ const count = entry.slice(separator + 1).trim();
+ if (!codec.validate(witness)) throw new UsageError("invalid_value", `invalid witness address: ${witness}`);
+ if (seen.has(witness)) throw new UsageError("invalid_value", `duplicate witness address: ${witness}`);
+ seen.add(witness);
+ if (!/^\d+$/.test(count) || /^0+$/.test(count)) {
+ throw new UsageError("invalid_value", `vote count for ${witness} must be a positive integer`);
+ }
+ if (BigInt(count) > BigInt(Number.MAX_SAFE_INTEGER)) {
+ throw new UsageError("invalid_value", `vote count for ${witness} exceeds the safe-integer limit for this client`);
+ }
+ return { witness, count };
+ });
+}
+
+function normalizeAccountVotes(account: TronAccount): TronVote[] {
+ return (account.votes ?? [])
+ .map(normalizeAccountVote)
+ .filter((vote): vote is TronVote => vote !== null);
+}
+
+function normalizeAccountVote(vote: TronVoteAllocation): TronVote | null {
+ const witness = tronHexToBase58(vote.vote_address ?? vote.voteAddress);
+ const count = quantityString(vote.vote_count ?? vote.voteCount);
+ if (!witness || count === "0") return null;
+ return { witness, count };
+}
+
+function zeroRatioWarning(vote: CurrentVoteView): string {
+ return `${vote.count} votes on ${vote.witness} (${vote.name}) earn nothing: reward ratio is 0%`;
+}
+
+/** getBrokerage with a per-request cache — a given SR is fetched at most once, and failures
+ * degrade to null (unknown reward ratio). */
+function brokerageOf(gateway: TronGateway, address: string, cache: BrokerageCache): Promise {
+ let pending = cache.get(address);
+ if (!pending) {
+ pending = gateway.getBrokerage(address).catch(() => null);
+ cache.set(address, pending);
+ }
+ return pending;
+}
+
+/** map with bounded concurrency (results keep input order), so a large witness list doesn't
+ * fan out one socket per item. */
+async function mapWithLimit(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise {
+ const results = new Array(items.length);
+ let next = 0;
+ const worker = async () => {
+ while (next < items.length) {
+ const index = next++;
+ results[index] = await fn(items[index]!, index);
+ }
+ };
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
+ return results;
+}
+
+function rewardRatioOf(brokeragePct: number | null): number | null {
+ if (brokeragePct === null || !Number.isFinite(brokeragePct)) return null;
+ return Math.max(0, Math.min(100, 100 - brokeragePct));
+}
+
+function witnessName(witness: TronWitness): string {
+ const url = witness.url?.trim();
+ if (!url) return witness.address;
+ try {
+ const parsed = new URL(url);
+ return parsed.hostname || url;
+ } catch {
+ return url.replace(/^https?:\/\//i, "").replace(/\/+$/, "") || witness.address;
+ }
+}
+
+function compareUnsigned(a: unknown, b: unknown): number {
+ const left = quantity(a);
+ const right = quantity(b);
+ if (left === right) return 0;
+ return left > right ? 1 : -1;
+}
+
+function quantityString(value: unknown): string {
+ const amount = quantity(value);
+ return amount.toString();
+}
+
+function quantity(value: unknown): bigint {
+ if (typeof value === "bigint") return value >= 0n ? value : 0n;
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : 0n;
+ if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
+ return 0n;
+}
diff --git a/ts/src/application/use-cases/wallet-service.ts b/ts/src/application/use-cases/wallet-service.ts
index 222eca52..61607cc7 100644
--- a/ts/src/application/use-cases/wallet-service.ts
+++ b/ts/src/application/use-cases/wallet-service.ts
@@ -63,6 +63,10 @@ export class WalletService {
return { previousLabel: result.previousLabel, ...this.wallets.describe(result.accountId) };
}
+ changePassword(oldPassword: string, newPassword: string) {
+ return this.wallets.changePassword(oldPassword, newPassword);
+ }
+
derive(seedId: string, index?: number, label?: string) {
// --seed-id is strictly the seed id (wlt_…) — the HD group header in `list`. No labels, no
// sub-account refs: labels/refs point at an account, and the seed (not an account) is the root.
diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts
index 0cf7c3ff..bc2d4546 100644
--- a/ts/src/bootstrap/composition.ts
+++ b/ts/src/bootstrap/composition.ts
@@ -1,5 +1,5 @@
import type { OutputMode } from "../domain/types/index.js";
-import type { ChainModule, Globals, SessionRef } from "../adapters/inbound/cli/contracts/index.js";
+import type { Globals, SessionRef } from "../adapters/inbound/cli/contracts/index.js";
import { ConfigLoader, NetworkRegistry } from "../adapters/outbound/config/index.js";
import { YamlConfigDocument } from "../adapters/outbound/config/yaml-config-document.js";
import {
@@ -28,6 +28,7 @@ import { TxPipeline } from "../application/services/pipeline/index.js";
import { ConfigService } from "../application/use-cases/config-service.js";
import { WalletService } from "../application/use-cases/wallet-service.js";
import { FAMILY_REGISTRY, familyMap } from "./family-registry.js";
+import { registerTronChainCommands } from "./families/tron.js";
export interface BootstrapOptions {
readonly globals: Globals;
@@ -77,17 +78,14 @@ export function composeCliRuntime(options: BootstrapOptions) {
registerWalletCommands(registry, { walletService, ledger });
registerConfigCommands(registry, configService);
registerNetworkCommands(registry);
- const chainModules: ChainModule[] = FAMILY_REGISTRY.map((definition) =>
- definition.createModule({
- gateways: gatewayProvider,
- tokens: tokenBook,
- prices: priceProvider,
- signers: signerResolver,
- transactions: txPipeline,
- timeoutMs,
- }),
- );
- for (const module of chainModules) module.registerCommands(registry);
+ registerTronChainCommands(registry, {
+ gateways: gatewayProvider,
+ tokens: tokenBook,
+ prices: priceProvider,
+ signers: signerResolver,
+ transactions: txPipeline,
+ timeoutMs,
+ });
const capabilitiesByFamily = registry.capabilityKeysByFamily();
for (const network of Object.values(config.networks)) {
diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts
index fe51430f..f9c4cb72 100644
--- a/ts/src/bootstrap/families/tron.ts
+++ b/ts/src/bootstrap/families/tron.ts
@@ -2,33 +2,143 @@ import { FAMILIES } from "../../domain/family/index.js";
import { tronSignStrategy } from "../../adapters/outbound/chain/tron/signing-strategy.js";
import { TronRpcClient } from "../../adapters/outbound/chain/tron/tron.js";
import { TronGridHistoryReader } from "../../adapters/outbound/chain/tron/history-reader.js";
-import { TronModule } from "../../adapters/inbound/cli/commands/tron/index.js";
+import { blockSpec, blockTronBinding } from "../../adapters/inbound/cli/commands/block.js";
+import {
+ accountBalanceSpec,
+ accountBalanceTronBinding,
+ accountHistorySpec,
+ accountHistoryTronBinding,
+ accountInfoSpec,
+ accountInfoTronBinding,
+ accountPortfolioSpec,
+ accountPortfolioTronBinding,
+} from "../../adapters/inbound/cli/commands/account.js";
+import {
+ tokenAddSpec,
+ tokenAddTronBinding,
+ tokenBalanceSpec,
+ tokenBalanceTronBinding,
+ tokenInfoSpec,
+ tokenInfoTronBinding,
+ tokenListSpec,
+ tokenListTronBinding,
+ tokenRemoveSpec,
+ tokenRemoveTronBinding,
+} from "../../adapters/inbound/cli/commands/token.js";
+import { messageSignSpec, messageSignBinding } from "../../adapters/inbound/cli/commands/shared.js";
+import {
+ txBroadcastSpec,
+ txBroadcastTronBinding,
+ txInfoSpec,
+ txInfoTronBinding,
+ txSendSpec,
+ txSendTronBinding,
+ txStatusSpec,
+ txStatusTronBinding,
+} from "../../adapters/inbound/cli/commands/tx.js";
+import { stakeDefinitions } from "../../adapters/inbound/cli/commands/stake.js";
+import { chainDefinitions } from "../../adapters/inbound/cli/commands/chain.js";
+import {
+ voteCastSpec,
+ voteCastTronBinding,
+ voteListSpec,
+ voteListTronBinding,
+ voteStatusSpec,
+ voteStatusTronBinding,
+} from "../../adapters/inbound/cli/commands/vote.js";
+import {
+ rewardBalanceSpec,
+ rewardBalanceTronBinding,
+ rewardWithdrawSpec,
+ rewardWithdrawTronBinding,
+} from "../../adapters/inbound/cli/commands/reward.js";
+import {
+ contractCallSpec,
+ contractCallTronBinding,
+ contractDeploySpec,
+ contractDeployTronBinding,
+ contractInfoSpec,
+ contractInfoTronBinding,
+ contractSendSpec,
+ contractSendTronBinding,
+} from "../../adapters/inbound/cli/commands/contract.js";
+import type { CommandRegistry } from "../../adapters/inbound/cli/registry/index.js";
import { TronAccountService } from "../../application/use-cases/tron/account-service.js";
import { TronTokenService } from "../../application/use-cases/tron/token-service.js";
import { TronTransactionService } from "../../application/use-cases/tron/transaction-service.js";
import { TronContractService } from "../../application/use-cases/tron/contract-service.js";
import { TronStakeService } from "../../application/use-cases/tron/stake-service.js";
+import { TronVoteService } from "../../application/use-cases/tron/vote-service.js";
+import { TronRewardService } from "../../application/use-cases/tron/reward-service.js";
+import { TronChainService } from "../../application/use-cases/tron/chain-service.js";
import { TronBlockService } from "../../application/use-cases/tron/block-service.js";
import { MessageService } from "../../application/use-cases/message-service.js";
+import type { ChainGatewayProvider } from "../../application/ports/chain/gateway-provider.js";
+import type { TokenRepository } from "../../application/ports/token-repository.js";
+import type { PriceProvider } from "../../application/ports/price-provider.js";
+import type { SignerResolver } from "../../application/services/signer/index.js";
+import type { TxPipeline } from "../../application/services/pipeline/index.js";
import type { FamilyPlugin } from "./types.js";
export const tronFamily: FamilyPlugin<"tron"> = {
meta: FAMILIES.tron,
signStrategy: tronSignStrategy,
createGateway: (network, timeoutMs) => new TronRpcClient(network.httpEndpoint ?? "", timeoutMs),
- createModule: ({ gateways, tokens, prices, signers, transactions, timeoutMs }) =>
- new TronModule({
- tronAccount: new TronAccountService(
- gateways,
- new TronGridHistoryReader(timeoutMs),
- tokens,
- prices,
- ),
- tronToken: new TronTokenService(gateways, tokens),
- tronTransaction: new TronTransactionService(gateways, tokens, transactions),
- tronContract: new TronContractService(gateways, transactions),
- tronStake: new TronStakeService(gateways, transactions),
- tronBlock: new TronBlockService(gateways),
- message: new MessageService(signers),
- }),
};
+
+export interface TronChainCommandDependencies {
+ gateways: ChainGatewayProvider;
+ tokens: TokenRepository;
+ prices: PriceProvider;
+ signers: SignerResolver;
+ transactions: TxPipeline;
+ timeoutMs: number;
+}
+
+export function registerTronChainCommands(reg: CommandRegistry, deps: TronChainCommandDependencies): void {
+ const account = new TronAccountService(
+ deps.gateways,
+ new TronGridHistoryReader(deps.timeoutMs),
+ deps.tokens,
+ deps.prices,
+ );
+ const token = new TronTokenService(deps.gateways, deps.tokens);
+ const message = new MessageService(deps.signers);
+ const transaction = new TronTransactionService(deps.gateways, deps.tokens, deps.transactions);
+ const stake = new TronStakeService(deps.gateways, deps.transactions);
+ const vote = new TronVoteService(deps.gateways, deps.transactions, stake);
+ const reward = new TronRewardService(deps.gateways, deps.transactions);
+ const chain = new TronChainService(deps.gateways);
+ const contract = new TronContractService(deps.gateways, deps.transactions);
+
+ reg.addChain(blockSpec, "tron", blockTronBinding(new TronBlockService(deps.gateways)));
+ reg.addChain(accountBalanceSpec, "tron", accountBalanceTronBinding(account));
+ reg.addChain(accountInfoSpec, "tron", accountInfoTronBinding(account));
+ reg.addChain(accountHistorySpec, "tron", accountHistoryTronBinding(account));
+ reg.addChain(accountPortfolioSpec, "tron", accountPortfolioTronBinding(account));
+ reg.addChain(tokenBalanceSpec, "tron", tokenBalanceTronBinding(token));
+ reg.addChain(tokenInfoSpec, "tron", tokenInfoTronBinding(token));
+ reg.addChain(tokenAddSpec, "tron", tokenAddTronBinding(token));
+ reg.addChain(tokenListSpec, "tron", tokenListTronBinding(token));
+ reg.addChain(tokenRemoveSpec, "tron", tokenRemoveTronBinding(token));
+ reg.addChain(messageSignSpec, "tron", messageSignBinding(message));
+ reg.addChain(txSendSpec, "tron", txSendTronBinding(transaction));
+ reg.addChain(txBroadcastSpec, "tron", txBroadcastTronBinding(transaction));
+ reg.addChain(txStatusSpec, "tron", txStatusTronBinding(transaction));
+ reg.addChain(txInfoSpec, "tron", txInfoTronBinding(transaction));
+ for (const definition of stakeDefinitions(stake)) {
+ reg.addChain(definition.spec, "tron", definition.binding);
+ }
+ reg.addChain(voteCastSpec, "tron", voteCastTronBinding(vote));
+ reg.addChain(voteListSpec, "tron", voteListTronBinding(vote));
+ reg.addChain(voteStatusSpec, "tron", voteStatusTronBinding(vote));
+ reg.addChain(rewardBalanceSpec, "tron", rewardBalanceTronBinding(reward));
+ reg.addChain(rewardWithdrawSpec, "tron", rewardWithdrawTronBinding(reward));
+ for (const definition of chainDefinitions(chain)) {
+ reg.addChain(definition.spec, "tron", definition.binding);
+ }
+ reg.addChain(contractCallSpec, "tron", contractCallTronBinding(contract));
+ reg.addChain(contractSendSpec, "tron", contractSendTronBinding(contract));
+ reg.addChain(contractDeploySpec, "tron", contractDeployTronBinding(contract));
+ reg.addChain(contractInfoSpec, "tron", contractInfoTronBinding(contract));
+}
diff --git a/ts/src/bootstrap/families/types.ts b/ts/src/bootstrap/families/types.ts
index 77bbec25..315dd397 100644
--- a/ts/src/bootstrap/families/types.ts
+++ b/ts/src/bootstrap/families/types.ts
@@ -1,30 +1,11 @@
import type { NetworkDescriptor, SignStrategy } from "../../domain/types/index.js";
-import type { ChainModule } from "../../adapters/inbound/cli/contracts/index.js";
import type { ChainFamily, FamilyMeta } from "../../domain/family/index.js";
-import type {
- ChainGatewayMap,
- ChainGatewayProvider,
-} from "../../application/ports/chain/gateway-provider.js";
-import type { PriceProvider } from "../../application/ports/price-provider.js";
-import type { TokenRepository } from "../../application/ports/token-repository.js";
-import type { SignerResolver } from "../../application/services/signer/index.js";
-import type { TxPipeline } from "../../application/services/pipeline/index.js";
-
-export interface FamilyApplicationDependencies {
- gateways: ChainGatewayProvider;
- tokens: TokenRepository;
- prices: PriceProvider;
- signers: SignerResolver;
- transactions: TxPipeline;
- /** effective per-invocation RPC timeout, for adapters the module constructs itself (history reader). */
- timeoutMs: number;
-}
+import type { ChainGatewayMap } from "../../application/ports/chain/gateway-provider.js";
export interface FamilyPlugin {
readonly meta: FamilyMeta & { family: F };
readonly signStrategy: SignStrategy;
createGateway(network: NetworkDescriptor, timeoutMs: number): ChainGatewayMap[F];
- createModule(dependencies: FamilyApplicationDependencies): ChainModule;
}
export type AnyFamilyPlugin = {
diff --git a/ts/src/bootstrap/runner.test.ts b/ts/src/bootstrap/runner.test.ts
index 3496d618..aa58bfbc 100644
--- a/ts/src/bootstrap/runner.test.ts
+++ b/ts/src/bootstrap/runner.test.ts
@@ -47,10 +47,9 @@ describe("parseGlobals", () => {
});
it("maps ---stdin to a '-' path (the only secret source)", () => {
- const { secretPaths } = parseGlobals(["import", "mnemonic", "--mnemonic-stdin"]);
- expect(secretPaths.mnemonic).toBe("-");
+ const { secretPaths } = parseGlobals(["tx", "broadcast", "--tx-stdin"]);
+ expect(secretPaths.tx).toBe("-");
expect(secretPaths.password).toBeUndefined();
- expect(secretPaths.privateKey).toBeUndefined();
});
it("ignores a value flag with no following token at end of argv", () => {
diff --git a/ts/src/domain/types/network.ts b/ts/src/domain/types/network.ts
index 389f3bee..b83dc51b 100644
--- a/ts/src/domain/types/network.ts
+++ b/ts/src/domain/types/network.ts
@@ -38,6 +38,8 @@ export interface Config {
defaultNetwork?: string;
defaultOutput: OutputMode;
timeoutMs: number;
+ /** default polling cap for broadcast commands' --wait, in ms (overridden by --wait-timeout). */
+ waitTimeoutMs: number;
networks: Record;
/** USD-valuation source for `account portfolio`. Missing → builtin CoinGecko. */
price?: PriceConfig;
diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts
index 193819e2..df9a76de 100644
--- a/ts/src/domain/types/tx.ts
+++ b/ts/src/domain/types/tx.ts
@@ -54,7 +54,8 @@ export interface TxParties { from?: string; to?: string; amount?: string; symbol
export type TxReceiptKind =
| "send" | "broadcast"
| "stake-freeze" | "stake-unfreeze" | "stake-delegate" | "stake-undelegate" | "stake-withdraw" | "stake-cancel"
- | "contract-send" | "contract-deploy";
+ | "contract-send" | "contract-deploy"
+ | "vote-cast" | "reward-withdraw";
/**
* Canonical tx receipt the signing commands return (dry-run / sign-only / broadcast stages).
@@ -82,6 +83,9 @@ export interface TxReceiptView {
to?: string;
receiver?: string;
resource?: string;
+ votes?: Array<{ witness: string; count: number }>;
+ totalVotes?: number;
+ rewardSun?: string | number;
// contract
method?: string;
contractAddress?: string;
diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts
index 6f5998fa..17d1f1e0 100644
--- a/ts/test/golden.test.ts
+++ b/ts/test/golden.test.ts
@@ -118,13 +118,14 @@ describe("golden CLI — meta & introspection", () => {
expect(globalFlags).toContain("--password-stdin");
expect(globalFlags).not.toContain("--mnemonic-stdin");
expect(r.json.aliases).toBeUndefined();
- const cmd = r.json.commands.find((c: { id: string }) => c.id === "tron.tx.send");
+ const cmd = r.json.commands.find((c: { id: string }) => c.id === "tx.send");
expect(cmd.usage).toBe("wallet-cli tx send [options]");
expect(cmd.requires).toMatchObject({ network: "optional", auth: "required", wallet: "optional" });
expect(cmd.inputSchema.properties.to).toBeDefined();
const importMnemonic = r.json.commands.find((c: { id: string }) => c.id === "import.mnemonic");
- expect(importMnemonic.inputFlags.map((g: { flag: string }) => g.flag)).toContain("--mnemonic-stdin");
- const broadcast = r.json.commands.find((c: { id: string }) => c.id === "tron.tx.broadcast");
+ // TTY-only setup op: the mnemonic is entered interactively, so there is no --*-stdin input flag.
+ expect(importMnemonic.inputFlags).toBeUndefined();
+ const broadcast = r.json.commands.find((c: { id: string }) => c.id === "tx.broadcast");
expect(broadcast.inputFlags.map((g: { flag: string }) => g.flag)).toContain("--tx-stdin");
const importWatch = r.json.commands.find((c: { id: string }) => c.id === "import.watch");
expect(importWatch.usage).toBe("wallet-cli import watch [options]");
@@ -134,7 +135,7 @@ describe("golden CLI — meta & introspection", () => {
const r = run(["tron", "--json-schema"], { password: null });
expect(r.status).toBe(0);
expect(r.json.commands.length).toBeGreaterThan(0);
- expect(r.json.commands.every((c: { kind: string; family: string }) => c.kind === "chain" && c.family === "tron")).toBe(true);
+ expect(r.json.commands.every((c: { kind: string; family?: string; families?: string[] }) => c.kind === "chain" && (c.family === "tron" || (c.families?.includes("tron") ?? false)))).toBe(true);
});
it("config shorthand shows, reads, and writes defaultNetwork", () => {
@@ -175,7 +176,7 @@ describe("golden CLI — wallet lifecycle (shared identity)", () => {
seedWallet();
const r = run(["--output", "json", "message", "sign", "--network", "tron:nile", "--message", "hello world"]);
expect(r.status).toBe(0);
- expect(r.json.command).toBe("tron.message.sign");
+ expect(r.json.command).toBe("message.sign");
expect(r.json.chain.network).toBe("tron:nile");
});
@@ -183,7 +184,7 @@ describe("golden CLI — wallet lifecycle (shared identity)", () => {
seedWallet();
const r = run(["--output", "json", "tx", "send", "--network", "tron:nile", "--to", TRON1, "--amount", "0.0000000000000000001", "--dry-run"]);
expect(r.status).toBe(2);
- expect(r.json.command).toBe("tron.tx.send");
+ expect(r.json.command).toBe("tx.send");
expect(r.json.error.code).toBe("invalid_amount");
});
@@ -194,7 +195,7 @@ describe("golden CLI — wallet lifecycle (shared identity)", () => {
"--token", "USDT", "--amount", "0.0000001", "--dry-run",
]);
expect(r.status).toBe(2);
- expect(r.json.command).toBe("tron.tx.send");
+ expect(r.json.command).toBe("tx.send");
expect(r.json.error.code).toBe("invalid_amount");
});
@@ -382,6 +383,29 @@ describe("golden CLI — error contract (exit codes)", () => {
expect(r.status).toBe(1);
expect(r.json.error.code).toBe("auth_required");
});
+
+ it("vote cast collects a repeated --for into an array (same SR twice → duplicate, exit 2)", () => {
+ seedWallet();
+ // Proves the CLI collects a repeated flag into an array. If --for were last-wins, only one
+ // entry would reach the service and there'd be no duplicate; the duplicate error confirms both
+ // repeated flags arrived. `parseVoteInputs` runs before any RPC, so this needs no network.
+ const r = run(["--output", "json", "vote", "cast", "--network", "tron:nile",
+ "--for", `${TRON1}=5`, "--for", `${TRON1}=5`, "--dry-run"]);
+ expect(r.status).toBe(2);
+ expect(r.json.error.code).toBe("invalid_value");
+ expect(r.json.error.message).toMatch(/duplicate/i);
+ });
+
+ it("vote cast delivers a SINGLE --for as a one-element array, not a split string", () => {
+ seedWallet();
+ // A lone `--for foo` (no '='): as a one-element array the whole "foo" is one bad entry; as a
+ // bare string the service would iterate its characters and complain about 'f'. An error naming
+ // the whole 'foo' proves yargs `array: true` delivered [ "foo" ]. No RPC (parse fails first).
+ const r = run(["--output", "json", "vote", "cast", "--network", "tron:nile", "--for", "foo", "--dry-run"]);
+ expect(r.status).toBe(2);
+ expect(r.json.error.code).toBe("invalid_value");
+ expect(r.json.error.message).toContain("'foo'");
+ });
});
describe("golden CLI — token address-book (local, no RPC)", () => {
@@ -401,7 +425,7 @@ describe("golden CLI — token address-book (local, no RPC)", () => {
seedWallet();
const r = run(["--output", "json", "token", "list"]);
expect(r.status).toBe(0);
- expect(r.json.command).toBe("tron.token.list");
+ expect(r.json.command).toBe("token.list");
expect(r.json.chain.network).toBe("tron:mainnet");
});