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"); });