diff --git a/README.md b/README.md index 73c689ffd..e074bb382 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ This repository holds **two independent implementations** that share the same purpose but target different users: -- **[Java](java/README.md)** — the original, full-featured reference CLI. An interactive prompt (REPL) for people who want the complete TRON feature surface. +- **[Java](java/README.md)** — the original, full-featured reference CLI. An interactive prompt (REPL) you drive by hand. - **[TypeScript](ts/README.md)** — an agent-first rewrite for automation. Standard subcommands with a stable JSON envelope, built for scripts, CI, and AI agents. -Both manage the same kind of wallet on the same networks — your address is identical regardless of which you use. They differ in how you install and drive them, and in how much of TRON they cover. Pick one and read its own README for depth; this page gives you the basics of each so you can choose. +Both manage the same kind of wallet on the same networks — your address is identical regardless of which you use. They cover the same TRON feature surface and differ in how you install and drive them. Pick one and read its own README for depth; this page gives you the basics of each so you can choose. ## At a glance @@ -31,7 +31,7 @@ Both manage the same kind of wallet on the same networks — your address is ide | **Output for scripts** | Human-readable text. | Stable JSON via `-o json` ([`wallet-cli.result.v1`](ts/docs/machine-interface.md)) + fixed exit codes (`0`/`1`/`2`). | | **Config / networks** | `config.conf` (net type + full node), or `SwitchNetwork` at runtime. Mainnet · Nile · Shasta · custom. | `--network` flag / `config` command. `tron:mainnet` · `tron:nile` · `tron:shasta`. | | **Signing** | Software keystore · Ledger. | Encrypted local keystore · Ledger. Secrets never via argv/env. | -| **Feature scope** | **The full surface** — everything in the TypeScript column, plus TRC10 token issuance and on-chain DEX & governance/proposals. | **Core wallet ops** — HD wallets, TRX/TRC20/TRC10 transfers, staking & delegation, voting & rewards, contract call/deploy, multi-sig, GasFree transfers, message signing, and on-chain queries. | +| **Feature scope** | **The full surface** — wallets and transfers, staking, voting and rewards, governance, contracts, TRC10, and the on-chain exchange. | **The full surface** — HD wallets, TRX/TRC20/TRC10 transfers, staking & delegation, voting & rewards, governance proposals & super-representative operation, contract call/deploy/governance, TRC10 issuance, the on-chain Bancor exchange, multi-sig, GasFree transfers, message signing, and on-chain queries. | | **Best for** | People at a terminal who want every TRON capability. | Scripting, CI pipelines, and AI agents. | | **Full docs** | [java/README.md](java/README.md) | [ts/README.md](ts/README.md) | @@ -49,7 +49,7 @@ $ java -jar wallet-cli.jar # opens the interactive prompt > GetBalance # TRX balance ``` -Full setup (config.conf, connecting to a node), the complete A–Z command list, and features like GasFree and multi-sig live in **[java/README.md](java/README.md)** — jump to [Setup](java/README.md#setup), [Quickstart](java/README.md#quickstart), [Commands](java/README.md#commands), or [GasFree](java/README.md#gasfree). +Full setup (config.conf, connecting to a node), the complete A–Z command list, and features like GasFree and multi-sig live in **[java/README.md](java/README.md)** — jump to [Setup](java/README.md#setup), [Quickstart](java/README.md#quickstart), [Commands](java/README.md#commands), or [GasFree](java/README.md#contracts-gasfree--chain-data). ## TypeScript — get a taste @@ -72,5 +72,5 @@ Every command has a reference page, and the JSON contract, exit codes, and agent ## Which should I use? - **Scripting, CI, or building an AI agent?** → the [TypeScript version](ts/README.md) — the JSON envelope and deterministic exit codes exist for exactly this. -- **Working interactively and want the complete TRON toolkit** — TRC10 issuance, or on-chain DEX/governance/proposals? → the [Java version](java/README.md). +- **Working interactively** — one long-running session at a `>` prompt, with the wallet unlocked once for the whole session? → the [Java version](java/README.md). - **Just sending TRX/tokens or staking from your own machine?** → either works; the TypeScript CLI is the lighter install (`npm install -g`, no build step). diff --git a/java/docs/commands/index.md b/java/docs/commands/index.md index b13d849d4..66aef7619 100644 --- a/java/docs/commands/index.md +++ b/java/docs/commands/index.md @@ -45,7 +45,7 @@ Type any command in the interactive wallet to see its built-in usage tips. | CreateProposal | [proposals.md#createproposal](proposals.md#createproposal) | | CreateWitness | [vote-reward.md#createwitness](vote-reward.md#createwitness) | | CurrentNetwork | [network.md#currentnetwork](network.md#currentnetwork) | -| DelegateResource | [stake-v2.md#delegateresource-undelegateresource](stake-v2.md#delegateresource-undelegateresource) | +| DelegateResource | [stake-v2.md#delegateresource](stake-v2.md#delegateresource) | | DeleteProposal | [proposals.md#deleteproposal](proposals.md#deleteproposal) | | DeployConstantContract | [contract.md#deployconstantcontract](contract.md#deployconstantcontract) | | DeployContract | [contract.md#deploycontract](contract.md#deploycontract) | @@ -58,7 +58,7 @@ Type any command in the interactive wallet to see its built-in usage tips. | ExportWalletKeystore | [wallet.md#exportwalletkeystore](wallet.md#exportwalletkeystore) | | ExportWalletMnemonic | [wallet.md#exportwalletmnemonic](wallet.md#exportwalletmnemonic) | | FreezeBalance | [stake-v1-legacy.md#how-to-freezeunfreeze-balance](stake-v1-legacy.md#how-to-freezeunfreeze-balance) | -| FreezeBalanceV2 | [stake-v2.md#freezebalancev2-unfreezebalancev2](stake-v2.md#freezebalancev2-unfreezebalancev2) | +| FreezeBalanceV2 | [stake-v2.md#freezebalancev2](stake-v2.md#freezebalancev2) | | GasFreeInfo | [gasfree.md#gasfreeinfo](gasfree.md#gasfreeinfo) | | GasFreeTrace | [gasfree.md#gasfreetrace](gasfree.md#gasfreetrace) | | GasFreeTransfer | [gasfree.md#gasfreetransfer](gasfree.md#gasfreetransfer) | @@ -145,17 +145,17 @@ Type any command in the interactive wallet to see its built-in usage tips. | TriggerConstantContract | [contract.md#triggerconstantcontract](contract.md#triggerconstantcontract) | | TriggerContract | [contract.md#triggercontract](contract.md#triggercontract) | | TronlinkMultiSign | [multisig.md#tronlinkmultisign](multisig.md#tronlinkmultisign) | -| UnDelegateResource | [stake-v2.md#delegateresource-undelegateresource](stake-v2.md#delegateresource-undelegateresource) | +| UnDelegateResource | [stake-v2.md#undelegateresource](stake-v2.md#undelegateresource) | | UnfreezeAsset | [transfer-trc10.md#unfreezeasset](transfer-trc10.md#unfreezeasset) | | UnfreezeBalance | [stake-v1-legacy.md#unfreezebalance-undelegate](stake-v1-legacy.md#unfreezebalance-undelegate) | -| UnfreezeBalanceV2 | [stake-v2.md#freezebalancev2-unfreezebalancev2](stake-v2.md#freezebalancev2-unfreezebalancev2) | +| UnfreezeBalanceV2 | [stake-v2.md#unfreezebalancev2](stake-v2.md#unfreezebalancev2) | | Unlock | [wallet.md#unlock](wallet.md#unlock) | | UpdateAccount | [account.md#updateaccount](account.md#updateaccount) | | UpdateAccountPermission | [multisig.md#updateaccountpermission](multisig.md#updateaccountpermission) | | UpdateAsset | [transfer-trc10.md#updateasset](transfer-trc10.md#updateasset) | | UpdateBrokerage | [vote-reward.md#updatebrokerage](vote-reward.md#updatebrokerage) | -| UpdateEnergyLimit | [contract.md#updateenergylimit-updatesetting](contract.md#updateenergylimit-updatesetting) | -| UpdateSetting | [contract.md#updateenergylimit-updatesetting](contract.md#updateenergylimit-updatesetting) | +| UpdateEnergyLimit | [contract.md#updateenergylimit--updatesetting](contract.md#updateenergylimit--updatesetting) | +| UpdateSetting | [contract.md#updateenergylimit--updatesetting](contract.md#updateenergylimit--updatesetting) | | UpdateWitness | [vote-reward.md#updatewitness](vote-reward.md#updatewitness) | | ViewBackupRecords | [account.md#viewbackuprecords](account.md#viewbackuprecords) | | ViewTransactionHistory | [account.md#viewtransactionhistory](account.md#viewtransactionhistory) | diff --git a/java/src/main/java/org/tron/common/utils/Utils.java b/java/src/main/java/org/tron/common/utils/Utils.java index 812959868..b5bd8cbd2 100644 --- a/java/src/main/java/org/tron/common/utils/Utils.java +++ b/java/src/main/java/org/tron/common/utils/Utils.java @@ -138,7 +138,7 @@ public class Utils { public static final int MIN_LENGTH = 2; public static final int MAX_LENGTH = 14; - public static final String VERSION = " v4.11.0"; + public static final String VERSION = " v4.12.0"; public static final String TRANSFER_METHOD_ID = "a9059cbb"; private static SecureRandom random = new SecureRandom(); diff --git a/ts/.dependency-cruiser.cjs b/ts/.dependency-cruiser.cjs index 3af44982d..e2e0ac004 100644 --- a/ts/.dependency-cruiser.cjs +++ b/ts/.dependency-cruiser.cjs @@ -30,7 +30,8 @@ module.exports = { { name: "inbound-does-not-know-outbound", severity: "error", - comment: "CLI adapters call application ports/use-cases; bootstrap/composition supplies outbound implementations", + comment: + "CLI adapters call application ports/use-cases; bootstrap/composition supplies outbound implementations", from: { path: "^src/adapters/inbound/", pathNot: "\\.test\\.ts$" }, to: { path: "^src/(adapters/outbound|bootstrap)/" }, }, diff --git a/ts/.gitignore b/ts/.gitignore index a0a00aedd..e2563668b 100644 --- a/ts/.gitignore +++ b/ts/.gitignore @@ -4,4 +4,7 @@ dist/ .wallet-cli/ .env .private/ -docs/superpowers \ No newline at end of file +docs/superpowers +docs/qa +docs/adr +CONTEXT.md \ No newline at end of file diff --git a/ts/.prettierignore b/ts/.prettierignore new file mode 100644 index 000000000..98e0146ae --- /dev/null +++ b/ts/.prettierignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.wallet-cli/ +.private/ +package-lock.json + +# hand-authored prose: tables and wrapping are deliberate, Prettier reflows them +*.md diff --git a/ts/.prettierrc.json b/ts/.prettierrc.json new file mode 100644 index 000000000..de753c537 --- /dev/null +++ b/ts/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} diff --git a/ts/README.md b/ts/README.md index e9a3dbc6a..990e47ea7 100644 --- a/ts/README.md +++ b/ts/README.md @@ -7,7 +7,7 @@ The agent-first implementation of wallet-cli, built for automation: every comman - **Agent-first** — stable JSON output, deterministic exit codes, and discoverable schemas, built for scripts, CI, and AI agents (details in [The contract, in one paragraph](#the-contract-in-one-paragraph)). - **Encrypted local storage** — software keystores are encrypted on disk; secrets are never passed via argv or environment variables. - **Software and Ledger signing** — sign in software, or on a Ledger device (the private key never leaves the device). -- **Covers the main TRON capabilities** — HD wallets, TRX and TRC20/TRC10 transfers, staking / resource delegation, voting / rewards, smart-contract calls and deployment, multi-sig, GasFree transfers, message signing, and on-chain queries. +- **Covers the full TRON feature surface** — HD wallets, TRX and TRC20/TRC10 transfers, staking / resource delegation, voting / rewards, governance proposals and super-representative operation, smart-contract calls, deployment and governance, TRC10 issuance, the on-chain Bancor exchange, multi-sig, GasFree transfers, message signing, and on-chain queries. ## Table of contents @@ -19,6 +19,7 @@ The agent-first implementation of wallet-cli, built for automation: every comman - [Transactions](#transactions) - [On-chain queries](#on-chain-queries) - [Tokens, contracts, staking, signing](#tokens-contracts-staking-signing) + - [Governance, TRC10, and the on-chain exchange](#governance-trc10-and-the-on-chain-exchange) - [Local tools and configuration](#local-tools-and-configuration) - [The contract, in one paragraph](#the-contract-in-one-paragraph) - [Understanding TRON mechanics](#understanding-tron-mechanics) @@ -104,11 +105,11 @@ Create, import, and manage local wallets and accounts. | Command | Description | |---|---| | [`create`](docs/commands/create.md) | Create a new HD wallet (BIP39 seed) | -| `import` | Import a wallet — [mnemonic](docs/commands/import/mnemonic.md) · [private-key](docs/commands/import/private-key.md) · [ledger](docs/commands/import/ledger.md) · [watch](docs/commands/import/watch.md)-only | +| `import` | Import a wallet — [mnemonic](docs/commands/import/mnemonic.md) · [private-key](docs/commands/import/private-key.md) · [keystore](docs/commands/import/keystore.md) · [ledger](docs/commands/import/ledger.md) · [watch](docs/commands/import/watch.md)-only | | [`list`](docs/commands/list.md) | List wallets and accounts | | [`use`](docs/commands/use.md) · [`current`](docs/commands/current.md) | Set / show the active account (`current --qr` for a receive QR) | | [`derive`](docs/commands/derive.md) | Derive the next HD account from a seed wallet | -| [`rename`](docs/commands/rename.md) · [`backup`](docs/commands/backup.md) · [`delete`](docs/commands/delete.md) | Rename, back up, or delete an account (backup writes secret + metadata, mode 0600) | +| [`rename`](docs/commands/rename.md) · [`backup`](docs/commands/backup.md) · [`delete`](docs/commands/delete.md) | Rename, back up, or delete an account (backup writes secret + metadata, mode 0600; `--keystore` for Web3 keystore format, `--records` for the export audit log) | | [`change-password`](docs/commands/change-password.md) | Change the master password (re-encrypt all software keystores) | ### Transactions @@ -138,16 +139,27 @@ Read account, block, and chain state. Token and contract operations, resource staking, voting rewards, message signing, and permissions. +| Command | Description | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [`token`](docs/commands/token/index.md) | Token address book and queries ([balance](docs/commands/token/balance.md) · [info](docs/commands/token/info.md) · [add](docs/commands/token/add.md) · [list](docs/commands/token/list.md) · [remove](docs/commands/token/remove.md)) | +| [`contact`](docs/commands/contact/index.md) | Recipient contact book ([add](docs/commands/contact/add.md) · [list](docs/commands/contact/list.md) · [remove](docs/commands/contact/remove.md)) | +| [`contract`](docs/commands/contract/index.md) | Call, send, deploy, inspect, and govern contracts ([call](docs/commands/contract/call.md) · [send](docs/commands/contract/send.md) · [deploy](docs/commands/contract/deploy.md) · [info](docs/commands/contract/info.md) · [clear-abi](docs/commands/contract/clear-abi.md) · [set-origin-energy-limit](docs/commands/contract/set-origin-energy-limit.md) · [set-user-resource-percent](docs/commands/contract/set-user-resource-percent.md) · [create2](docs/commands/contract/create2.md)) | +| [`stake`](docs/commands/stake/index.md) | Stake / delegate resources ([freeze](docs/commands/stake/freeze.md) · [unfreeze](docs/commands/stake/unfreeze.md) · [delegate](docs/commands/stake/delegate.md) · [info](docs/commands/stake/info.md), …) | +| [`vote`](docs/commands/vote/index.md) · [`reward`](docs/commands/reward/index.md) | Vote for super representatives and claim voting rewards | +| [`message`](docs/commands/message/index.md) · [`typed-data`](docs/commands/typed-data/index.md) | Sign arbitrary messages, or EIP-712/TIP-712 structured data | +| [`permission`](docs/commands/permission/index.md) | View / update account permissions for multi-sig | +| [`gasfree`](docs/commands/gasfree/index.md) | Gas-free token transfers via the GasFree service | + +### Governance, TRC10, and the on-chain exchange + +Chain governance, super-representative operation, and TRON's protocol-level TRC10 and Bancor exchange mechanics. + | Command | Description | |---|---| -| [`token`](docs/commands/token/index.md) | Token address book and queries ([balance](docs/commands/token/balance.md) · [info](docs/commands/token/info.md) · [add](docs/commands/token/add.md) · [list](docs/commands/token/list.md) · [remove](docs/commands/token/remove.md)) | -| [`contact`](docs/commands/contact/index.md) | Recipient contact book ([add](docs/commands/contact/add.md) · [list](docs/commands/contact/list.md) · [remove](docs/commands/contact/remove.md)) | -| [`contract`](docs/commands/contract/index.md) | Call, send, deploy, inspect contracts ([call](docs/commands/contract/call.md) · [send](docs/commands/contract/send.md) · [deploy](docs/commands/contract/deploy.md) · [info](docs/commands/contract/info.md)) | -| [`stake`](docs/commands/stake/index.md) | Stake / delegate resources ([freeze](docs/commands/stake/freeze.md) · [unfreeze](docs/commands/stake/unfreeze.md) · [delegate](docs/commands/stake/delegate.md) · [info](docs/commands/stake/info.md), …) | -| [`vote`](docs/commands/vote/index.md) · [`reward`](docs/commands/reward/index.md) | Vote for super representatives and claim voting rewards | -| [`message`](docs/commands/message/index.md) · [`typed-data`](docs/commands/typed-data/index.md) | Sign arbitrary messages, or EIP-712/TIP-712 structured data | -| [`permission`](docs/commands/permission/index.md) | View / update account permissions for multi-sig | -| [`gasfree`](docs/commands/gasfree/index.md) | Gas-free token transfers via the GasFree service | +| [`proposal`](docs/commands/proposal/index.md) | Chain-parameter proposals ([list](docs/commands/proposal/list.md) · [show](docs/commands/proposal/show.md) · [create](docs/commands/proposal/create.md) · [approve](docs/commands/proposal/approve.md) · [delete](docs/commands/proposal/delete.md)) — `list` / `show` are open to anyone, the write commands require a registered witness | +| [`witness`](docs/commands/witness/index.md) | Register and operate a super representative ([create](docs/commands/witness/create.md) · [update](docs/commands/witness/update.md) · [set-brokerage](docs/commands/witness/set-brokerage.md)) | +| [`asset`](docs/commands/asset/index.md) | Issue and manage TRC10 tokens ([issue](docs/commands/asset/issue.md) · [update](docs/commands/asset/update.md) · [participate](docs/commands/asset/participate.md) · [unfreeze](docs/commands/asset/unfreeze.md) · [info](docs/commands/asset/info.md) · [list](docs/commands/asset/list.md)); TRC10 transfers go through [`tx send`](docs/commands/tx/send.md) | +| [`exchange`](docs/commands/exchange/index.md) | The protocol-level Bancor exchange between TRX and TRC10 ([create](docs/commands/exchange/create.md) · [inject](docs/commands/exchange/inject.md) · [withdraw](docs/commands/exchange/withdraw.md) · [trade](docs/commands/exchange/trade.md) · [show](docs/commands/exchange/show.md) · [list](docs/commands/exchange/list.md)) | ### Local tools and configuration diff --git a/ts/docs/commands/account/activate.md b/ts/docs/commands/account/activate.md index 311e8fe3d..cf0bb92da 100644 --- a/ts/docs/commands/account/activate.md +++ b/ts/docs/commands/account/activate.md @@ -26,8 +26,8 @@ Requires the payer account and the master password via `--password-stdin`; watch | `--dry-run` | Build and estimate only; no signature/broadcast, no password. Excludes `--sign-only` / `--build-only` | | `--sign-only` | Build and sign, output the signed hex (feed [`tx broadcast`](../tx/broadcast.md)). Excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex (feed [`tx multisig --create`](../tx/multisig.md)). Excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only` | -| `--permission-id ` | Permission group to sign with (default `0`) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | @@ -88,7 +88,7 @@ echo "$PW" | wallet-cli account activate --address TNewAddr9k2fP7cW4bXm1sV8dRj6e ## Exit status -`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`account_already_active`, `watch_only_no_signer`, `wrong_password`, `auth_failed`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value` — malformed address). +`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`account_already_active`, `watch_only_no_signer`, `auth_failed`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value` — malformed address). After a **confirmed** transaction the command reads the account back to verify the change took effect. That follow-up never turns an already-paid transaction into a command failure: a mismatch or an unreadable read is reported as a `meta.warnings` entry (`account_activate_postcheck_mismatch` / `account_activate_postcheck_unavailable`) with `success` still `true` and exit `0`. diff --git a/ts/docs/commands/account/set.md b/ts/docs/commands/account/set.md index 5f9f6789c..22ef0f84f 100644 --- a/ts/docs/commands/account/set.md +++ b/ts/docs/commands/account/set.md @@ -27,8 +27,8 @@ Requires the account and the master password via `--password-stdin`; watch-only | `--dry-run` | Build and estimate only; no signature/broadcast, no password. Excludes `--sign-only` / `--build-only` | | `--sign-only` | Build and sign, output the signed hex (feed [`tx broadcast`](../tx/broadcast.md)). Excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex (feed [`tx multisig --create`](../tx/multisig.md)). Excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only` | -| `--permission-id ` | Permission group to sign with (default `0`) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | @@ -90,7 +90,7 @@ echo "$PW" | wallet-cli account set --id acme-treasury-01 --network tron:nile -- ## Exit status -`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`name_already_set`, `id_already_set`, `id_taken`, `watch_only_no_signer`, `wrong_password`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`, `invalid_option` — malformed or missing name/id). +`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`name_already_set`, `id_already_set`, `id_taken`, `watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`, `invalid_option` — malformed or missing name/id). After a **confirmed** transaction the command reads the account back to verify the change took effect. That follow-up never turns an already-paid transaction into a command failure: a mismatch or an unreadable read is reported as a `meta.warnings` entry (`account_set_postcheck_mismatch` / `account_set_postcheck_unavailable`) with `success` still `true` and exit `0`. diff --git a/ts/docs/commands/asset/index.md b/ts/docs/commands/asset/index.md new file mode 100644 index 000000000..7d0b3b4c2 --- /dev/null +++ b/ts/docs/commands/asset/index.md @@ -0,0 +1,35 @@ +# wallet-cli asset + +Issue and manage TRC10 tokens. + +TRC10 is TRON's **chain-native** token standard: issuance, the ICO sale, and frozen supply are protocol features, not contract code. That is what separates this group from [`token`](../token/index.md), which deals in TRC20 contract tokens — and from [`contract`](../contract/index.md), since a TRC10 has no contract at all. + +Four facts shape everything here: + +- **One token per account, for life.** An account that has issued a TRC10 can never issue another. Getting it wrong means starting over with a different account. +- **Issuance is final.** The issuance fee is burned, and only the description, URL, and the two free-bandwidth limits stay editable afterwards ([`asset update`](update.md)). Supply, precision, ICO rate, ICO window, and frozen tranches are fixed at issuance — the chain has no way to change them. +- **Participation is the ICO, not a market.** [`asset participate`](participate.md) buys from the issuance at the fixed rate set when the token was created, inside its funding window. There is no order book here; TRX↔TRC10 trading lives in [`exchange`](../exchange/index.md). +- **Transfers are not in this group.** Send a TRC10 with [`tx send --asset-id `](../tx/send.md), the same as any other token. + +Amounts on the command line and in text output are in **whole tokens**; json carries the on-chain raw value (whole tokens × 10^precision). + +## Synopsis + +``` +wallet-cli asset COMMAND +``` + +## Subcommands + +| Command | Page | Description | +|---|---|---| +| `asset issue` | [issue.md](issue.md) | Issue a TRC10 and lock in its ICO terms | +| `asset update` | [update.md](update.md) | Change the four mutable fields | +| `asset participate` | [participate.md](participate.md) | Buy into a token's ICO with TRX | +| `asset unfreeze` | [unfreeze.md](unfreeze.md) | Release matured frozen supply | +| `asset info` | [info.md](info.md) | Full detail of one TRC10 | +| `asset list` | [list.md](list.md) | List every TRC10 on chain | + +## See also + +[`tx send`](../tx/send.md) · [`token info`](../token/info.md) · [`exchange`](../exchange/index.md) diff --git a/ts/docs/commands/asset/info.md b/ts/docs/commands/asset/info.md new file mode 100644 index 000000000..309c2e016 --- /dev/null +++ b/ts/docs/commands/asset/info.md @@ -0,0 +1,145 @@ +# wallet-cli asset info + +Show one TRC10 in full. + +## Synopsis + +``` +wallet-cli asset info ( | --issuer
) [options] +``` + +## Description + +Reports a token's issuance record: issuer, total supply, precision, ICO rate and window, frozen tranches, description, URL, and the two free-bandwidth limits. Read-only, no account needed. + +Look it up three ways — by id (an all-digit argument), by name, or by `--issuer` address. Exactly one of `` and `--issuer` is required; giving neither or both is `invalid_value`. Since an account can only ever issue one TRC10, an issuer lookup has a single answer. + +Names are not guaranteed unique on chain. **A name that matches several tokens is an error, not a listing** — the command exits `1` with `ambiguous_asset_name` and prints the candidates so you can re-run with an id. See [the example below](#a-name-that-is-not-unique). + +Empty sections are dropped entirely: a token with no frozen tranches shows no `Frozen` block at all. + +Timestamps here are printed to the second (`2026-08-01 00:00:00 UTC`), not to the minute as elsewhere in the CLI. + +This is the TRC10-specific counterpart to [`token info`](../token/info.md), which reports the generic metadata (name, symbol, decimals) shared with TRC20 and selects a TRC10 only by `--asset-id`. + +There is no "amount already sold", "remaining supply", or holder count: a node cannot compute any of them reliably — an issuer's plain transfers are indistinguishable from ICO sales when working backwards — so none is reported. For the issuer's current holding, read its balance with [`account balance`](../account/balance.md). + +## Options + +| Option | Description | +|---|---| +| `` | Token id or name; an all-digit value is read as the id. One of `` / `--issuer` | +| `--issuer
` | The token issued by this address. One of `` / `--issuer` | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +By id: + +```bash +wallet-cli asset info 1000123 --network tron:nile +``` + +```console +Asset MyToken (id 1000123) + Issuer TQkXm4vN...5Zt7Uw + Total supply 1,000,000,000 + Precision 6 + Price 1 TRX = 100 MyToken + ICO start time 2026-08-01 00:00:00 UTC + ICO end time 2026-08-31 00:00:00 UTC + Url https://mytoken.io + Description Demo TRC10 + Free net/account 0 + Public free net 0 + Frozen (2) + 100,000,000 until 2026-08-31 00:00:00 UTC + 50,000,000 until 2026-10-30 00:00:00 UTC +``` + +### A name that is not unique + +```bash +wallet-cli asset info MyToken --network tron:nile +``` + +The command fails with exit `1`; the message and the candidate table go to **stderr**: + +```console +error [ambiguous_asset_name]: 2 TRC10 tokens are named MyToken; re-run with the id +| ID | Issuer | Total supply | Precision | +| ------- | ---------------------------------- | ------------- | --------- | +| 1000123 | TQkXm4vN2f8LrQ5tYc7bWmXe3sVd9Zt7Uw | 1,000,000,000 | 6 | +| 1000488 | TZx9kP2mR4nJ6vLc8dHqYe1tWbXs5f7bWq | 50,000,000 | 2 | +``` + +In json the same information is in `error.details` — see [Output](#output). + +By issuer — someone else's token here, and it has no frozen tranches: + +```bash +wallet-cli asset info --issuer TZx9kP2m...7bWq --network tron:nile +``` + +```console +Asset MyToken (id 1000488) + Issuer TZx9kP2m...7bWq + Total supply 50,000,000 + Precision 2 + Price 1 TRX = 5 MyToken + ICO start time 2026-07-15 00:00:00 UTC + ICO end time 2026-09-15 00:00:00 UTC + Url https://beta.example + Description Another TRC10 + Free net/account 0 + Public free net 0 +``` + +```bash +wallet-cli asset info 1000123 --network tron:nile -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.info","data":{"kind":"asset-info","assetId":"1000123","name":"MyToken","abbr":"MTK","issuerAddress":"TQkXm4vN...","totalSupply":"1000000000000000","precision":6,"price":"1:100","trxNum":1000000,"num":100000000,"startTime":1785542400000,"endTime":1788134400000,"url":"https://mytoken.io","description":"Demo TRC10","freeAssetNetLimit":0,"publicFreeAssetNetLimit":0,"frozenSupply":[{"amount":"100000000000000","days":30,"expireTime":1788134400000},{"amount":"50000000000000","days":90,"expireTime":1793318400000}]},"meta":{"durationMs":26,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +The ambiguous-name failure, in json: + +```bash +wallet-cli asset info MyToken --network tron:nile -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":false,"command":"asset.info","error":{"code":"ambiguous_asset_name","message":"2 TRC10 tokens are named MyToken; re-run with the id","details":{"name":"MyToken","assetIds":["1000123","1000488"],"matches":[{"assetId":"1000123","issuerAddress":"TQkXm4vN...","totalSupply":"1000000000000000","precision":6},{"assetId":"1000488","issuerAddress":"TZx9kP2m...","totalSupply":"5000000000","precision":2}]}},"meta":{"durationMs":29,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data.kind` is `asset-info`. + +| Field | Type | Meaning | +|---|---|---| +| `assetId` | string | Token id | +| `name` / `abbr` | string | Name and abbreviation as issued. `abbr` is json-only — text has no row for it | +| `issuerAddress` | string | Issuer, base58 | +| `totalSupply` | string | Total supply, raw (whole tokens × 10^`precision`). A **string**: supplies reach int64 and would lose precision as a JSON number | +| `precision` | number | Decimal places, 0–6 | +| `price` | string | The issued rate as `trx:tokens`, in whole units — what text renders as `1 TRX = 100 MyToken` | +| `trxNum` / `num` | number | The same rate exactly as stored on chain, in sun and minimal units. For a `precision` of 6, `1:100` is stored as `1000000` / `100000000` | +| `startTime` / `endTime` | number | ICO window, ms since epoch | +| `url` / `description` | string | Project page and description | +| `freeAssetNetLimit` / `publicFreeAssetNetLimit` | number | Free bandwidth per holder, and the shared pool | +| `frozenSupply[]` | array | `amount` (raw, a **string**), `days`, `expireTime` (ms since epoch). An empty array when there are none | + +There is no `remainingSupply` field. + +A name matching several tokens fails instead of returning data. `error.details` then carries `name`, `assetIds[]` (the ids to re-run with), and `matches[]` — one flat row per candidate with `assetId`, `issuerAddress`, `totalSupply` (raw, string), and `precision`. Text mode renders `matches[]` as the table shown above, scaling each `totalSupply` by its `precision`. + +## Exit status + +`0` success · `1` execution failure (`asset_not_found` — no such token, `ambiguous_asset_name` — the name matches several tokens, `rpc_error`) · `2` usage error (`invalid_value` — neither `` nor `--issuer` given, or both; or `--issuer` is not a valid base58 TRON address). + +## See also + +[`asset list`](list.md) · [`token info`](../token/info.md) · [`asset participate`](participate.md) diff --git a/ts/docs/commands/asset/issue.md b/ts/docs/commands/asset/issue.md new file mode 100644 index 000000000..27e1e960e --- /dev/null +++ b/ts/docs/commands/asset/issue.md @@ -0,0 +1,117 @@ +# wallet-cli asset issue + +Issue a TRC10 token and lock in its ICO terms. + +## Synopsis + +``` +wallet-cli asset issue --name --supply --price : + --start --end --url + [--abbr ] [--precision <0-6>] [--description ] + [--free-net-per-account ] [--public-free-net ] + [--freeze : ...] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Creates a TRC10 token and, in the same transaction, fixes the terms of its ICO: total supply, precision, the TRX-to-token rate, the funding window, and any frozen tranches. + +**This cannot be undone.** The issuance fee is burned — the chain parameter `getAssetIssueFee`, currently around 1,024 TRX, readable with [`chain params`](../chain/params.md) — and an account may issue **only one TRC10 in its lifetime**. Afterwards only the description, the URL, and the two free-bandwidth limits can be changed ([`asset update`](update.md)); everything else is permanent. The receipt therefore echoes the complete definition, because that is the final one. + +**`--price` is converted using `--precision`.** The chain stores the rate as an integer pair `trxNum` / `num` satisfying `num ÷ trxNum = tokens × 10^precision ÷ (trx × 10^6)`, reduced to lowest terms. So `--price 1:100` is stored as `trxNum=1, num=100` at `--precision 6`, but as `trxNum=10000, num=1` at `--precision 0` — the same flag, a different on-chain rate. Both values must land in the positive int32 range after reduction; otherwise the command fails with `invalid_value` and nothing is broadcast. + +Amounts (`--supply`, `--freeze`) are in **whole tokens** — `--supply 1000000000 --precision 6` becomes an on-chain `total_supply` of `1000000000000000`. + +Dates are read as **UTC**, as `YYYY-MM-DD` or `YYYY-MM-DD HH:mm:ss`; a bare date means `00:00:00`. `--start` must be later than the chain's current time, so a bare date is at the earliest tomorrow — to start a sale the same day, give the time as well. + +Constraints are checked locally before broadcast: `--name` and `--abbr` are 1–32 visible ASCII characters (`0x21`–`0x7E`, so no spaces and no non-ASCII); `--url` is required and at most 256 bytes; `--description` at most 200 bytes; `--precision` 0–6; `--end` after `--start`; each `--freeze` tranche's days within `getMinFrozenSupplyTime`…`getMaxFrozenSupplyTime`, the number of tranches within `getMaxFrozenSupplyNumber`, and their sum within the total supply; both free-bandwidth limits below `getOneDayNetLimit`. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `--name ` | **Required.** Token name, 1–32 visible ASCII characters | +| `--supply ` | **Required.** Total supply in whole tokens, > 0 | +| `--price :` | **Required.** ICO rate, whole TRX to whole tokens (e.g. `1:100`); both sides > 0, converted using `--precision` | +| `--start ` | **Required.** ICO start, UTC; must be in the future | +| `--end ` | **Required.** ICO end, UTC; must be after `--start` | +| `--url ` | **Required.** Project page, non-empty, ≤ 256 bytes | +| `--abbr ` | Token abbreviation; same character rules as `--name` (default: empty) | +| `--precision <0-6>` | Decimal places (default `0`) | +| `--description ` | Short description, ≤ 200 bytes (default: empty) | +| `--free-net-per-account ` | Free bandwidth each holder may use (default `0`) | +| `--public-free-net ` | Shared free-bandwidth pool for holders (default `0`) | +| `--freeze :` | **Repeatable.** Frozen tranche; amount in whole tokens, e.g. `100000000:30` | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli asset issue --name MyToken --abbr MTK --supply 1000000000 --price 1:100 --precision 6 \ + --start 2026-08-01 --end 2026-08-31 --url https://mytoken.io --description "Demo TRC10" \ + --freeze 100000000:30 --freeze 50000000:90 --network tron:nile --wait --password-stdin +``` + +```console +✅ Asset issued + Asset MyToken (id 1000123) + Issuer TQkXm4vN...5Zt7Uw (main) + Total supply 1,000,000,000 + Precision 6 + Price 1 TRX = 100 MyToken + ICO start time 2026-08-01 00:00 UTC + ICO end time 2026-08-31 00:00 UTC + Url https://mytoken.io + Description Demo TRC10 + Free net/account 0 + Public free net 0 + Frozen (2) + 100,000,000 for 30 days + 50,000,000 for 90 days + TxID 7d1... + Block 57,883,010 + Fee 1,024 TRX (312 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli asset issue --name MyToken --abbr MTK --supply 1000000000 --price 1:100 --precision 6 \ + --start 2026-08-01 --end 2026-08-31 --url https://mytoken.io --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.issue","data":{"kind":"asset-issue","stage":"confirmed","txId":"7d1...","confirmed":true,"blockNumber":57883010,"failed":false,"assetId":"1000123","name":"MyToken","abbr":"MTK","totalSupply":1000000000000000,"precision":6,"price":"1:100","trxNum":1,"num":100,"startTime":1785542400000,"endTime":1788134400000,"url":"https://mytoken.io","description":"Demo TRC10","freeAssetNetLimit":0,"publicFreeAssetNetLimit":0,"frozenSupply":[{"amount":100000000000000,"days":30},{"amount":50000000000000,"days":90}],"feeSun":1024000000,"resource":{"netUsage":312,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6720,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "asset-issue"`, `stage: "submitted"`, `txId`, and the token definition below except `assetId` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed`, and `assetId` — assigned by the chain, so known only once confirmed | + +Definition fields: `name`, `abbr`, `totalSupply` (raw), `precision`, `price` (the `trx:tokens` string as given) with the stored `trxNum` / `num` pair, `startTime` / `endTime` (ms since epoch), `url`, `description`, `freeAssetNetLimit`, `publicFreeAssetNetLimit`, and `frozenSupply[]` (`amount` raw, `days`). + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`already_issued_asset` — this account already issued one, `insufficient_balance` — below the issuance fee, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — a required flag is absent; `invalid_asset_name` — name or abbreviation outside 1–32 visible ASCII; `invalid_value` — rate, precision, dates, bandwidth limits, or frozen tranches out of range, or the rate exceeding int32 after conversion). + +## See also + +[`asset update`](update.md) · [`asset info`](info.md) · [`asset participate`](participate.md) · [`chain params`](../chain/params.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/asset/list.md b/ts/docs/commands/asset/list.md new file mode 100644 index 000000000..7fa20b16a --- /dev/null +++ b/ts/docs/commands/asset/list.md @@ -0,0 +1,69 @@ +# wallet-cli asset list + +List every TRC10 on chain. + +## Synopsis + +``` +wallet-cli asset list [--limit ] [--offset ] [options] +``` + +## Description + +Lists TRC10 tokens with id, name, total supply, precision, and issuer. Read-only, no account needed. For one token's full issuance record — ICO rate and window, frozen tranches — use [`asset info`](info.md). + +Paging happens on the node, and **there is no total**: the chain exposes no count of TRC10 tokens, and fetching them all to count them is expensive (thousands of tokens, megabytes of response). So the title reports the window it asked for — `Assets (limit 3, offset 0)` — not `showing 3 of N`, and `meta.pagination.total` is always `null`. To get everything, pass a `--limit` large enough to cover it. + +## Options + +| Option | Description | +|---|---| +| `--limit ` | Max tokens to return (default `10`) | +| `--offset ` | Pagination offset (default `0`) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli asset list --limit 3 --network tron:nile +``` + +```console +Assets (limit 3, offset 0) +| ID | Name | Total supply | Precision | Issuer | +| ------- | --------- | ------------- | --------- | ----------------- | +| 1000125 | AlphaCoin | 500,000,000 | 2 | TAlpha7k...3nQw | +| 1000124 | BetaToken | 2,000,000,000 | 6 | TBeta9mR...8pLx | +| 1000123 | MyToken | 1,000,000,000 | 6 | TQkXm4vN...5Zt7Uw | +``` + +```bash +wallet-cli asset list --limit 3 --network tron:nile -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.list","data":{"kind":"asset-list","assets":[{"assetId":"1000125","name":"AlphaCoin","issuerAddress":"TAlpha7k...","totalSupply":"50000000000","precision":2},{"assetId":"1000124","name":"BetaToken","issuerAddress":"TBeta9mR...","totalSupply":"2000000000000000","precision":6},{"assetId":"1000123","name":"MyToken","issuerAddress":"TQkXm4vN...","totalSupply":"1000000000000000","precision":6}]},"meta":{"durationMs":48,"warnings":[],"pagination":{"offset":0,"limit":3,"total":null}},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data.kind` is `asset-list`. `data.assets[]` — one entry per token: + +| Field | Type | Meaning | +|---|---|---| +| `assetId` | string | Token id | +| `name` | string | Token name | +| `issuerAddress` | string | Issuer, base58 | +| `totalSupply` | string | Total supply, raw (whole tokens × 10^`precision`). A **string**: supplies reach int64 and would lose precision as a JSON number | +| `precision` | number | Decimal places, 0–6 | + +`meta.pagination` carries `offset`, `limit`, and `total` — `total` is always `null` here, meaning "no count exists", not "zero". + +## Exit status + +`0` success · `1` execution failure (`rpc_error`) · `2` usage error (`invalid_value` — bad limit or offset). + +## See also + +[`asset info`](info.md) · [`token list`](../token/list.md) diff --git a/ts/docs/commands/asset/participate.md b/ts/docs/commands/asset/participate.md new file mode 100644 index 000000000..3041d4e60 --- /dev/null +++ b/ts/docs/commands/asset/participate.md @@ -0,0 +1,87 @@ +# wallet-cli asset participate + +Buy into a TRC10's ICO with TRX. + +## Synopsis + +``` +wallet-cli asset participate --pay + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Buys from a token's issuance inside its funding window, at the fixed rate set when it was issued. This is participation in the ICO, not a market trade — the tokens come out of the issuer's remaining supply, and the price is not negotiable. The issuer's address is resolved from the token, so there is nothing to pass for it. + +**`--pay` is the TRX you spend, not the tokens you receive.** You get `floor(pay × tokens ÷ trx)` where `trx:tokens` is the token's issued rate — the amount paid times the unit price, rounded down, since the chain multiplies before dividing on integers. The TRX is transferred in full, so any truncated remainder is not refunded; the loss is under 1 sun and cannot occur at all when the rate's `trxNum` is 1. If `--pay` is too small to buy even one unit, the command fails locally rather than broadcasting. + +The acting account cannot be the token's own issuer. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `` | **Required.** Token id or name; an all-digit value is read as the id | +| `--pay ` | **Required.** TRX to spend (not a token count), > 0 | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +Spend 100 TRX on a token issued at `1:100`: + +```bash +echo "$PW" | wallet-cli asset participate 1000124 --pay 100 --network tron:nile --wait --password-stdin +``` + +```console +✅ Participated in ICO + Asset BetaToken (id 1000124) + Issuer TBeta9mR...8pLx + Participant TQkXm4vN...5Zt7Uw (main) + Paid 100 TRX + Received 10,000 BetaToken + TxID 4c8... + Block 57,883,402 + Fee 0 TRX (301 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli asset participate 1000124 --pay 100 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.participate","data":{"kind":"asset-participate","stage":"confirmed","txId":"4c8...","confirmed":true,"blockNumber":57883402,"failed":false,"assetId":"1000124","name":"BetaToken","issuerAddress":"TBeta9mR...","participantAddress":"TQkXm4vN...","paidSun":100000000,"receivedAmount":10000000000,"feeSun":0,"resource":{"netUsage":301,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6450,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "asset-participate"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress`, `participantAddress`, `paidSun`, `receivedAmount` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +`paidSun` is the TRX spent in sun; `receivedAmount` is the token amount in its smallest unit (text shows both in human units). + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`asset_not_found` — no such token, `not_in_ico_window` — outside the funding window, `self_participation` — you issued this token, `insufficient_balance`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--pay`; `invalid_amount` — `--pay` is not a decimal number, or has more than 6 decimal places; `invalid_value` — `--pay` ≤ 0, or too small to buy one unit). + +## See also + +[`asset info`](info.md) · [`tx send`](../tx/send.md) · [`exchange trade`](../exchange/trade.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/asset/unfreeze.md b/ts/docs/commands/asset/unfreeze.md new file mode 100644 index 000000000..b318945e6 --- /dev/null +++ b/ts/docs/commands/asset/unfreeze.md @@ -0,0 +1,86 @@ +# wallet-cli asset unfreeze + +Release matured frozen supply of the TRC10 you issued. + +## Synopsis + +``` +wallet-cli asset unfreeze + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Returns the part of the issued supply that was frozen at issuance and whose lock period is over, back to the issuer's balance. + +There is no argument of any kind: the command always targets the token issued by the signing account, and the chain accepts neither "which tranche" nor "how much" — **every matured tranche is released in one transaction**. Tranches that have not matured are untouched; run the command again once they are. + +A tranche matures at its issuance `--start` plus its `days`, not at the moment the token was actually issued: the chain writes each tranche's `expire_time` as `start_time + days × 86400000` when the token is created. The resulting dates are visible in the `Frozen` section of [`asset info`](info.md). + +This is unrelated to [`stake unfreeze`](../stake/unfreeze.md), which releases staked TRX; the only thing they share is the word. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +This command has no options of its own. + +| Option | Description | +|---|---| +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli asset unfreeze --network tron:nile --wait --password-stdin +``` + +```console +✅ Frozen supply released + Asset MyToken (id 1000123) + Issuer TQkXm4vN...5Zt7Uw (main) + Released 100,000,000 MyToken + Still frozen 50,000,000 MyToken + TxID 6a5... + Block 57,883,560 + Fee 0 TRX (288 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli asset unfreeze --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.unfreeze","data":{"kind":"asset-unfreeze","stage":"confirmed","txId":"6a5...","confirmed":true,"blockNumber":57883560,"failed":false,"assetId":"1000123","name":"MyToken","issuerAddress":"TQkXm4vN...","releasedAmount":100000000000000,"stillFrozenAmount":50000000000000,"feeSun":0,"resource":{"netUsage":288,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6410,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "asset-unfreeze"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed`, `releasedAmount`, `stillFrozenAmount` | + +`releasedAmount` and `stillFrozenAmount` are raw amounts (smallest unit) and reflect what the confirmed transaction actually did. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_an_issuer` — this account has not issued a TRC10, `no_frozen_supply`, `not_yet_unfreezable` — nothing has matured yet, `watch_only_no_signer`, `auth_failed`) · `2` usage error. + +## See also + +[`asset info`](info.md) · [`asset issue`](issue.md) · [`stake unfreeze`](../stake/unfreeze.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/asset/update.md b/ts/docs/commands/asset/update.md new file mode 100644 index 000000000..243bc60c6 --- /dev/null +++ b/ts/docs/commands/asset/update.md @@ -0,0 +1,89 @@ +# wallet-cli asset update + +Change the mutable fields of the TRC10 you issued. + +## Synopsis + +``` +wallet-cli asset update [--description ] [--url ] + [--free-net-per-account ] [--public-free-net ] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +There is no token argument: the command always targets the TRC10 issued by the signing account. An account that has not issued one fails with `not_an_issuer`. + +**Only four fields can ever change** — description, URL, free bandwidth per holder, and the shared free-bandwidth pool. Supply, precision, ICO rate, ICO window, and frozen tranches were fixed at issuance and the chain offers no way to alter them. + +Pass only the fields you are changing. The others are read from chain and written back unchanged, so nothing is silently cleared; at least one field is required. The receipt shows all four as they now stand. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `--description ` | New description, ≤ 200 bytes (unchanged if omitted) | +| `--url ` | New project page, non-empty, ≤ 256 bytes (unchanged if omitted) | +| `--free-net-per-account ` | Free bandwidth each holder may use (unchanged if omitted) | +| `--public-free-net ` | Shared free-bandwidth pool for holders (unchanged if omitted) | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 --network tron:nile --wait --password-stdin +``` + +```console +✅ Asset updated + Asset MyToken (id 1000123) + Issuer TQkXm4vN...5Zt7Uw (main) + Url https://mytoken.io/v2 + Description Demo TRC10 + Free net/account 0 + Public free net 0 + TxID 9e3... + Block 57,883,190 + Fee 0 TRX (295 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"asset.update","data":{"kind":"asset-update","stage":"confirmed","txId":"9e3...","confirmed":true,"blockNumber":57883190,"failed":false,"assetId":"1000123","name":"MyToken","issuerAddress":"TQkXm4vN...","url":"https://mytoken.io/v2","description":"Demo TRC10","freeAssetNetLimit":0,"publicFreeAssetNetLimit":0,"feeSun":0,"resource":{"netUsage":295,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6480,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "asset-update"`, `stage: "submitted"`, `txId`, `assetId`, `name`, `issuerAddress`, and the four fields as submitted | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +The four fields are `url`, `description`, `freeAssetNetLimit`, and `publicFreeAssetNetLimit` — always all four, including the ones read back unchanged. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_an_issuer` — this account has not issued a TRC10, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no field given; `invalid_value` — URL or description too long, bandwidth limits out of range). + +## See also + +[`asset issue`](issue.md) · [`asset info`](info.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/backup.md b/ts/docs/commands/backup.md index 69b4d08cd..148d8aaf3 100644 --- a/ts/docs/commands/backup.md +++ b/ts/docs/commands/backup.md @@ -1,40 +1,72 @@ # wallet-cli backup -Export an account's secret + metadata to a 0600 file. +Export an account's secret to a 0600 file, or review past exports. ## Synopsis ``` -wallet-cli backup [--out ] [options] +wallet-cli backup [--keystore] [--out ] [--password-stdin] [options] +wallet-cli backup --records [] [--from ] [--to ] [--limit ] [--offset ] [--account ] [options] ``` -## Arguments +## Description -- `account` — account or wallet to export, by accountId, label, or address +With an account, `backup` writes that account's secret material and metadata to a file created with mode **0600**, never overwriting an existing one. The secret goes only into the file — never to stdout. Watch-only and Ledger accounts have no secret to export and fail with `not_exportable` — checked before any password is demanded, so an account that cannot be exported never costs you a prompt. + +Two formats: + +- **Native** (default) — the wallet's own backup JSON. A seed account exports its recovery phrase, so the whole seed moves with it. +- **`--keystore`** — a standard Web3 keystore JSON, importable by TronLink and others, encrypted with **your master password**. A keystore holds a **single private key**: an HD account exports only its current derived key, and that key arrives elsewhere as a standalone account with nothing derivable from it. Use the native format to move a seed. + +**Files land in the current working directory** by default — `./-.json`, or `./-.keystore.json` with `--keystore`. `--out` overrides the path. + +> A file holding a private key or recovery phrase is now sitting in your working directory. Do not run this in a shared directory or inside a git repository: the CLI guarantees mode 0600 and refuses to overwrite, but it does not check whether the directory is safe or version-controlled. Move the file to secure storage and treat it as the key itself — see [Security](../concepts/security.md). + +With `--records` and no account, nothing is exported: the command lists the **local audit log of past exports** instead. One row per `backup` and `backup --keystore`, newest first, recording which account's secret left, when, and **which file it went to**. Imports are not logged — the log's purpose is a trail of secrets leaving. It keeps the most recent 1000 entries and drops the oldest beyond that. `Exported account` is the account whose secret was exported, and `--account` filters on it. + +**The two forms do not mix, and the CLI enforces that in both directions:** + +- `--keystore` and `--out` describe an export, so combining either with `--records` fails rather than being silently ignored. +- `--from` / `--to` / `--limit` / `--offset` filter the log, so any of them **without** `--records` fails too. + +Both are `invalid_value` at exit `2`, and the message names the offending flag — for example `invalid --offset: --offset filters the export log; it needs --records`. + +The positional account is the exception: it means different things in the two forms rather than conflicting with `--records`. `backup main` exports `main`'s secret; `backup main --records` lists `main`'s past exports, exactly as `--account main` would. ## Options | Option | Description | |---|---| -| `--out ` | output file path; omit to write /backups/-.json; mode 0600, never overwritten | -| `--password-stdin` | read the master password from stdin (fd 0) | +| `` | Account to export, by accountId, label, or address. Required unless `--records`; **with** `--records` it filters the log instead, like `--account` | +| `--keystore` | Export as a standard Web3 keystore instead of the native format | +| `--out ` | Output file path; mode 0600, never overwritten (default: the current directory, see above) | +| `--password-stdin` | Master password from stdin (fd 0) | -Plus [global options](index.md). +With `--records`, instead of an account: -## Notes +| Option | Description | +|---|---| +| `--records` | List past exports instead of exporting | +| `--from ` | Only records at or after this time, `YYYY-MM-DD[ HH:mm:ss]`, UTC | +| `--to ` | Only records at or before this time, same format | +| `--limit ` | Max records to return (default: all) | +| `--offset ` | Pagination offset (default `0`) | +| `--account ` | Only exports of this account, by accountId / label / address | -The file contains recoverable secret material — move it to secure storage and treat it as the key itself. See [Security](../concepts/security.md). +Plus the [global options](index.md#global-options-every-command). ## Examples In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. +Native export of a seed account — the recovery phrase: + ```bash printf '%s' "$PW" | wallet-cli backup main --password-stdin ``` ```console -⚠️ Backup written /backups/wlt_d1qbj2fb.0-1783751611076.json +⚠️ Backup written ./wlt_d1qbj2fb.0-1783751611076.json Account ID wlt_d1qbj2fb.0 Secret recovery phrase File mode 0600 @@ -43,36 +75,91 @@ printf '%s' "$PW" | wallet-cli backup main --password-stdin ⚠️ Secret material was written only to the backup file, never to stdout. ``` +As a keystore instead — a single private key: + +```bash +printf '%s' "$PW" | wallet-cli backup main --keystore --password-stdin +``` + +```console +⚠️ Keystore written ./wlt_d1qbj2fb.0-1785930000.keystore.json + Account ID wlt_d1qbj2fb.0 + Secret private key + File mode 0600 + Bytes 491 + +⚠️ Secret material was written only to the keystore file, never to stdout. +``` + ```bash -printf '%s' "$PW" | wallet-cli backup main --out ./main-backup.json --password-stdin -o json +printf '%s' "$PW" | wallet-cli backup main --keystore --out ./main.keystore.json --password-stdin -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"backup","data":{"accountId":"wlt_d1qbj2fb.0","label":"main","type":"seed","index":0,"active":true,"addresses":{"tron":"TJToBi4Ngr6JT3HqZHfCkKvuQTvqm73HHp"},"seedId":"wlt_d1qbj2fb","secretType":"mnemonic","out":"./main-backup.json","fileMode":"0600","bytes":277},"meta":{"durationMs":1387,"warnings":[]}} +{"schema":"wallet-cli.result.v1","success":true,"command":"backup","data":{"accountId":"wlt_d1qbj2fb.0","label":"main","type":"seed","index":0,"active":true,"addresses":{"tron":"TQkXm4vN...5Zt7Uw"},"seedId":"wlt_d1qbj2fb","secretType":"privateKey","format":"keystore","out":"./main.keystore.json","fileMode":"0600","bytes":491},"meta":{"durationMs":1420,"warnings":[]}} +``` + +The audit log: + +```bash +wallet-cli backup --records --limit 3 +``` + +```console +Backup records (showing 3 of 12) +| Time (UTC) | Exported account | Operation | File | +| ---------------- | ------------------------ | ----------------- | ----------------------------------------- | +| 2026-08-05 11:40 | TQkXm4vN...5Zt7Uw (main) | backup --keystore | ./wlt_d1qbj2fb.0-1785930000.keystore.json | +| 2026-08-04 09:12 | TQkXm4vN...5Zt7Uw (main) | backup | ./wlt_d1qbj2fb.0-1785834720.json | +| 2026-07-30 22:03 | TBeta9mR...8pLx | backup | ./tbeta-seed.json | +``` + +```bash +wallet-cli backup --records --limit 3 -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"backup.records","data":{"records":[{"operation":"backup --keystore","accountId":"wlt_d1qbj2fb.0","account":"TQkXm4vN...5Zt7Uw","label":"main","out":"./wlt_d1qbj2fb.0-1785930000.keystore.json","timestamp":"2026-08-05T11:40:00Z"},{"operation":"backup","accountId":"wlt_d1qbj2fb.0","account":"TQkXm4vN...5Zt7Uw","label":"main","out":"./wlt_d1qbj2fb.0-1785834720.json","timestamp":"2026-08-04T09:12:00Z"},{"operation":"backup","accountId":"wlt_9x3k2m7p.0","account":"TBeta9mR...8pLx","label":null,"out":"./tbeta-seed.json","timestamp":"2026-07-30T22:03:00Z"}]},"meta":{"durationMs":8,"warnings":[],"pagination":{"offset":0,"limit":3,"total":12}}} ``` ## Output -`data` is the backed-up account plus the backup file details. The secret is written only to the file, never to stdout. Local command — no `chain` block. +Both forms are local commands — no `chain` block — and they carry different `command` ids: `backup` for an export, `backup.records` for the log. + +`data` for an export is the account plus the file's details: | Field | Type | Meaning | |---|---|---| | `accountId` | string | Account id | | `label` | string | Account label | -| `type` | string | Account type (backupable: `seed` / `privateKey`) | +| `type` | string | Account type (exportable: `seed` / `privateKey`) | | `index` | number \| null | HD derivation index; `null` for private-key accounts | | `active` | boolean | Whether it is the active account | | `addresses.tron` | string | Base58 TRON address | | `seedId` | string | Owning seed wallet id (`seed` accounts only) | -| `secretType` | string | Kind of exported secret, e.g. `mnemonic` | -| `out` | string | Backup file path | +| `secretType` | string | Kind of exported secret — `mnemonic`, or `privateKey` with `--keystore` | +| `format` | string | `keystore` when `--keystore` was used | +| `out` | string | Path written | | `fileMode` | string | File permissions, always `0600` | | `bytes` | number | File size in bytes | +`data.records[]` for `--records`: + +| Field | Type | Meaning | +|---|---|---| +| `operation` | string | `backup` or `backup --keystore` | +| `accountId` / `account` / `label` | string \| null | The account whose secret was exported; `label` is `null` when unset | +| `out` | string | File the secret went to | +| `timestamp` | string | Export time, UTC | + +`meta.pagination` carries `offset`, `limit` (`null` = unlimited), and `total`. + ## Exit status -`0` success · `1` execution failure · `2` usage error. See [machine-interface](../machine-interface.md). +`0` success · `1` execution failure (`not_exportable` — watch-only or Ledger, `invalid_value` — no such account, `auth_failed`, `io_error` — path not writable) · `2` usage error (`output_exists` — the target file already exists and is never overwritten; `invalid_value` — a record filter without `--records`, `--keystore` / `--out` with `--records`, or a bad time / limit / offset). + +`invalid_value` appears under both exit codes here: an unresolvable account reference is exit `1`, a malformed call is exit `2`. Branch on the exit code first. ## See also -[Security model](../concepts/security.md) · [`delete`](delete.md) +[Security model](../concepts/security.md) · [`import keystore`](import/keystore.md) · [`delete`](delete.md) diff --git a/ts/docs/commands/chain/params.md b/ts/docs/commands/chain/params.md index 40eb3d823..9628ca6ba 100644 --- a/ts/docs/commands/chain/params.md +++ b/ts/docs/commands/chain/params.md @@ -52,12 +52,13 @@ wallet-cli chain params --network tron:nile ``` ```console -Key Value -getEnergyFee 210 SUN -getTransactionFee 1,000 SUN -getCreateAccountFee 100,000 SUN -getWitnessPayPerBlock 16,000,000 SUN -getMaintenanceTimeInterval 21,600,000 ms +| Key | Value | +| -------------------------- | -------------- | +| getEnergyFee | 210 SUN | +| getTransactionFee | 1,000 SUN | +| getCreateAccountFee | 100,000 SUN | +| getWitnessPayPerBlock | 16,000,000 SUN | +| getMaintenanceTimeInterval | 21,600,000 ms | ``` ```bash diff --git a/ts/docs/commands/contract/clear-abi.md b/ts/docs/commands/contract/clear-abi.md new file mode 100644 index 000000000..891620dc8 --- /dev/null +++ b/ts/docs/commands/contract/clear-abi.md @@ -0,0 +1,79 @@ +# wallet-cli contract clear-abi + +Clear the ABI a contract stores on chain. + +## Synopsis + +``` +wallet-cli contract clear-abi
+ [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Removes the ABI held on chain for a contract. **This cannot be undone** — the ABI is gone from the chain, and anything that decoded calls by reading it (explorers, SDKs, [`contract call`](call.md)) must supply its own from then on. + +What it does **not** touch: the bytecode and the contract's state are unaffected, and the contract stays callable exactly as before. The ABI is auxiliary metadata, not part of execution. + +Only the contract's deployer can do this — the address the chain records as the contract's origin, visible in [`contract info`](info.md). Other accounts fail with `not_contract_deployer`. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `
` | **Required.** Contract whose ABI to clear | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV...4wRe --network tron:nile --wait --password-stdin +``` + +```console +✅ ABI cleared + Contract TQ5nJ8mV...4wRe + Deployer TQkXm4vN...5Zt7Uw (main) + TxID 3f7... + Block 57,882,140 + Fee 0 TRX (287 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV...4wRe --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"contract.clear-abi","data":{"kind":"contract-clear-abi","stage":"confirmed","txId":"3f7...","confirmed":true,"blockNumber":57882140,"failed":false,"contractAddress":"TQ5nJ8mV...","deployerAddress":"TQkXm4vN...","feeSun":0,"resource":{"netUsage":287,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6510,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "contract-clear-abi"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address). + +## See also + +[`contract info`](info.md) · [`contract deploy`](deploy.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/contract/create2.md b/ts/docs/commands/contract/create2.md new file mode 100644 index 000000000..67176d4ff --- /dev/null +++ b/ts/docs/commands/contract/create2.md @@ -0,0 +1,82 @@ +# wallet-cli contract create2 + +Compute the address a CREATE2 deployment would land on. + +## Synopsis + +``` +wallet-cli contract create2 --deployer
(--code | --code-file ) --salt [options] +``` + +## Description + +Pure local arithmetic: no node is contacted, nothing is broadcast, and no account or password is involved. The result is the same on every TRON network, so `--network` does not affect it. + +**TRON's derivation is not Ethereum's** — do not compute it with an EVM calculator. The address is + +``` +sha3omit12( deployer (21 bytes, 0x41-prefixed) ‖ salt (32 bytes) ‖ keccak256(code) ) +``` + +where `sha3omit12` takes bytes `[11:32]` of the keccak256 digest, overwrites the first byte with `0x41`, and Base58Check-encodes the result. There is no `0xff` prefix: the 21-byte `0x41`-prefixed deployer already separates the domain. The same deployer, salt, and code therefore yield different addresses on TRON and Ethereum. + +**The code must be the creation bytecode with constructor arguments already appended** — not the runtime bytecode. One byte of difference in the constructor arguments gives an entirely different address. Creation bytecode usually runs to tens of thousands of characters, which is why `--code-file` exists; a `0x` prefix and any whitespace are stripped from either form. + +`--salt` is a decimal integer (64-bit signed). It is placed in the low bytes of a 32-byte salt with the rest zero-filled; hex salts are not accepted. + +Deploying with CREATE2 itself requires the chain to have TVM Constantinople enabled, but this command is arithmetic only and is not subject to that. + +## Options + +| Option | Description | +|---|---| +| `--deployer
` | **Required.** Address performing the CREATE2 — a factory contract or a plain account | +| `--code ` | Creation bytecode, constructor arguments included. One of `--code` / `--code-file` | +| `--code-file ` | Read the creation bytecode from a file — preferred, since it is usually very long. One of `--code` / `--code-file` | +| `--salt ` | **Required.** Salt as a decimal integer, zero-padded to 32 bytes | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli contract create2 --deployer TQkXm4vN...5Zt7Uw --code-file ./MyToken.creation.hex --salt 1 +``` + +```console +Contract address (CREATE2) + Deployer TQkXm4vN...5Zt7Uw + Salt 1 (0x000000…0001) + Code hash c8f4a1...b91b + Address TXm5RQ7d...9kPa +``` + +Short bytecode can go inline instead: + +```bash +wallet-cli contract create2 --deployer TQkXm4vN...5Zt7Uw --code 6080604052... --salt 255 -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"contract.create2","data":{"deployerAddress":"TQkXm4vN...","salt":255,"saltHex":"0x00000000000000000000000000000000000000000000000000000000000000ff","codeHash":"c8f4a1...b91b","address":"TWq8dK3n...2mHb"},"meta":{"durationMs":3,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `deployerAddress` | string | The deployer as given, base58 | +| `salt` | number | The salt as given, decimal | +| `saltHex` | string | The zero-padded 32 bytes that actually enter the hash | +| `codeHash` | string | `keccak256` of the creation bytecode | +| `address` | string | The resulting contract address, base58 | + +This is a local command, so the envelope carries no `chain` block. + +## Exit status + +`0` success · `1` execution failure (`io_error` — `--code-file` cannot be read) · `2` usage error (`missing_option` — no `--deployer` / `--salt`, or neither code source; `invalid_option` — both `--code` and `--code-file`; `invalid_value` — malformed deployer address, non-hex code, or a salt outside the 64-bit signed range). + +## See also + +[`contract deploy`](deploy.md) · [`contract info`](info.md) · [`encoding convert`](../encoding/convert.md) diff --git a/ts/docs/commands/contract/deploy.md b/ts/docs/commands/contract/deploy.md index 751d95e4d..fc98a9b7b 100644 --- a/ts/docs/commands/contract/deploy.md +++ b/ts/docs/commands/contract/deploy.md @@ -14,6 +14,11 @@ wallet-cli contract deploy --abi --bytecode --fee-limit Deploys compiled contract bytecode from the active account (or `--account`) and reports the new contract address. `--fee-limit` is **required** here (deployments are energy-heavy; there is no safe default). Constructor arguments go via `--params` alone — the parameter types are taken from the constructor entry in the `--abi` you pass. +Two shapes are checked before anything is built, both reported as `invalid_value` at exit `2`: + +- **`--params` takes raw positional values here**, e.g. `[100, "T..."]`. The `{"type","value"}` entries that [`contract call`](call.md) and [`contract send`](send.md) take are rejected — deploy reads the types from the ABI's constructor instead. +- **The ABI's `constructor` entry needs a string `stateMutability`** (`"nonpayable"` or `"payable"`). `solc` emits it; an ABI that was hand-trimmed, or produced by `solc` older than 0.5, may not have it. + Same execution model as other broadcast commands: `--dry-run` previews, `--sign-only` outputs a signed transaction for [`tx broadcast`](../tx/broadcast.md), default returns at submission, `--wait` blocks until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. @@ -30,7 +35,7 @@ Requires an account. The master password (via `--password-stdin`) is needed only | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | @@ -71,7 +76,7 @@ echo "$PW" | wallet-cli contract deploy --abi "$(cat MyToken.abi.json)" --byteco ## Exit status -`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value` — bad ABI/bytecode/params, missing `--fee-limit`). +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`watch_only_no_signer`, `auth_failed`, `rpc_error`, `timeout`) · `2` usage error (`missing_option` — no `--fee-limit`; `invalid_value` — bad ABI or bytecode, `--params` in `{"type","value"}` form, or an ABI constructor without a string `stateMutability`). ## See also diff --git a/ts/docs/commands/contract/index.md b/ts/docs/commands/contract/index.md index ccc7774a6..380e512e0 100644 --- a/ts/docs/commands/contract/index.md +++ b/ts/docs/commands/contract/index.md @@ -1,6 +1,8 @@ # wallet-cli contract -Call, send, deploy, and inspect smart contracts. +Call, send, deploy, inspect, and govern smart contracts. + +The governing part is the deployer's: who pays a call's energy, and whether the contract keeps an ABI on chain. Those settings belong to the account that deployed the contract and take effect as soon as the transaction confirms. `create2` is unrelated to any of that — it is local arithmetic over an address that does not exist yet. ## Synopsis @@ -16,6 +18,10 @@ wallet-cli contract COMMAND | `contract send` | [send.md](send.md) | State-changing call (triggerSmartContract) | | `contract deploy` | [deploy.md](deploy.md) | Deploy a smart contract | | `contract info` | [info.md](info.md) | Show contract ABI + metadata | +| `contract clear-abi` | [clear-abi.md](clear-abi.md) | Clear the on-chain ABI (irreversible) | +| `contract set-origin-energy-limit` | [set-origin-energy-limit.md](set-origin-energy-limit.md) | Energy the deployer covers per call | +| `contract set-user-resource-percent` | [set-user-resource-percent.md](set-user-resource-percent.md) | Share of a call's energy paid by the caller | +| `contract create2` | [create2.md](create2.md) | Compute a CREATE2 address locally | ## See also diff --git a/ts/docs/commands/contract/send.md b/ts/docs/commands/contract/send.md index e410d639a..287c24240 100644 --- a/ts/docs/commands/contract/send.md +++ b/ts/docs/commands/contract/send.md @@ -33,7 +33,7 @@ Requires an account. The master password (via `--password-stdin`) is needed only | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/contract/set-origin-energy-limit.md b/ts/docs/commands/contract/set-origin-energy-limit.md new file mode 100644 index 000000000..a71aa6af5 --- /dev/null +++ b/ts/docs/commands/contract/set-origin-energy-limit.md @@ -0,0 +1,85 @@ +# wallet-cli contract set-origin-energy-limit + +Set the energy the deployer will cover per call. + +## Synopsis + +``` +wallet-cli contract set-origin-energy-limit
+ [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Sets `origin_energy_limit` — the ceiling on how much energy the **deployer** is willing to pay for a single call to this contract. + +It is not a cap on the contract, and not a cap on the caller. What the deployer actually covers is bounded by three things at once: this limit, the deployer's own staked energy, and the caller/deployer split from [`contract set-user-resource-percent`](set-user-resource-percent.md). Whatever the deployer's side cannot cover falls back to the caller. Two ways this ends up doing nothing: the deployer has no staked energy (the subsidy is zero regardless of this limit), or the user share is 100 % (the deployer's portion is zero, so this limit never comes into play). + +`` must be an integer **greater than zero** — the chain rejects zero, and it is refused locally rather than broadcast. + +Only the contract's deployer can do this; the current value is in [`contract info`](info.md). Settings take effect as soon as the transaction confirms. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `
` | **Required.** Contract to configure; you must be its deployer | +| `` | **Required.** Per-call energy the deployer will cover, integer > 0 | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV...4wRe 50000000 --network tron:nile --wait --password-stdin +``` + +```console +✅ Origin energy limit set + Contract TQ5nJ8mV...4wRe + Deployer TQkXm4vN...5Zt7Uw (main) + Energy limit 50,000,000 + TxID 3a9... + Block 57,882,265 + Fee 0 TRX (290 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV...4wRe 50000000 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"contract.set-origin-energy-limit","data":{"kind":"contract-set-origin-energy-limit","stage":"confirmed","txId":"3a9...","confirmed":true,"blockNumber":57882265,"failed":false,"contractAddress":"TQ5nJ8mV...","deployerAddress":"TQkXm4vN...","originEnergyLimit":50000000,"feeSun":0,"resource":{"netUsage":290,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6530,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "contract-set-origin-energy-limit"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress`, `originEnergyLimit` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +`originEnergyLimit` is the value now in effect. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address, or energy not an integer > 0). + +## See also + +[`contract set-user-resource-percent`](set-user-resource-percent.md) · [`contract info`](info.md) · [Energy & bandwidth](../../concepts/energy-bandwidth.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/contract/set-user-resource-percent.md b/ts/docs/commands/contract/set-user-resource-percent.md new file mode 100644 index 000000000..0877f2df4 --- /dev/null +++ b/ts/docs/commands/contract/set-user-resource-percent.md @@ -0,0 +1,87 @@ +# wallet-cli contract set-user-resource-percent + +Set the share of a call's energy paid by the caller. + +## Synopsis + +``` +wallet-cli contract set-user-resource-percent
+ [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Sets `consume_user_resource_percent`: the percentage of a call's energy the **caller** pays. The remainder is covered by the deployer, itself capped by [`contract set-origin-energy-limit`](set-origin-energy-limit.md) and by the deployer's staked energy. + +`100` means callers pay everything and the deployer subsidises nothing — which also makes the origin energy limit irrelevant. `0` means the deployer pays everything within those caps. The value is an integer 0–100, validated locally. + +The number is the **caller's** share, matching the chain field's own direction; it is not inverted by this CLI. + +Only the contract's deployer can do this; the current value is in [`contract info`](info.md). Settings take effect as soon as the transaction confirms. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `
` | **Required.** Contract to configure; you must be its deployer | +| `` | **Required.** Share of energy paid by the caller, integer 0–100 | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +Callers pay the full energy cost: + +```bash +echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV...4wRe 100 --network tron:nile --wait --password-stdin +``` + +```console +✅ User pay ratio set + Contract TQ5nJ8mV...4wRe + Deployer TQkXm4vN...5Zt7Uw (main) + User pays 100% + TxID 8b2... + Block 57,882,388 + Fee 0 TRX (289 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV...4wRe 100 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"contract.set-user-resource-percent","data":{"kind":"contract-set-user-resource-percent","stage":"confirmed","txId":"8b2...","confirmed":true,"blockNumber":57882388,"failed":false,"contractAddress":"TQ5nJ8mV...","deployerAddress":"TQkXm4vN...","consumeUserResourcePercent":100,"feeSun":0,"resource":{"netUsage":289,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6470,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "contract-set-user-resource-percent"`, `stage: "submitted"`, `txId`, `contractAddress`, `deployerAddress`, `consumeUserResourcePercent` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +`consumeUserResourcePercent` is the value now in effect — the caller's share. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`contract_not_found` — no such contract, `not_contract_deployer`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — malformed address, or percent outside 0–100). + +## See also + +[`contract set-origin-energy-limit`](set-origin-energy-limit.md) · [`contract info`](info.md) · [Energy & bandwidth](../../concepts/energy-bandwidth.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/exchange/create.md b/ts/docs/commands/exchange/create.md new file mode 100644 index 000000000..3615a8a90 --- /dev/null +++ b/ts/docs/commands/exchange/create.md @@ -0,0 +1,91 @@ +# wallet-cli exchange create + +Create a Bancor exchange pair and seed both sides. + +## Synopsis + +``` +wallet-cli exchange create --pair : + (--amounts : | --raw-amounts :) + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Creates a pair and puts the initial liquidity into it in the same transaction. Either side may be TRX or a TRC10 asset id, and the two must differ. Any account can create a pair. + +**The creator binding is permanent.** From this point on, only the creating account can [inject](inject.md) or [withdraw](withdraw.md) this pair's liquidity, and the chain offers no way to move that right to another account. Creating from the wrong account leaves the liquidity under that account for good. + +The creation fee is **burned** — the chain parameter `getExchangeCreateFee`, currently around 1,024 TRX, readable with [`chain params`](../chain/params.md) — and both initial amounts leave your account on top of it. + +`--pair` and `--amounts` are positional to each other: `--pair TRX:1000123 --amounts 10000:500000` puts 10,000 on the TRX side and 500,000 on asset 1000123's side. That ratio is the pair's starting quote — here roughly 1 TRX to 50 tokens — and every trade thereafter moves it. + +**Tokens are named by id only** — `TRX` (or its on-chain id `_`) and a numeric TRC10 id. A TRC10 name may itself contain `:`, which would make `--pair` ambiguous; find an id with [`asset info `](../asset/info.md). + +`--amounts` is in whole tokens and is converted using each side's precision; `--raw-amounts` gives the same two numbers in minimal units. Exactly one of them is required. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `--pair :` | **Required.** The two sides — `TRX` or a TRC10 asset id; they must differ | +| `--amounts :` | Amount for each side in whole tokens, in `--pair` order; both > 0. Debited from your account and become the pair's reserves. One of `--amounts` / `--raw-amounts` | +| `--raw-amounts :` | The same two amounts in minimal units. One of `--amounts` / `--raw-amounts` | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 --network tron:nile --wait --password-stdin +``` + +```console +✅ Exchange created + Exchange id 12 + Creator TQkXm4vN...5Zt7Uw + Reserves 10,000 TRX / 500,000 MyToken + TxID 2b7... + Block #57,884,020 + Fee 1,024 TRX + Status success +``` + +```bash +echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.create","data":{"kind":"exchange-create","stage":"confirmed","txId":"2b7...","confirmed":true,"blockNumber":57884020,"failed":false,"exchangeId":12,"pair":"TRX:1000123","creatorAddress":"TQkXm4vN...","firstTokenId":"_","firstTokenQuant":"10000000000","firstTokenLabel":"TRX","firstTokenDecimals":6,"secondTokenId":"1000123","secondTokenQuant":"500000000000","secondTokenLabel":"MyToken","secondTokenDecimals":6,"feeSun":1024000000},"meta":{"durationMs":6680,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "exchange-create"`, `stage: "submitted"`, `txId`, `pair`, `creatorAddress`, and both sides' `…TokenId` / `…TokenQuant` / `…TokenLabel` / `…TokenDecimals` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `failed`, and `exchangeId` — assigned by the chain, so known only once confirmed | + +`firstTokenId` / `secondTokenId` are on-chain ids, so TRX appears as `"_"`. The quantities are **strings** in each token's minimal unit; `…TokenLabel` and `…TokenDecimals` are what text uses to print them as whole tokens. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`same_token` — both sides identical, `asset_not_found` — no TRC10 with that id, `transaction_rejected` — the node refused it, for example for lack of balance or a reserve above `getExchangeBalanceLimit`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--pair`; `invalid_option` — both or neither of `--amounts` / `--raw-amounts`; `invalid_amount` — a side is not a decimal number, or has more decimal places than that token allows; `invalid_value` — a malformed `:`, or a side ≤ 0). + +## See also + +[`exchange inject`](inject.md) · [`exchange trade`](trade.md) · [`exchange show`](show.md) · [`chain params`](../chain/params.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/exchange/index.md b/ts/docs/commands/exchange/index.md new file mode 100644 index 000000000..db13a016b --- /dev/null +++ b/ts/docs/commands/exchange/index.md @@ -0,0 +1,40 @@ +# wallet-cli exchange + +TRON's protocol-level Bancor exchange. + +Pairs trade **TRX against TRC10** — never TRC20 — and settle instantly against a bonding curve: no order book, no counterparty, no matching. Four properties differ from the AMMs most people are used to, and all four matter before you touch this group: + +- **A pair is private to its creator.** Only the account that created a pair can inject or withdraw its liquidity, and that binding cannot be transferred. There are no LP tokens and no outside liquidity providers. +- **Anyone can trade**, though — trading is open even though liquidity is not. +- **The protocol charges no fee.** `trade`, `inject`, and `withdraw` cost bandwidth only; the one charge is `create`, which burns `getExchangeCreateFee`. +- **Human amounts are scaled by node-supplied decimals.** Every `--amount` / `--amounts` / + `--min-received` is converted to base units using the TRC10 `precision` the node reports, so that + value decides the quantity you sign. It is checked against the protocol range 0..6 and against the + token id requested, but a wrong value inside that range cannot be caught locally. Use the + `--raw-*` variants when the exact base-unit quantity matters — they are used verbatim. +- **TRX's token id on chain is the underscore `_`.** Write `TRX` (any case) or an asset id; `_` is accepted too. json shows what actually went on chain, so TRX appears there as `"_"`. + +**Pricing follows the curve, not the ratio.** The ratio of the two reserves is a marginal quote — true only for a trade of size zero. A real trade moves along the curve, and the larger it is relative to the reserves, the worse the price it gets. That gap is the slippage, which is why [`exchange trade`](trade.md) always requires a floor (`--min-received` or `--slippage`), and why no command here prints a "price". To price a specific amount, run `exchange trade --dry-run` against the current reserves. Reserves are also capped by the chain parameter `getExchangeBalanceLimit`. + +**Tokens are named by id only in this group** — `TRX` or a numeric asset id, never a token name. Pairs are written with a colon (`--pair TRX:1000123`, `--amounts 10000:500000`), and TRC10 names may legally contain colons, so allowing names would make `--pair` ambiguous. Resolve a name to its id with [`asset info `](../asset/info.md). + +## Synopsis + +``` +wallet-cli exchange COMMAND +``` + +## Subcommands + +| Command | Page | Description | +|---|---|---| +| `exchange create` | [create.md](create.md) | Create a pair and seed both sides | +| `exchange inject` | [inject.md](inject.md) | Add liquidity in proportion to reserves | +| `exchange withdraw` | [withdraw.md](withdraw.md) | Take liquidity out in proportion to reserves | +| `exchange trade` | [trade.md](trade.md) | Swap one side for the other | +| `exchange show` | [show.md](show.md) | One pair's creator, creation time, and reserves | +| `exchange list` | [list.md](list.md) | List every pair on chain | + +## See also + +[`asset`](../asset/index.md) · [`tx send`](../tx/send.md) · [`chain params`](../chain/params.md) diff --git a/ts/docs/commands/exchange/inject.md b/ts/docs/commands/exchange/inject.md new file mode 100644 index 000000000..580878ba2 --- /dev/null +++ b/ts/docs/commands/exchange/inject.md @@ -0,0 +1,92 @@ +# wallet-cli exchange inject + +Add liquidity to a pair, in proportion to its reserves. + +## Synopsis + +``` +wallet-cli exchange inject --token + (--amount | --raw-amount ) + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +**Injection is two-sided.** You name one side and its amount; the chain computes the other side from the current reserve ratio and debits that as well. `--token TRX --amount 1000` on a pair holding 10,000 TRX and 500,000 tokens therefore also takes 50,000 tokens — you need enough of **both**, not just the one you named. + +Only the pair's creator can inject; any other account fails with `not_exchange_creator`. + +If the amount is so small that the computed other side rounds to zero, the chain rejects the transaction. That case is caught locally against the current reserves rather than broadcast. + +**Tokens are named by id only** — `TRX` (or its on-chain id `_`) and a numeric TRC10 id; a TRC10 name may contain `:`. `--amount` is in whole tokens of the side you named and is converted using its precision; `--raw-amount` gives the same figure in minimal units. Exactly one of them is required. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `` | **Required.** Exchange pair id | +| `--token ` | **Required.** The side you are specifying | +| `--amount ` | Amount for that side in whole tokens; the other side follows the reserve ratio. One of `--amount` / `--raw-amount` | +| `--raw-amount ` | The same amount in minimal units. One of `--amount` / `--raw-amount` | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 --network tron:nile --wait --password-stdin +``` + +```console +✅ Liquidity injected + Exchange id 12 + Creator TQkXm4vN...5Zt7Uw + Injected 1,000 TRX / 50,000 MyToken + Reserves 11,000 TRX / 550,000 MyToken + TxID 5c3... + Block #57,884,180 + Fee 0 TRX + Status success +``` + +```bash +echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.inject","data":{"kind":"exchange-inject","stage":"confirmed","txId":"5c3...","confirmed":true,"blockNumber":57884180,"failed":false,"exchangeId":12,"pair":"TRX:1000123","creatorAddress":"TQkXm4vN...","tokenId":"_","tokenQuant":"1000000000","tokenLabel":"TRX","tokenDecimals":6,"otherTokenId":"1000123","otherTokenQuant":"50000000000","otherTokenLabel":"MyToken","otherTokenDecimals":6,"reserveAfter":"11000000000","otherReserveAfter":"550000000000","feeSun":0},"meta":{"durationMs":6440,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` is flat — the side you named, the side that followed, and both reserves afterwards: + +| Field | Type | Meaning | +|---|---|---| +| `exchangeId` / `pair` / `creatorAddress` | number / string / string | The pair and its creator | +| `tokenId` / `tokenQuant` | string | The side you named and the amount debited from it, in minimal units | +| `tokenLabel` / `tokenDecimals` | string / number | How text renders that side in whole tokens | +| `otherTokenId` / `otherTokenQuant` / `otherTokenLabel` / `otherTokenDecimals` | — | The same four for the side computed from the ratio | +| `reserveAfter` / `otherReserveAfter` | string | The pair's balances after this injection, same order | + +TRX is identified as `"_"`; every quantity is a **string** in minimal units. Before confirmation the other side and both reserves are this command's own exact arithmetic; once confirmed the receipt's figure replaces it. `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `not_exchange_creator`, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `transaction_rejected` — the node refused it, for example for lack of balance, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--token`; `invalid_option` — both or neither of `--amount` / `--raw-amount`; `invalid_amount` — the amount is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or so small that the computed other side works out to zero). + +## See also + +[`exchange withdraw`](withdraw.md) · [`exchange show`](show.md) · [`exchange create`](create.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/exchange/list.md b/ts/docs/commands/exchange/list.md new file mode 100644 index 000000000..a67586bd4 --- /dev/null +++ b/ts/docs/commands/exchange/list.md @@ -0,0 +1,73 @@ +# wallet-cli exchange list + +List every exchange pair on chain. + +## Synopsis + +``` +wallet-cli exchange list [--limit ] [--offset ] [options] +``` + +## Description + +Lists pairs with their id, both tokens, reserves, and creator. Read-only, no account needed. For one pair on its own, use [`exchange show`](show.md). + +**`Pair` is in the order the chain stored it**, which is the order the creator supplied — TRX is not normalized to either side, so `1000124:TRX` and `TRX:1000123` both occur. The two numbers in `Reserves` follow that same order. + +**Reserves here are in minimal units, not whole tokens.** This command makes a single RPC and so has no token precisions to divide by; `exchange show` fetches them and prints whole tokens instead. The same pair therefore reads `6,672` here and `66.72` there. + +Paging happens on the node, and **there is no total**: the chain exposes no count of exchange pairs. The title reports the window it asked for — `Exchanges (limit 3, offset 0)` — not `showing 3 of N`, and `meta.pagination.total` is always `null`. To get everything, pass a `--limit` large enough to cover it. + +## Options + +| Option | Description | +|---|---| +| `--limit ` | Max pairs to return (default `10`) | +| `--offset ` | Pagination offset (default `0`) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli exchange list --limit 3 --network tron:nile +``` + +```console +Exchanges (limit 3, offset 0) +| ID | Pair | Reserves (minimal units) | Creator | +| -- | ----------- | ---------------------------------- | ----------------- | +| 14 | 1000124:TRX | 2,500,000,000,000 / 50,000,000,000 | TBeta9mR...8pLx | +| 13 | 1000125:TRX | 16,000,000 / 8,000,000,000 | TAlpha7k...3nQw | +| 12 | TRX:1000123 | 10,000,000,000 / 500,000,000,000 | TQkXm4vN...5Zt7Uw | +``` + +```bash +wallet-cli exchange list --limit 3 --network tron:nile -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.list","data":{"kind":"exchange-list","exchanges":[{"exchangeId":14,"pair":"1000124:TRX","creatorAddress":"TBeta9mR...","firstTokenId":"1000124","firstTokenBalance":"2500000000000","secondTokenId":"_","secondTokenBalance":"50000000000"},{"exchangeId":13,"pair":"1000125:TRX","creatorAddress":"TAlpha7k...","firstTokenId":"1000125","firstTokenBalance":"16000000","secondTokenId":"_","secondTokenBalance":"8000000000"},{"exchangeId":12,"pair":"TRX:1000123","creatorAddress":"TQkXm4vN...","firstTokenId":"_","firstTokenBalance":"10000000000","secondTokenId":"1000123","secondTokenBalance":"500000000000"}]},"meta":{"durationMs":52,"warnings":[],"pagination":{"offset":0,"limit":3,"total":null}},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data.kind` is `exchange-list`. `data.exchanges[]` — one entry per pair: + +| Field | Type | Meaning | +|---|---|---| +| `exchangeId` | number | Pair id | +| `pair` | string | The two sides as `tokenA:tokenB`, in stored order; TRX is spelled `TRX` here | +| `creatorAddress` | string | Creator, base58 | +| `firstTokenId` / `secondTokenId` | string | On-chain token ids, in stored order; TRX is `"_"` — it can be either side | +| `firstTokenBalance` / `secondTokenBalance` | string | Reserves, raw amounts in each token's smallest unit. **Strings**: reserves reach int64 and would lose precision as JSON numbers | + +`meta.pagination` carries `offset`, `limit`, and `total` — `total` is always `null` here, meaning "no count exists", not "zero". + +## Exit status + +`0` success · `1` execution failure (`rpc_error`) · `2` usage error (`invalid_value` — bad limit or offset). + +## See also + +[`exchange show`](show.md) · [`exchange trade`](trade.md) · [`asset list`](../asset/list.md) diff --git a/ts/docs/commands/exchange/show.md b/ts/docs/commands/exchange/show.md new file mode 100644 index 000000000..5e8dc043b --- /dev/null +++ b/ts/docs/commands/exchange/show.md @@ -0,0 +1,73 @@ +# wallet-cli exchange show + +Show one exchange pair. + +## Synopsis + +``` +wallet-cli exchange show [options] +``` + +## Description + +Reports a pair's creator, creation time, and both tokens with their reserves. Read-only, no account needed. + +**No price is shown.** The ratio of the reserves is a quoted rate that holds only in the limit of a zero-size trade; anything with volume settles further along the curve and returns less. Printing it would invite reading it as an executable price. To price a specific amount against the current reserves, run [`exchange trade --dry-run`](trade.md). + +Unlike [`exchange list`](list.md), this command resolves each side's name and precision, so **reserves print as whole tokens here** and the json carries `firstTokenLabel` / `firstTokenDecimals` and their `second*` counterparts alongside the raw balances. The same pair therefore reads `66.72` here and `6,672` in the list. + +`pair` and the `first*` / `second*` fields follow the order the chain stored them, which is the order the creator supplied — TRX is not normalized to either side. Text folds the two sides into a `Reserves` block in that same order. + +## Options + +| Option | Description | +|---|---| +| `` | **Required.** Exchange pair id | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli exchange show 12 --network tron:nile +``` + +```console +Exchange id 12 + Creator TQkXm4vN...5Zt7Uw + Created time 2026-08-02 09:15:00 UTC + Reserves + TRX 10,000 + MyToken (id 1000123) 500,000 +``` + +```bash +wallet-cli exchange show 12 --network tron:nile -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.show","data":{"kind":"exchange-show","exchangeId":12,"pair":"TRX:1000123","creatorAddress":"TQkXm4vN...","createTime":1785662100000,"firstTokenId":"_","firstTokenBalance":"10000000000","firstTokenLabel":"TRX","firstTokenDecimals":6,"secondTokenId":"1000123","secondTokenBalance":"500000000000","secondTokenLabel":"MyToken","secondTokenDecimals":6},"meta":{"durationMs":24,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data.kind` is `exchange-show`. + +| Field | Type | Meaning | +|---|---|---| +| `exchangeId` | number | Pair id | +| `pair` | string | The two sides as `tokenA:tokenB`, in stored order; TRX is spelled `TRX` here | +| `creatorAddress` | string | Creator, base58 — the only account that may inject or withdraw | +| `createTime` | number | Creation time, ms since epoch | +| `firstTokenId` / `secondTokenId` | string | On-chain token ids, in stored order; TRX is `"_"` — it can be either side | +| `firstTokenBalance` / `secondTokenBalance` | string | Reserves, raw amounts in each token's smallest unit. **Strings**: reserves reach int64 and would lose precision as JSON numbers | +| `firstTokenLabel` / `secondTokenLabel` | string | Token name, or `TRX` | +| `firstTokenDecimals` / `secondTokenDecimals` | number | Decimal places used to render the whole-token figures in text | + +## Exit status + +`0` success · `1` execution failure (`exchange_not_found` — no such pair, `rpc_error`) · `2` usage error (`invalid_value` — id not a number). + +## See also + +[`exchange list`](list.md) · [`exchange trade`](trade.md) · [`exchange inject`](inject.md) diff --git a/ts/docs/commands/exchange/trade.md b/ts/docs/commands/exchange/trade.md new file mode 100644 index 000000000..d3ba4121a --- /dev/null +++ b/ts/docs/commands/exchange/trade.md @@ -0,0 +1,109 @@ +# wallet-cli exchange trade + +Swap one side of a pair for the other. + +## Synopsis + +``` +wallet-cli exchange trade --sell + (--amount | --raw-amount ) + [--min-received | --raw-min-received | --slippage ] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Sells one side of the pair for the other along the Bancor curve. Settlement is immediate and needs no counterparty, and **anyone can trade** — unlike liquidity, trading is not restricted to the pair's creator. + +The floor is optional, and **omitting it means no slippage protection at all**: + +- `--min-received` is an absolute floor, **not an estimate**. If the trade would return less, the whole thing is rejected on chain as `slippage_exceeded` and only bandwidth is spent. It is the only defence against the price moving between signing and execution. `--raw-min-received` is the same figure in minimal units. +- `--slippage` is the convenience form: the CLI reads the current reserves, computes what the trade would return, subtracts that percentage, and sends the result as the floor. What reaches the chain is always an absolute number. +- **With none of the three**, the floor sent on chain is `1` — the lowest value the protocol accepts — so the trade takes any non-zero return at any price. The response carries a warning in `meta.warnings` saying so. + +At most one of the three may be given; combining them is a usage error. + +Slippage grows with trade size relative to the reserves — that is the curve, not a fee; the protocol takes no cut. Check depth with [`exchange show`](show.md), and price a specific amount with `exchange trade --dry-run`. + +**Tokens are named by id only** — `TRX` (or its on-chain id `_`) and a numeric TRC10 id; a TRC10 name may contain `:`. `--amount` is in whole tokens of the side being sold, `--raw-amount` in minimal units; exactly one of them is required. + +> **Trading may be closed on the network you are on.** java-tron refuses `ExchangeTransactionContract` outright until the TIP-836 hardening proposal (`getAllowHardenExchangeCalculation`) is activated — it is unset on both mainnet and Nile, and the command then fails with `exchange_trading_disabled`. [`exchange create`](create.md), [`inject`](inject.md) and [`withdraw`](withdraw.md) are unaffected. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `` | **Required.** Exchange pair id | +| `--sell ` | **Required.** The side you are selling; the other side is what you receive | +| `--amount ` | How much to sell, in whole tokens, > 0. One of `--amount` / `--raw-amount` | +| `--raw-amount ` | The same amount in minimal units. One of `--amount` / `--raw-amount` | +| `--min-received ` | Lowest acceptable return, in whole tokens; below it the trade reverts. At most one of the three floor flags | +| `--raw-min-received ` | The same floor in minimal units | +| `--slippage ` | Derive the floor from current reserves, minus this percentage; > 0 and < 100 | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +With an explicit floor: + +```bash +echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --min-received 4900 --network tron:nile --wait --password-stdin +``` + +```console +✅ Trade completed + Exchange id 12 + Trader TQkXm4vN...5Zt7Uw + Sold 100 TRX + Received 4,950 MyToken + Min accepted 4,900 MyToken + TxID d9a... + Block #57,884,455 + Fee 0 TRX + Status success +``` + +The same trade via `--slippage 1`: the CLI computes 4,950 from the current reserves, takes 1 % off, and sends 4,900 as the floor. + +```bash +echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --slippage 1 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.trade","data":{"kind":"exchange-trade","stage":"confirmed","txId":"d9a...","confirmed":true,"blockNumber":57884455,"failed":false,"exchangeId":12,"pair":"TRX:1000123","traderAddress":"TQkXm4vN...","soldTokenId":"_","soldQuant":"100000000","soldLabel":"TRX","soldDecimals":6,"receivedTokenId":"1000123","receivedLabel":"MyToken","receivedDecimals":6,"receivedQuant":"4950000000","estimatedReceivedQuant":"4950000000","minReceivedQuant":"4900000000","feeSun":0},"meta":{"durationMs":6490,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `exchangeId` / `pair` / `traderAddress` | number / string / string | The pair and the account that traded | +| `soldTokenId` / `soldQuant` | string | The side sold and how much, in minimal units | +| `soldLabel` / `soldDecimals` | string / number | How text renders that side in whole tokens | +| `receivedTokenId` / `receivedLabel` / `receivedDecimals` | — | The same three for the side received | +| `estimatedReceivedQuant` | string | What the Bancor curve predicted at build time — advisory, always present | +| `receivedQuant` | string | What the trade actually returned; **only once confirmed**, since it exists only in the receipt | +| `minReceivedQuant` | string | The floor that went on chain — yours, the one `--slippage` derived, or `"1"` when no floor was given | + +TRX is identified as `"_"`; every quantity is a **string** in minimal units. Before confirmation text shows `Estimated return` in place of `Received`. `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `exchange_trading_disabled` — the network is not accepting Bancor trades, `slippage_exceeded` — the return fell below the floor, `transaction_rejected` — the node refused it, for example for lack of balance, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--sell`; `invalid_option` — both or neither of `--amount` / `--raw-amount`, or more than one floor flag; `invalid_amount` — the amount or `--min-received` is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or a `--slippage` outside 0–100). + +## See also + +[`exchange show`](show.md) · [`exchange list`](list.md) · [`asset info`](../asset/info.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/exchange/withdraw.md b/ts/docs/commands/exchange/withdraw.md new file mode 100644 index 000000000..edb359982 --- /dev/null +++ b/ts/docs/commands/exchange/withdraw.md @@ -0,0 +1,90 @@ +# wallet-cli exchange withdraw + +Take liquidity out of a pair, in proportion to its reserves. + +## Synopsis + +``` +wallet-cli exchange withdraw --token + (--amount | --raw-amount ) + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +The mirror of [`exchange inject`](inject.md): you name one side and its amount, the other side follows the current reserve ratio, and both come back to your account. Only the pair's creator can withdraw. + +**Amounts that do not divide cleanly by the reserve ratio are refused.** Converting one side to the other has a precision requirement on chain — the quotient must be exact to within 0.01% — and an amount that fails it is rejected outright rather than rounded, as `precision_loss`. Round the amount to something the ratio divides and try again. + +**Tokens are named by id only** — `TRX` (or its on-chain id `_`) and a numeric TRC10 id; a TRC10 name may contain `:`. `--amount` is in whole tokens of the side you named; `--raw-amount` gives the same figure in minimal units. Exactly one of them is required. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `` | **Required.** Exchange pair id | +| `--token ` | **Required.** The side you are specifying | +| `--amount ` | Amount for that side in whole tokens; the other side follows the reserve ratio. One of `--amount` / `--raw-amount` | +| `--raw-amount ` | The same amount in minimal units. One of `--amount` / `--raw-amount` | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 --network tron:nile --wait --password-stdin +``` + +```console +✅ Liquidity withdrawn + Exchange id 12 + Creator TQkXm4vN...5Zt7Uw + Withdrawn 1,000 TRX / 50,000 MyToken + Reserves 10,000 TRX / 500,000 MyToken + TxID 8f6... + Block #57,884,310 + Fee 0 TRX + Status success +``` + +```bash +echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"exchange.withdraw","data":{"kind":"exchange-withdraw","stage":"confirmed","txId":"8f6...","confirmed":true,"blockNumber":57884310,"failed":false,"exchangeId":12,"pair":"TRX:1000123","creatorAddress":"TQkXm4vN...","tokenId":"_","tokenQuant":"1000000000","tokenLabel":"TRX","tokenDecimals":6,"otherTokenId":"1000123","otherTokenQuant":"50000000000","otherTokenLabel":"MyToken","otherTokenDecimals":6,"reserveAfter":"10000000000","otherReserveAfter":"500000000000","feeSun":0},"meta":{"durationMs":6460,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` is flat, and identical in shape to [`exchange inject`](inject.md#output): + +| Field | Type | Meaning | +|---|---|---| +| `exchangeId` / `pair` / `creatorAddress` | number / string / string | The pair and its creator | +| `tokenId` / `tokenQuant` | string | The side you named and the amount returned from it, in minimal units | +| `tokenLabel` / `tokenDecimals` | string / number | How text renders that side in whole tokens | +| `otherTokenId` / `otherTokenQuant` / `otherTokenLabel` / `otherTokenDecimals` | — | The same four for the side computed from the ratio | +| `reserveAfter` / `otherReserveAfter` | string | The pair's balances after this withdrawal, same order | + +TRX is identified as `"_"`; every quantity is a **string** in minimal units. `--wait` adds `stage: "confirmed"`, `confirmed`, `blockNumber`, `feeSun`, `failed`. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`exchange_not_found` — no such pair, `not_exchange_creator`, `token_not_in_exchange`, `exchange_closed` — a side holds zero, `insufficient_reserve` — more than that side holds, `precision_loss` — the amount does not convert cleanly, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--token`; `invalid_option` — both or neither of `--amount` / `--raw-amount`; `invalid_amount` — the amount is not a decimal number, or has more decimal places than that token allows; `invalid_value` — amount ≤ 0, or so small that the computed other side works out to zero). + +## See also + +[`exchange inject`](inject.md) · [`exchange show`](show.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/gasfree/transfer.md b/ts/docs/commands/gasfree/transfer.md index 27cf9ba10..11cc9e9b6 100644 --- a/ts/docs/commands/gasfree/transfer.md +++ b/ts/docs/commands/gasfree/transfer.md @@ -79,7 +79,7 @@ wallet-cli gasfree transfer --to TBy6mQ7Y3nJ8sD2fWpXk4LhVc9Ra1Zt5Ub --amount 25 ``` ```console -📋 Dry run — GasFree transfer 25 USDT (not submitted) +⏳ Dry run — GasFree transfer 25 USDT (not submitted) From TVjsyZ7fYF3qCcNaMxN5PMWmSgYcCyqZfw (GasFree address, not activated) To TBy6mQ7Y3nJ8sD2fWpXk4LhVc9Ra1Zt5Ub Fee 1.5 USDT (0.5 service + 1.0 activation) @@ -105,7 +105,7 @@ A provider-side failure still leaves the envelope at `success: true` and exit `0 ## Exit status -`0` submitted (or dry-run) · `1` execution failure (`gasfree_credentials_missing`, `insufficient_token_balance` — token balance < amount + service fee [+ activation fee], `unsupported_token`, `gasfree_rejected` — the provider declined the authorization, `gasfree_integrity` — the provider's fee metadata disagreed with itself, `watch_only_no_signer`, `wrong_password`, `auth_failed`, `signing_rejected`, `provider_error`) · `2` usage error (`invalid_value`, `invalid_amount`). +`0` submitted (or dry-run) · `1` execution failure (`gasfree_credentials_missing`, `insufficient_token_balance` — token balance < amount + service fee [+ activation fee], `unsupported_token`, `gasfree_rejected` — the provider declined the authorization, `gasfree_integrity` — the provider's fee metadata disagreed with itself, `watch_only_no_signer`, `auth_failed`, `signing_rejected`, `provider_error`) · `2` usage error (`invalid_value`, `invalid_amount`). ## See also diff --git a/ts/docs/commands/import/index.md b/ts/docs/commands/import/index.md index 50aa6aa52..8718cf50f 100644 --- a/ts/docs/commands/import/index.md +++ b/ts/docs/commands/import/index.md @@ -14,6 +14,7 @@ wallet-cli import COMMAND |---|---| | [`import mnemonic`](mnemonic.md) | Import a BIP39 mnemonic phrase | | [`import private-key`](private-key.md) | Import a raw private key | +| [`import keystore`](keystore.md) | Import an account from a Web3 keystore file | | `import ledger` | Register a Ledger account (watch-only locally; signs on device) — `wallet-cli import ledger --help` | | `import watch` | Register a watch-only address (no secret) — `wallet-cli import watch --help` | diff --git a/ts/docs/commands/import/keystore.md b/ts/docs/commands/import/keystore.md new file mode 100644 index 000000000..fac1dcf6f --- /dev/null +++ b/ts/docs/commands/import/keystore.md @@ -0,0 +1,82 @@ +# wallet-cli import keystore + +Import an account from a Web3 keystore file. **Interactive-only.** + +> **Note**: there are no stdin flags here. Both the master password and the keystore file's own password are entered **only** via hidden TTY prompts — the file password is secret material like any other. + +## Synopsis + +``` +wallet-cli import keystore [--label ] +``` + +## Description + +Imports a single account from a standard Web3 keystore JSON — the format TronLink exports, and what [`backup --keystore`](../backup.md) writes — and stores it encrypted under your master password. The imported wallet becomes active. + +A keystore holds **one private key**, so the resulting account has no seed and nothing can be derived from it, exactly like [`import private-key`](private-key.md). Its `type` is recorded as `privateKey`. + +Two passwords are involved and they are unrelated: your master password encrypts the account into local storage, the keystore's own password decrypts the file. They are prompted in that order, and only **after** the file has been read and structurally checked — so a mistyped path costs no password typing. + +Without a TTY the command fails with `tty_required` at exit `2`, and that check runs **first**, ahead of the file. In a non-interactive environment every call fails the same way whether the path is good or not; the file-before-password ordering above only applies once you have a terminal. + +If an account with the same address already exists locally, the import is **refused** rather than overwriting it: replacing an address silently could destroy the seed backup an existing account depends on. Delete the existing account first if replacement is what you want. + +## Options + +| Option | Description | +|---|---| +| `` | **Required.** Path to the keystore JSON file | +| `--label ` | Human-friendly unique account label, 1–64 chars; omit to auto-generate | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli import keystore ./tronlink-export.json --label imported +``` + +```console +? Master password (hidden): +? Keystore file password (hidden): +✅ Imported wallet "imported" + Account ID wlt_7h2k9m1a + Type private key + TRON address TZx9kP2m...7bWq + Active yes + +⚠️ The keystore password was read from hidden input and was not printed. +``` + +```bash +wallet-cli import keystore ./tronlink-export.json --label imported -o json +``` + +```console +? Master password (hidden): +? Keystore file password (hidden): +{"schema":"wallet-cli.result.v1","success":true,"command":"import.keystore","data":{"status":"created","accountId":"wlt_7h2k9m1a","label":"imported","type":"privateKey","index":null,"active":true,"addresses":{"tron":"TZx9kP2m...7bWq"}},"meta":{"durationMs":44,"warnings":[]}} +``` + +## Output + +`data` carries the imported account — addresses only, never any secret. Local command — no `chain` block. + +| Field | Type | Meaning | +|---|---|---| +| `status` | string | `"created"` | +| `accountId` | string | Stable account id | +| `label` | string | Account label | +| `type` | string | `"privateKey"` (standalone, no seed) | +| `index` | number \| null | Non-HD account, always `null` | +| `active` | boolean | Became the active account | +| `addresses.tron` | string | Base58 TRON address | + +## Exit status + +`0` imported · `1` execution failure (`keystore_not_found` — no such file; `invalid_keystore` — not a valid keystore JSON; `wrong_keystore_password`; `account_exists` — this address is already in the wallet; `auth_failed`; `io_error`) · `2` usage error (`tty_required` — no TTY for interactive input, checked before anything else; duplicate label). + +## See also + +[`backup`](../backup.md) · [`import private-key`](private-key.md) · [`delete`](../delete.md) · [machine-interface → Secret handling](../../machine-interface.md#secret-handling) diff --git a/ts/docs/commands/index.md b/ts/docs/commands/index.md index 06df98967..cb15139aa 100644 --- a/ts/docs/commands/index.md +++ b/ts/docs/commands/index.md @@ -7,8 +7,10 @@ Every command — including every subcommand — has its own page, following a f | Command | Page | |---|---| | `create` | [create.md](create.md) | +| `import` (group) | [import/index.md](import/index.md) | | `import mnemonic` | [import/mnemonic.md](import/mnemonic.md) *(interactive-only)* | | `import private-key` | [import/private-key.md](import/private-key.md) *(interactive-only)* | +| `import keystore` | [import/keystore.md](import/keystore.md) *(interactive-only)* | | `import ledger` | [import/ledger.md](import/ledger.md) | | `import watch` | [import/watch.md](import/watch.md) | | `list` | [list.md](list.md) | @@ -77,6 +79,10 @@ Every command — including every subcommand — has its own page, following a f | `contract send` | [contract/send.md](contract/send.md) | | `contract deploy` | [contract/deploy.md](contract/deploy.md) | | `contract info` | [contract/info.md](contract/info.md) | +| `contract clear-abi` | [contract/clear-abi.md](contract/clear-abi.md) | +| `contract set-origin-energy-limit` | [contract/set-origin-energy-limit.md](contract/set-origin-energy-limit.md) | +| `contract set-user-resource-percent` | [contract/set-user-resource-percent.md](contract/set-user-resource-percent.md) | +| `contract create2` | [contract/create2.md](contract/create2.md) | | `gasfree` (group) | [gasfree/index.md](gasfree/index.md) | | `gasfree info` | [gasfree/info.md](gasfree/info.md) | | `gasfree transfer` | [gasfree/transfer.md](gasfree/transfer.md) | @@ -103,6 +109,40 @@ Every command — including every subcommand — has its own page, following a f | `reward balance` | [reward/balance.md](reward/balance.md) | | `reward withdraw` | [reward/withdraw.md](reward/withdraw.md) | +## Governance + +| Command | Page | +|---|---| +| `proposal` (group) | [proposal/index.md](proposal/index.md) | +| `proposal list` | [proposal/list.md](proposal/list.md) | +| `proposal show` | [proposal/show.md](proposal/show.md) | +| `proposal create` | [proposal/create.md](proposal/create.md) | +| `proposal approve` | [proposal/approve.md](proposal/approve.md) | +| `proposal delete` | [proposal/delete.md](proposal/delete.md) | +| `witness` (group) | [witness/index.md](witness/index.md) | +| `witness create` | [witness/create.md](witness/create.md) | +| `witness update` | [witness/update.md](witness/update.md) | +| `witness set-brokerage` | [witness/set-brokerage.md](witness/set-brokerage.md) | + +## TRC10 assets and the on-chain exchange + +| Command | Page | +|---|---| +| `asset` (group) | [asset/index.md](asset/index.md) | +| `asset issue` | [asset/issue.md](asset/issue.md) | +| `asset update` | [asset/update.md](asset/update.md) | +| `asset participate` | [asset/participate.md](asset/participate.md) | +| `asset unfreeze` | [asset/unfreeze.md](asset/unfreeze.md) | +| `asset info` | [asset/info.md](asset/info.md) | +| `asset list` | [asset/list.md](asset/list.md) | +| `exchange` (group) | [exchange/index.md](exchange/index.md) | +| `exchange create` | [exchange/create.md](exchange/create.md) | +| `exchange inject` | [exchange/inject.md](exchange/inject.md) | +| `exchange withdraw` | [exchange/withdraw.md](exchange/withdraw.md) | +| `exchange trade` | [exchange/trade.md](exchange/trade.md) | +| `exchange show` | [exchange/show.md](exchange/show.md) | +| `exchange list` | [exchange/list.md](exchange/list.md) | + ## Signing | Command | Page | @@ -116,7 +156,9 @@ Every command — including every subcommand — has its own page, following a f | Command | Page | |---|---| +| `encoding` (group) | [encoding/index.md](encoding/index.md) | | `encoding convert` | [encoding/convert.md](encoding/convert.md) | +| `address` (group) | [address/index.md](address/index.md) | | `address generate` | [address/generate.md](address/generate.md) | | `config` | [config.md](config.md) | | `networks` | [networks.md](networks.md) | @@ -133,3 +175,5 @@ Every command — including every subcommand — has its own page, following a f ``` Broadcast (✍️) commands additionally take `--wait` / `--wait-timeout ` (cap default: config `waitTimeoutMs`, built-in 60000), the early-exit modes `--dry-run` / `--sign-only` / `--build-only`, and the multi-sig options `--permission-id ` / `--expiration `. + +The three early-exit modes are mutually exclusive, and `--expiration` is accepted only alongside `--sign-only` or `--build-only`. Breaking either rule is a usage error at exit `2`. The code depends on where the check runs: on the governance and asset/exchange writes it is `invalid_value`, and the message names the field as `--input` rather than the flags you passed — for example `invalid --input: choose at most one of --dry-run, --sign-only, --build-only`. Elsewhere the same conflict reports `invalid_option`. Branch on the exit code, not on the code string; see [machine interface](../machine-interface.md#error-codes). diff --git a/ts/docs/commands/permission/update.md b/ts/docs/commands/permission/update.md index 7a916905c..39285de00 100644 --- a/ts/docs/commands/permission/update.md +++ b/ts/docs/commands/permission/update.md @@ -43,8 +43,8 @@ Changing only `keys`, `threshold` or `name` needs no such deletion. | `--dry-run` | Mock receipt — fee, resulting-structure card, and warnings — matching a real submission; no signature, no broadcast, no password. Excludes `--sign-only` / `--build-only` | | `--sign-only` | Build and sign, output the signed hex without broadcasting (feed [`tx broadcast`](../tx/broadcast.md) for on-chain co-signing). Excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex (feed [`tx multisig --create`](../tx/multisig.md) for service-relayed multi-sig). Excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only` | -| `--permission-id ` | Permission group to sign with — changing permissions is owner-level, so normally `0` (default `0`) | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active) — changing permissions is owner-level, so normally `0` (default `0`) | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | @@ -124,7 +124,7 @@ Local warnings (`owner_lockout`, `owner_lockout_partial`, `active_can_update_per ## Exit status -`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`invalid_permission`, `not_authorized`, `watch_only_no_signer`, `wrong_password`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`). +`0` submitted (or built/signed/dry-run in early-exit modes) · `1` execution failure (`invalid_permission`, `not_authorized`, `watch_only_no_signer`, `auth_failed`, `insufficient_balance`, `rpc_error`, `timeout`) · `2` usage error (`invalid_value`). On a multi-sig account, a submission whose accumulated signature weight is below the permission threshold is refused **after signing and before broadcasting** with `not_authorized` (`signature threshold is not reached; missing N weight`) — nothing is sent and no fee is burned. Collect the remaining signatures through `--sign-only` + [`tx sign`](../tx/sign.md) and submit with [`tx broadcast`](../tx/broadcast.md) instead. `--sign-only` and `--build-only` still return a partial signature, which is how a co-signing flow starts. diff --git a/ts/docs/commands/proposal/approve.md b/ts/docs/commands/proposal/approve.md new file mode 100644 index 000000000..4acbb58ab --- /dev/null +++ b/ts/docs/commands/proposal/approve.md @@ -0,0 +1,96 @@ +# wallet-cli proposal approve + +Approve a proposal, or cancel your approval. + +## Synopsis + +``` +wallet-cli proposal approve [--cancel] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Adds your approval to a proposal; `--cancel` withdraws an approval you already cast. TRON governance has these two states only — there is no "against" vote, and abstaining means doing nothing. + +Only a registered witness can approve; other accounts fail with `not_a_witness`. The chain checks nothing beyond that, so a non-elected candidate's approval is accepted and lands on chain — but at tally only the approvals of the **top-27 active SRs** count toward the threshold, so it does not move the proposal any closer to passing. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `` | **Required.** Proposal id | +| `--cancel` | Withdraw an approval you cast earlier instead of adding one | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli proposal approve 47 --network tron:nile --wait --password-stdin +``` + +```console +✅ Proposal approved + Proposal #47 + Voter TSRmq8kP...9dEf (main) + Approvals 13 / 18 + TxID b1e... + Block 57,880,240 + Fee 0 TRX (267 bandwidth) + Status success +``` + +`--cancel` takes your own approval back off the proposal: + +```bash +echo "$PW" | wallet-cli proposal approve 47 --cancel --network tron:nile --wait --password-stdin +``` + +```console +✅ Approval canceled + Proposal #47 + Voter TSRmq8kP...9dEf (main) + Approvals 12 / 18 + TxID b2f... + Block 57,880,255 + Fee 0 TRX (267 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli proposal approve 47 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"proposal.approve","data":{"kind":"proposal-approve","stage":"confirmed","txId":"b1e...","confirmed":true,"blockNumber":57880240,"failed":false,"proposalId":47,"addApproval":true,"feeSun":0,"resource":{"netUsage":267,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6410,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "proposal-approve"`, `stage: "submitted"`, `txId`, `proposalId`, `addApproval` (`false` with `--cancel`) | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `proposal_not_found` — no such proposal, `already_approved` — you already approved it, `not_approved` — `--cancel` with no approval to withdraw, `proposal_expired`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — id not a number). + +## See also + +[`proposal show`](show.md) · [`proposal list`](list.md) · [`proposal delete`](delete.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/proposal/create.md b/ts/docs/commands/proposal/create.md new file mode 100644 index 000000000..c1cf2b7dd --- /dev/null +++ b/ts/docs/commands/proposal/create.md @@ -0,0 +1,111 @@ +# wallet-cli proposal create + +Create a governance proposal that changes chain parameters. + +## Synopsis + +``` +wallet-cli proposal create --set = [--set ...] + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Submits a proposal carrying one or more chain-parameter changes for super representatives to vote on. Only a registered witness can create one; other accounts fail with `not_a_witness`. + +`--set` takes a parameter **name** — the `getXxx` vocabulary of [`chain params`](../chain/params.md) — and resolves it to the on-chain parameter id; a raw numeric id also works. Unknown names and out-of-range values are rejected locally, before anything is broadcast. + +Pass `--set` once per parameter. The receipt and `data.changes[]` order changes by parameter id, not by the order you typed them, so the same proposal always renders the same way. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `--set =` | **Required, repeatable.** One parameter change, e.g. `--set getTransactionFee=15`; `name` is a `chain params` key, a raw parameter id is also accepted | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +One parameter, waiting for confirmation: + +```bash +echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --network tron:nile --wait --password-stdin +``` + +```console +✅ Proposal created + Proposal #48 + Proposer TSRmq8kP...9dEf (main) + Parameter changes (1) + getTransactionFee 10 → 15 sun/byte + TxID 9c4... + Block 57,880,102 + Fee 0 TRX (268 bandwidth) + Status success +``` + +Several parameters in one proposal — the receipt lists them by parameter id: + +```bash +echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --set getCreateAccountFee=200000 --network tron:nile --wait --password-stdin +``` + +```console +✅ Proposal created + Proposal #49 + Proposer TSRmq8kP...9dEf (main) + Parameter changes (2) + getCreateAccountFee 100000 → 200000 sun + getTransactionFee 10 → 15 sun/byte + TxID a1b... + Block 57,880,140 + Fee 0 TRX (292 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli proposal create --set getTransactionFee=15 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"proposal.create","data":{"kind":"proposal-create","stage":"confirmed","txId":"9c4...","confirmed":true,"blockNumber":57880102,"feeSun":0,"resource":{"netUsage":268,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0},"failed":false,"proposalId":48,"changes":[{"id":3,"name":"getTransactionFee","currentValue":10,"proposedValue":15,"unit":"sun/byte"}]},"meta":{"durationMs":6480,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "proposal-create"`, `stage: "submitted"`, `txId`, `changes[]` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed`, and `proposalId` — the new proposal's id, known only once it is on chain | + +`proposalId` is **omitted** when the id cannot be established beyond doubt. The chain does not +report it, so it is recognised by comparing the proposal list against a snapshot taken before +submitting; if the node's list has not caught up yet, or more than one new proposal matches these +parameters, a warning says so and the field is absent. Treat it as optional and fall back to +[`proposal list`](list.md) — a guessed id would be passed on to `proposal approve` or the +irreversible `proposal delete`. The transaction itself has succeeded either way. + +`changes[]` entries carry `id`, `name`, `currentValue`, `proposedValue`, and `unit`, ordered by `id`. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--set` given; `unknown_parameter` — no such name or id; `invalid_value` — value out of range or not a number). + +## See also + +[`proposal approve`](approve.md) · [`proposal delete`](delete.md) · [`proposal show`](show.md) · [`chain params`](../chain/params.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/proposal/delete.md b/ts/docs/commands/proposal/delete.md new file mode 100644 index 000000000..2cbb069a2 --- /dev/null +++ b/ts/docs/commands/proposal/delete.md @@ -0,0 +1,79 @@ +# wallet-cli proposal delete + +Delete a proposal you created. + +## Synopsis + +``` +wallet-cli proposal delete + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Withdraws the proposal itself. Only its creator can do this, and only while it is still in its voting window; afterwards the proposal has been tallied and is final. + +This is a different action from [`proposal approve --cancel`](approve.md), which withdraws a single approval. The receipts say so: `Proposal deleted` here, `Approval canceled` there. + +The chain records the result under its own name — after a successful delete, [`proposal show`](show.md) reports `State canceled`. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `` | **Required.** Proposal id | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli proposal delete 48 --network tron:nile --wait --password-stdin +``` + +```console +✅ Proposal deleted + Proposal #48 + Proposer TSRmq8kP...9dEf (main) + TxID c7d... + Block 57,880,355 + Fee 0 TRX (265 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli proposal delete 48 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"proposal.delete","data":{"kind":"proposal-delete","stage":"confirmed","txId":"c7d...","confirmed":true,"blockNumber":57880355,"failed":false,"proposalId":48,"feeSun":0,"resource":{"netUsage":265,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6390,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "proposal-delete"`, `stage: "submitted"`, `txId`, `proposalId` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`proposal_not_found` — no such proposal, `not_proposal_owner` — you are not its creator, `proposal_expired`, `already_canceled`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — id not a number). + +## See also + +[`proposal create`](create.md) · [`proposal approve`](approve.md) · [`proposal show`](show.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/proposal/index.md b/ts/docs/commands/proposal/index.md new file mode 100644 index 000000000..41afa25d8 --- /dev/null +++ b/ts/docs/commands/proposal/index.md @@ -0,0 +1,34 @@ +# wallet-cli proposal + +Create and vote on governance proposals. + +A proposal is a set of **chain-parameter changes** — the same parameters [`chain params`](../chain/params.md) reports — that super representatives vote on. Reading proposals is open to anyone; creating, approving, and deleting them requires a registered witness ([`witness create`](../witness/create.md)). + +The mechanics that shape every subcommand: + +- **Approve or un-approve only.** There is no "against" vote — an SR either adds its approval or withdraws it. +- **Nothing settles early.** A proposal stays in its voting window until `expiration_time`, even once it has enough approvals; it is tallied at the maintenance cycle that follows. +- **Only the top-27 active SRs count.** Any registered witness can approve and the transaction succeeds, but the tally filters to active SRs and needs ≥ 70 % of them. +- **Approved changes apply immediately** at that tally — the parameter is live from then on. + +States: `voting` (in the window) · `approved` (met the threshold, applied, final) · `disapproved` (expired below the threshold, final) · `canceled` (withdrawn by its creator before expiry, final). + +## Synopsis + +``` +wallet-cli proposal COMMAND +``` + +## Subcommands + +| Command | Page | Description | +|---|---|---| +| `proposal list` | [list.md](list.md) | List proposals with approval progress | +| `proposal show` | [show.md](show.md) | Full detail of one proposal | +| `proposal create` | [create.md](create.md) | Create a proposal to change chain parameters | +| `proposal approve` | [approve.md](approve.md) | Approve a proposal, or cancel your approval | +| `proposal delete` | [delete.md](delete.md) | Delete a proposal you created | + +## See also + +[`witness`](../witness/index.md) · [`chain params`](../chain/params.md) · [`vote list`](../vote/list.md) diff --git a/ts/docs/commands/proposal/list.md b/ts/docs/commands/proposal/list.md new file mode 100644 index 000000000..6f5485281 --- /dev/null +++ b/ts/docs/commands/proposal/list.md @@ -0,0 +1,88 @@ +# wallet-cli proposal list + +List on-chain governance proposals. + +## Synopsis + +``` +wallet-cli proposal list [--state ] [--limit ] [--offset ] [options] +``` + +## Description + +Lists proposals with their approval progress, expiry, and the chain parameters each one would set. Parameters are shown by name, using the same vocabulary as [`chain params`](../chain/params.md). Read-only, no account needed. + +**`Value` is what the proposal would set, not the value in effect now.** A proposal records only its target values — the chain keeps no record of what a parameter was when the proposal was created. For a settled proposal the current value is unrelated to that baseline, and for an approved one it *is* the value that proposal installed. Use [`chain params`](../chain/params.md) for the values in effect now. + +A proposal can set several parameters at once. The list never truncates them: the first parameter sits on the proposal's row, the rest continue on their own rows with the left-hand columns blank. Parameters are ordered by parameter id, so the same proposal always prints in the same order; `data.proposals[].parameters[]` uses that order too. + +Filtering is client-side and happens before pagination: `--state` narrows the set, then `--offset` / `--limit` cut a window out of it. The title carries the count — `Proposals (4)` for the whole set, `Proposals (showing 2 of 4)` once a window is in play. Exact numbers are in `meta.pagination`. When nothing matches, the title is followed by `(none)`. + +## Options + +| Option | Description | +|---|---| +| `--state ` | `active` = still inside the voting window (default); `all` = also approved, disapproved, and canceled ones | +| `--limit ` | Max proposals to return (default: all) | +| `--offset ` | Pagination offset (default `0`) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli proposal list --state all --network tron:nile +``` + +```console +Proposals (4) + ID State Approvals Expiry (UTC) Parameter Value + 47 voting 12 / 18 2026-07-22 08:00 getTransactionFee 15 + 46 voting 5 / 18 2026-07-22 08:00 getCreateAccountFee 200000 + 45 approved 18 / 18 2026-07-21 08:00 getEnergyFee 140 + 44 disapproved 8 / 18 2026-07-20 08:00 getMaintenanceTimeInterval 10800000 + getMaxCpuTimeOfOneTx 80 +``` + +Second page — skip the first two, two per page: + +```bash +wallet-cli proposal list --state all --offset 2 --limit 2 --network tron:nile +``` + +```console +Proposals (showing 2 of 4) + ID State Approvals Expiry (UTC) Parameter Value + 45 approved 18 / 18 2026-07-21 08:00 getEnergyFee 140 + 44 disapproved 8 / 18 2026-07-20 08:00 getMaintenanceTimeInterval 10800000 + getMaxCpuTimeOfOneTx 80 +``` + +```bash +wallet-cli proposal list --state all --network tron:nile -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"proposal.list","data":{"approvalThreshold":18,"proposals":[{"id":47,"proposerAddress":"TSRmq8kP...","state":"voting","approvals":12,"expirationTime":1784707200000,"parameters":[{"id":3,"name":"getTransactionFee","value":15,"unit":"sun/byte"}]},{"id":44,"proposerAddress":"TSRee5...","state":"disapproved","approvals":8,"expirationTime":1784534400000,"parameters":[{"id":0,"name":"getMaintenanceTimeInterval","value":10800000,"unit":"ms"},{"id":13,"name":"getMaxCpuTimeOfOneTx","value":80,"unit":"ms"}]}]},"meta":{"durationMs":31,"warnings":[],"pagination":{"offset":0,"limit":null,"total":4}},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `approvalThreshold` | number | Approvals needed to pass = 70 % of the active SRs | +| `proposals[].id` | number | Proposal id | +| `proposals[].proposerAddress` | string | Creator, base58 | +| `proposals[].state` | string | `voting` / `approved` / `disapproved` / `canceled` | +| `proposals[].approvals` | number | Approvals cast so far | +| `proposals[].expirationTime` | number | End of the voting window, ms since epoch | +| `proposals[].parameters[]` | array | `id`, `name`, `value` (what the proposal sets), `unit`; ordered by `id` | +| `meta.pagination` | object | `offset`, `limit` (`null` = unlimited), `total` after `--state` filtering | + +## Exit status + +`0` success · `1` execution failure (`rpc_error`) · `2` usage error (`invalid_value` — bad state, limit, or offset). + +## See also + +[`proposal show`](show.md) · [`proposal create`](create.md) · [`chain params`](../chain/params.md) diff --git a/ts/docs/commands/proposal/show.md b/ts/docs/commands/proposal/show.md new file mode 100644 index 000000000..bce992099 --- /dev/null +++ b/ts/docs/commands/proposal/show.md @@ -0,0 +1,112 @@ +# wallet-cli proposal show + +Show one proposal, the parameters it sets, and its approval progress. + +## Synopsis + +``` +wallet-cli proposal show [options] +``` + +## Description + +Reports a single proposal: every parameter it sets as `name value` with its unit, the approval count against the threshold, and the creation and expiry times. Read-only, no account needed. + +**The value shown is the one the proposal would set, not the value in effect now.** The chain does not record what a parameter was when the proposal was created. For a settled proposal the current value is unrelated to that baseline, and for an approved one it *is* the value the proposal installed. Use [`chain params`](../chain/params.md) for the values in effect now. + +Text shows the approval count only. The addresses behind it are in the json as `approvedBy[]`, at full length. + +`State` is the chain's own value, so a proposal deleted by its creator reads `canceled` here. + +## Options + +| Option | Description | +|---|---| +| `` | **Required.** Proposal id | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +A proposal inside its voting window: + +```bash +wallet-cli proposal show 47 --network tron:nile +``` + +```console +Proposal #47 + State voting + Proposer TSRmq8kP...9dEf + Created time 2026-07-21 08:00 UTC + Expiry time 2026-07-22 08:00 UTC + Approvals 12 / 18 + Parameters (1) + getTransactionFee 15 sun/byte +``` + +One that reached the threshold at expiry — the value is live from that tally on: + +```bash +wallet-cli proposal show 45 --network tron:nile +``` + +```console +Proposal #45 + State approved + Proposer TSRwd3nL...8vC + Created time 2026-07-20 08:00 UTC + Expiry time 2026-07-21 08:00 UTC + Approvals 18 / 18 + Parameters (1) + getEnergyFee 140 sun +``` + +One that expired below the threshold, carrying two parameters: + +```bash +wallet-cli proposal show 44 --network tron:nile +``` + +```console +Proposal #44 + State disapproved + Proposer TSRee5...2xB + Created time 2026-07-19 08:00 UTC + Expiry time 2026-07-20 08:00 UTC + Approvals 8 / 18 + Parameters (2) + getMaintenanceTimeInterval 10800000 ms + getMaxCpuTimeOfOneTx 80 ms +``` + +```bash +wallet-cli proposal show 47 --network tron:nile -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"proposal.show","data":{"id":47,"proposerAddress":"TSRmq8kP...","state":"voting","createTime":1784620800000,"expirationTime":1784707200000,"approvals":12,"approvalThreshold":18,"reachedThreshold":false,"parameters":[{"id":3,"name":"getTransactionFee","value":15,"unit":"sun/byte"}],"approvedBy":["TSRaa1...","TSRbb2..."]},"meta":{"durationMs":22,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +| Field | Type | Meaning | +|---|---|---| +| `id` | number | Proposal id | +| `proposerAddress` | string | Creator, base58 | +| `state` | string | `voting` / `approved` / `disapproved` / `canceled` | +| `createTime` / `expirationTime` | number | Creation and end of the voting window, ms since epoch | +| `approvals` / `approvalThreshold` | number | Approvals cast, and the count needed to pass | +| `reachedThreshold` | boolean | Whether `approvals` already meets `approvalThreshold` | +| `parameters[]` | array | `id`, `name`, `value` (what the proposal sets), `unit`; ordered by `id` | +| `approvedBy[]` | array | Addresses that have approved, base58 (json only) | + +There is no cancellation timestamp: the chain's proposal record holds only the fields above, so `canceled` carries no time of its own. + +## Exit status + +`0` success · `1` execution failure (`proposal_not_found` — no such proposal, `rpc_error`) · `2` usage error (`invalid_value` — id not a number). + +## See also + +[`proposal list`](list.md) · [`proposal approve`](approve.md) · [`chain params`](../chain/params.md) diff --git a/ts/docs/commands/reward/withdraw.md b/ts/docs/commands/reward/withdraw.md index 4d5134ff2..cbc940746 100644 --- a/ts/docs/commands/reward/withdraw.md +++ b/ts/docs/commands/reward/withdraw.md @@ -25,7 +25,7 @@ Moves your accumulated voting rewards (plus block rewards if you are an SR) into | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/cancel-unfreeze.md b/ts/docs/commands/stake/cancel-unfreeze.md index 60d990eeb..475ef2b72 100644 --- a/ts/docs/commands/stake/cancel-unfreeze.md +++ b/ts/docs/commands/stake/cancel-unfreeze.md @@ -23,7 +23,7 @@ Cancels **every** unstake still in its waiting period and rolls those amounts ba | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/delegate.md b/ts/docs/commands/stake/delegate.md index 8d77a8773..33d3b59c5 100644 --- a/ts/docs/commands/stake/delegate.md +++ b/ts/docs/commands/stake/delegate.md @@ -33,7 +33,7 @@ Check how much you can still delegate with [`stake delegated`](delegated.md) (`M | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/freeze.md b/ts/docs/commands/stake/freeze.md index 4071420ef..1118ba65e 100644 --- a/ts/docs/commands/stake/freeze.md +++ b/ts/docs/commands/stake/freeze.md @@ -27,7 +27,7 @@ Amount is in SUN (1 TRX = 1,000,000 SUN). Staked TRX stays yours; to get it back | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/undelegate.md b/ts/docs/commands/stake/undelegate.md index aab2f0dbc..5e313addb 100644 --- a/ts/docs/commands/stake/undelegate.md +++ b/ts/docs/commands/stake/undelegate.md @@ -29,7 +29,7 @@ Reclaiming is immediate (no waiting period — the TRX was staked all along, onl | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/unfreeze.md b/ts/docs/commands/stake/unfreeze.md index f24b150fd..43ad8cfc5 100644 --- a/ts/docs/commands/stake/unfreeze.md +++ b/ts/docs/commands/stake/unfreeze.md @@ -27,7 +27,7 @@ Stake 2.0 allows at most **32 pending unstakes** per account at a time; check re | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/stake/withdraw.md b/ts/docs/commands/stake/withdraw.md index bff901a40..3cff26c4a 100644 --- a/ts/docs/commands/stake/withdraw.md +++ b/ts/docs/commands/stake/withdraw.md @@ -25,7 +25,7 @@ Withdrawing also frees up unstake slots (max 32 pending unstakes per account). | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/tx/multisig.md b/ts/docs/commands/tx/multisig.md index 18db4c9cd..e897f135d 100644 --- a/ts/docs/commands/tx/multisig.md +++ b/ts/docs/commands/tx/multisig.md @@ -174,7 +174,7 @@ A record the client cannot reconcile with the chain stays visible and is labelle ## Exit status -`0` success · `1` execution failure (`tronlink_credentials_missing`, `not_found` — txId not on the service, `not_authorized`, `already_signed`, `tx_expired`, `wrong_password`, `provider_error` — service error / rate limit) · `2` usage error (`invalid_value` — including an already-signed transaction passed to `--create`, conflicting modes). +`0` success · `1` execution failure (`tronlink_credentials_missing`, `not_found` — txId not on the service, `not_authorized`, `already_signed`, `tx_expired`, `auth_failed`, `provider_error` — service error / rate limit) · `2` usage error (`invalid_value` — including an already-signed transaction passed to `--create`, conflicting modes). ## See also diff --git a/ts/docs/commands/tx/send.md b/ts/docs/commands/tx/send.md index 7087e1697..afe4037e4 100644 --- a/ts/docs/commands/tx/send.md +++ b/ts/docs/commands/tx/send.md @@ -22,6 +22,14 @@ Builds, signs, and submits a transfer from the active account (or `--account`). Amounts: `--amount` is human units (TRX, or token units respecting the token's decimals); `--raw-amount` is the raw integer (SUN or token base units). Exactly one of the two. +Where the decimals come from: TRX is fixed at 6, but a token's are read from the node — from the +contract for TRC20, from the asset record for TRC10. `--amount` is therefore scaled by a number the +node supplies, and a node that misreports it moves the decimal point on the amount you sign. The +value is checked against the protocol range (a TRC10 precision is 0..6, and a record answering for +a different id is refused outright), but a wrong value *inside* that range cannot be detected +locally — there is nothing to compare it against. When the exact base-unit quantity matters, pass +`--raw-amount`, which is used verbatim and never rescaled. + Early exits: `--dry-run` builds and estimates only — no signature, no broadcast, nothing leaves your machine; `--sign-only` signs and prints the signed transaction **hex**; `--build-only` builds but does **not** sign, printing the **unsigned** hex. For multi-sig, `--permission-id` selects the signing group and `--expiration` extends how long the transaction stays valid for co-signers to add their signatures. **By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed, or poll [`tx status`](status.md). @@ -42,8 +50,8 @@ Requires an account and the master password via `--password-stdin` — signing c | `--dry-run` | Build and estimate only; excludes `--sign-only` / `--build-only` | | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | -| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only` | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default 60000; on cap returns the submitted receipt) | | `--password-stdin` | Master password from stdin | diff --git a/ts/docs/commands/tx/sign.md b/ts/docs/commands/tx/sign.md index 3718554be..6179bdf2e 100644 --- a/ts/docs/commands/tx/sign.md +++ b/ts/docs/commands/tx/sign.md @@ -143,7 +143,7 @@ No `fee` is reported for `--transaction`: nothing was estimated, because the tra ## Exit status -`0` success · `1` execution failure (`tx_integrity` — the three payload representations disagree, `invalid_transaction`, `tx_expired`, `not_authorized` — this account isn't in the group's key list, `already_signed`, `watch_only_no_signer`, `wrong_password`, `signing_rejected`, `rpc_error`) · `2` usage error (`invalid_value`, `missing_option`). +`0` success · `1` execution failure (`tx_integrity` — the three payload representations disagree, `invalid_transaction`, `tx_expired`, `not_authorized` — this account isn't in the group's key list, `already_signed`, `watch_only_no_signer`, `auth_failed`, `signing_rejected`, `rpc_error`) · `2` usage error (`invalid_value`, `missing_option`). ## See also diff --git a/ts/docs/commands/vote/cast.md b/ts/docs/commands/vote/cast.md index 05abfbece..5c33138f9 100644 --- a/ts/docs/commands/vote/cast.md +++ b/ts/docs/commands/vote/cast.md @@ -31,7 +31,7 @@ Votes take effect at the next maintenance cycle (~6 h). Each vote uses 1 TP (it | `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | | `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | | `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | -| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2–9=active); default `0` | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | | `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | | `--password-stdin` | Master password from stdin (fd 0) | diff --git a/ts/docs/commands/vote/list.md b/ts/docs/commands/vote/list.md index 6340fd625..37254d550 100644 --- a/ts/docs/commands/vote/list.md +++ b/ts/docs/commands/vote/list.md @@ -34,10 +34,11 @@ wallet-cli vote list --limit 3 --network tron:nile ``` ```console -Rank Name Votes APR Reward ratio Address - 1 TRONSCAN 1,203,456,789 4.8% 80% TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g - 2 Binance Staking 998,765,432 0% 0% TT5W8MPbYJih9R586kTszb4LoybzUvCYm2 - 3 JustLend 876,543,210 4.9% 80% TWxkzUeAiKcFvzXvJEcaTQCQqCuMednAtN +| Rank | Name | Votes | APR | Reward ratio | Address | +| ---- | --------------- | ------------- | ---- | ------------ | ---------------------------------- | +| 1 | TRONSCAN | 1,203,456,789 | 4.8% | 80% | TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g | +| 2 | Binance Staking | 998,765,432 | 0% | 0% | TT5W8MPbYJih9R586kTszb4LoybzUvCYm2 | +| 3 | JustLend | 876,543,210 | 4.9% | 80% | TWxkzUeAiKcFvzXvJEcaTQCQqCuMednAtN | ``` ```bash diff --git a/ts/docs/commands/vote/status.md b/ts/docs/commands/vote/status.md index f283619b8..b1e0b1350 100644 --- a/ts/docs/commands/vote/status.md +++ b/ts/docs/commands/vote/status.md @@ -33,10 +33,11 @@ Voting power 1,500 TP (used 1,000 / available 500) Claimable 12.345678 TRX Current votes (2) - Name Votes APR Reward ratio Address - TRONSCAN 600 4.8% 80% TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g - Binance Staking 400 0% 0% TT5W8MPbYJih9R586kTszb4LoybzUvCYm2 -! 400 votes are on an SR with 0% reward ratio — they earn you nothing +| Name | Votes | APR | Reward ratio | Address | +| --------------- | ----- | ---- | ------------ | ---------------------------------- | +| TRONSCAN | 600 | 4.8% | 80% | TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g | +| Binance Staking | 400 | 0% | 0% | TT5W8MPbYJih9R586kTszb4LoybzUvCYm2 | +! 400 votes on Binance Staking earn nothing — 0% reward ratio ``` ```bash diff --git a/ts/docs/commands/witness/create.md b/ts/docs/commands/witness/create.md new file mode 100644 index 000000000..b9ddb39fa --- /dev/null +++ b/ts/docs/commands/witness/create.md @@ -0,0 +1,81 @@ +# wallet-cli witness create + +Register the account as a super representative candidate. + +## Synopsis + +``` +wallet-cli witness create --url + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Registers the acting account as an SR candidate, making it votable and eligible to produce blocks once its votes reach the top 27. It also makes the account a witness for governance purposes — [`proposal create`](../proposal/create.md) and [`proposal approve`](../proposal/approve.md) require it. + +**Registration burns a fee — currently about 9,999 TRX — and it is not refundable.** The exact amount is the chain parameter `getAccountUpgradeCost` ([`chain params`](../chain/params.md)), so read it there rather than assuming; the receipt's `Fee` line reports what was actually burned. There is no way to unregister. + +The account must already be activated and hold at least the registration fee. `--url` is the candidate info page — the website explorers show next to the SR — and is the only business field the chain stores for a candidate; change it later with [`witness update`](update.md). + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `--url ` | **Required.** Candidate info page | +| `--dry-run` | Build and estimate only, no signature/broadcast; reports the registration fee; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli witness create --url https://sr.acme.io --network tron:nile --wait --password-stdin +``` + +```console +✅ Witness registered + Witness TSRmq8kP...9dEf (main) + Url https://sr.acme.io + TxID d3a... + Block 57,881,020 + Fee 9,999 TRX (285 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli witness create --url https://sr.acme.io --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"witness.create","data":{"kind":"witness-create","stage":"confirmed","txId":"d3a...","confirmed":true,"blockNumber":57881020,"failed":false,"witnessAddress":"TSRmq8kP...","url":"https://sr.acme.io","feeSun":9999000000,"resource":{"netUsage":285,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0},"registrationFeeSun":9999000000},"meta":{"durationMs":6620,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "witness-create"`, `stage: "submitted"`, `txId`, `witnessAddress`, `url` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed`, `registrationFeeSun` | + +`registrationFeeSun` is the burned registration fee on its own; `feeSun` is the transaction's total cost, which includes it. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`already_witness`, `account_not_active`, `insufficient_balance` — below the registration fee, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--url`). + +## See also + +[`witness update`](update.md) · [`witness set-brokerage`](set-brokerage.md) · [`proposal create`](../proposal/create.md) · [`chain params`](../chain/params.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/witness/index.md b/ts/docs/commands/witness/index.md new file mode 100644 index 000000000..60e921f4c --- /dev/null +++ b/ts/docs/commands/witness/index.md @@ -0,0 +1,31 @@ +# wallet-cli witness + +Register and operate a super representative (SR) candidacy. + +Registering turns an ordinary account into an **SR candidate** — it can be voted for ([`vote cast`](../vote/cast.md)), and if its votes put it in the top 27 it produces blocks. Candidacy is also what unlocks governance: only a registered witness can create or approve [proposals](../proposal/index.md). + +The chain stores very little about a candidate: the owner address and a single **url** — the info page shown next to the SR in explorers — which is the only field this group can change. Everything else about an SR (rank, votes, block production) is a consequence of votes, not a setting. + +The one economic knob is **brokerage**: the share of block rewards the SR keeps, with the remainder distributed to its voters. It defaults to 20 %. + +Registration burns a fee (currently ≈ 9,999 TRX) and cannot be undone. + +## Synopsis + +``` +wallet-cli witness COMMAND +``` + +## Subcommands + +| Command | Page | Description | +|---|---|---| +| `witness create` | [create.md](create.md) | Register the account as an SR candidate | +| `witness update` | [update.md](update.md) | Change the candidate info page URL | +| `witness set-brokerage` | [set-brokerage.md](set-brokerage.md) | Set the share of block rewards the SR keeps | + +Candidates, their votes, and their brokerage are read with [`vote list`](../vote/list.md) — which shows the 27 elected SRs by default, so a candidate outside that set needs `vote list --candidates`. + +## See also + +[`proposal`](../proposal/index.md) · [`vote list`](../vote/list.md) · [`reward balance`](../reward/balance.md) diff --git a/ts/docs/commands/witness/set-brokerage.md b/ts/docs/commands/witness/set-brokerage.md new file mode 100644 index 000000000..2b0c05dbe --- /dev/null +++ b/ts/docs/commands/witness/set-brokerage.md @@ -0,0 +1,83 @@ +# wallet-cli witness set-brokerage + +Set the share of block rewards the SR keeps. + +## Synopsis + +``` +wallet-cli witness set-brokerage + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +`` is the **brokerage** — the percentage of block rewards the SR keeps for itself; the remaining `100 − percent` is distributed to its voters in proportion to their votes. It defaults to 20, and is validated locally as an integer 0–100 before anything is broadcast. + +This is the same number [`vote list`](../vote/list.md) reports as `brokeragePct`; that page's `Reward ratio` column is its complement — the voters' share. Setting `100` means voters earn nothing from your blocks. + +Any registered witness can set it, elected or not. The acting account must be a candidate; otherwise the command fails with `not_a_witness`. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `` | **Required.** Share the SR keeps, integer 0–100 | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +Keep 20 %, pass 80 % to voters: + +```bash +echo "$PW" | wallet-cli witness set-brokerage 20 --network tron:nile --wait --password-stdin +``` + +```console +✅ Brokerage set + Witness TSRmq8kP...9dEf (main) + Brokerage 20% + TxID f8c... + Block 57,881,402 + Fee 0 TRX (269 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli witness set-brokerage 20 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"witness.set-brokerage","data":{"kind":"witness-set-brokerage","stage":"confirmed","txId":"f8c...","confirmed":true,"blockNumber":57881402,"failed":false,"witnessAddress":"TSRmq8kP...","brokerage":20,"feeSun":0,"resource":{"netUsage":269,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6470,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "witness-set-brokerage"`, `stage: "submitted"`, `txId`, `witnessAddress`, `brokerage` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +`brokerage` is the value now in effect, as a number. + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`invalid_value` — percent missing, not an integer, or outside 0–100). + +## See also + +[`witness create`](create.md) · [`vote list`](../vote/list.md) · [`reward balance`](../reward/balance.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/commands/witness/update.md b/ts/docs/commands/witness/update.md new file mode 100644 index 000000000..688856ec6 --- /dev/null +++ b/ts/docs/commands/witness/update.md @@ -0,0 +1,77 @@ +# wallet-cli witness update + +Change the candidate info page URL. + +## Synopsis + +``` +wallet-cli witness update --url + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] + [--permission-id ] [options] +``` + +## Description + +Replaces the info page URL of an existing SR candidacy. The url is the only field the chain keeps for a candidate, so this is the whole of "editing" an SR. It can be changed as often as needed and costs only bandwidth. + +The acting account must already be a candidate; otherwise the command fails with `not_a_witness`. + +**By default the command returns at submission** (`stage: "submitted"`), not confirmation — add `--wait` to block until confirmed/failed. Requires an account. The master password (via `--password-stdin`) is needed only by the modes that sign — `--dry-run` and `--build-only` do not unlock the wallet and run without it. Watch-only accounts fail with `watch_only_no_signer` in a signing mode. + +## Options + +| Option | Description | +|---|---| +| `--url ` | **Required.** New candidate info page | +| `--dry-run` | Build and estimate only, no signature/broadcast; excludes `--sign-only` / `--build-only` | +| `--sign-only` | Sign without broadcasting, output the signed hex; excludes `--dry-run` / `--build-only`; pairs with `--expiration` | +| `--build-only` | Build only, output the **unsigned** hex; excludes `--dry-run` / `--sign-only`; pairs with `--expiration` | +| `--expiration ` | Transaction expiration in ms, up to `86400000` (24h); only with `--sign-only` or `--build-only`; omitted = node default (~60s) | +| `--permission-id ` | Permission group to sign with (0=owner, 1=witness, 2-9=active); default `0` | +| `--wait` / `--wait-timeout ` | Poll after broadcast until confirmed/failed (cap default: config `waitTimeoutMs`, built-in 60000) | +| `--password-stdin` | Master password from stdin (fd 0) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. + +```bash +echo "$PW" | wallet-cli witness update --url https://sr.acme.io/v2 --network tron:nile --wait --password-stdin +``` + +```console +✅ Witness updated + Witness TSRmq8kP...9dEf (main) + Url https://sr.acme.io/v2 + TxID e5b... + Block 57,881,190 + Fee 0 TRX (270 bandwidth) + Status success +``` + +```bash +echo "$PW" | wallet-cli witness update --url https://sr.acme.io/v2 --network tron:nile --wait --password-stdin -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"witness.update","data":{"kind":"witness-update","stage":"confirmed","txId":"e5b...","confirmed":true,"blockNumber":57881190,"failed":false,"witnessAddress":"TSRmq8kP...","url":"https://sr.acme.io/v2","feeSun":0,"resource":{"netUsage":270,"netFeeSun":0,"energyUsage":0,"energyFeeSun":0}},"meta":{"durationMs":6440,"warnings":[]},"chain":{"family":"tron","network":"tron:nile","chainId":"nile"}} +``` + +## Output + +`data` varies by stage: + +| Stage | Fields | +|---|---| +| default (submit) | `kind: "witness-update"`, `stage: "submitted"`, `txId`, `witnessAddress`, `url` | +| `--wait` (confirmed) | above, plus `stage: "confirmed"`, `confirmed` (boolean), `blockNumber`, `feeSun`, `resource`, `failed` | + +## Exit status + +`0` submitted (or built/signed in early-exit modes) · `1` execution failure (`not_a_witness`, `watch_only_no_signer`, `auth_failed`) · `2` usage error (`missing_option` — no `--url`). + +## See also + +[`witness create`](create.md) · [`witness set-brokerage`](set-brokerage.md) · [`vote list`](../vote/list.md) · [Script safety](../../machine-interface.md#script-safety-never-mistake-submitted-for-confirmed) diff --git a/ts/docs/concepts/accounts-and-hd.md b/ts/docs/concepts/accounts-and-hd.md index a27bb4a60..186d9548b 100644 --- a/ts/docs/concepts/accounts-and-hd.md +++ b/ts/docs/concepts/accounts-and-hd.md @@ -34,7 +34,7 @@ Labels are unique, 1–64 chars, renameable (`rename`) — the stable handle is ## Lifecycle -- `backup ` exports secret + metadata to a file created with mode **0600** and never overwritten (default under `/backups/`). Treat the file as the secret it contains. +- `backup ` exports secret + metadata to a file created with mode **0600** and never overwritten (in the current working directory by default). Treat the file as the secret it contains — and mind where you run it, since the CLI does not check whether that directory is shared or version-controlled. - `delete` removes accounts; **deleting an HD wallet cascades from the seed root** — all derived accounts of that seed go with it. The on-chain assets are untouched: re-import the mnemonic to regain access. - Losing the master password is unrecoverable locally; the escape hatch is always the mnemonic → `import mnemonic`. diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index c7cc8e36d..2d9058cf4 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -56,13 +56,14 @@ Schema id: `wallet-cli.result.v1`. | ----------------- | ------------------------ | ------------------- | -------------------------------------------------------------------------------- | | `schema` | `"wallet-cli.result.v1"` | always | Version gate; dispatch on this | | `success` | boolean | always | Mirrors the exit code (`true` ⇔ 0) | -| `command` | string | always | Canonical command id, e.g. `tx.send`, `list` | +| `command` | string | always | Canonical command id, e.g. `tx.send`, `list`. It names the **operation**, not the words typed: `backup --records` reports `backup.records`, `import keystore` reports `import.keystore` | | `data` | object/array | success only | Command-specific payload; see each command's reference page | | `error.code` | string | error only | Machine-readable; see [error codes](#error-codes) | | `error.message` | string | error only | Human-readable; **not** stable — never parse it | | `error.details` | object | optional | Structured extras when available | | `meta.durationMs` | number | always | Wall time | | `meta.warnings` | `(string \| {code, message})[]` | always | Non-fatal notices; **elements are not uniformly typed** — see below | +| `meta.pagination` | object | paginated commands only | `offset` / `limit` / `total`; present where `--limit` / `--offset` apply — see [pagination](#pagination) | | `chain` | object | chain commands only | `family` / `network` / `chainId`; neutral commands (`list`, `config`, …) omit it | Encoding rules: `bigint` values are serialized as decimal **strings** (e.g. `"balance": "1976489000"`), binary as hex. Treat every on-chain amount as a string. @@ -81,6 +82,35 @@ jq -e '.meta.warnings[] | select(type == "object" and .code == "owner_lockout")' Helpers that assume strings (`.meta.warnings | join("\n")`, `Array.prototype.join`) fail or print `[object Object]` on the object form. Warning `code` values are stable and additive within v1 — new codes may appear, existing ones keep their meaning. Warning `message` text is **not** stable; treat it like `error.message` and never parse it. +### Pagination + +Every command that takes `--limit` / `--offset` reports the window it returned in `meta.pagination`, never inside `data`: + +| Key | Type | Meaning | +|---|---|---| +| `offset` | number | Index the page started at — echoes `--offset` | +| `limit` | number \| **null** | Page size; `null` = unlimited (no `--limit` given) | +| `total` | number \| **null** | Matching records in total; `null` means **no count exists**, not "it was omitted" | + +All three keys are always present, so `null` is the only "unknown" signal and absent never has to be told apart from null. + +`total` is `null` permanently for the commands served by TRON's paginated node endpoints — [`asset list`](commands/asset/list.md) and [`exchange list`](commands/exchange/list.md). The endpoint returns no count, and computing one would mean transferring every record (5,187 assets, 2.7 MB on mainnet). Page until a short page comes back rather than comparing against a total: + +```bash +offset=0 +while :; do + page=$(wallet-cli asset list --limit 50 --offset "$offset" -o json) + n=$(jq '.data.assets | length' <<<"$page") + jq -c '.data.assets[]' <<<"$page" + [ "$n" -lt 50 ] && break + offset=$((offset + 50)) +done +``` + +Commands that page a local, bounded set ([`backup --records`](commands/backup.md)) or that fetch everything and window it client-side ([`proposal list`](commands/proposal/list.md)) do report a `total`. + +Text mode titles the same window (`Assets (limit 50, offset 0)`, `Proposals (showing 2 of 4)`, `Backup records (showing 3 of 12)`), but text is not part of this contract — parse `-o json`. + ## Error codes The **exit code is the hard contract**: `2` means the call was malformed (it will still be wrong on retry), `1` means execution failed (network / device / chain / wallet). `error.code` is a machine-readable string that refines the exit code — branch on the exit code first, then optionally on `error.code`. The code set is **open and non-exhaustive**: it grows as commands are added, and a few strings (e.g. `invalid_value`, `aborted`) can appear under either exit code depending on where they are raised. Always tolerate an unknown code by falling back to its exit-code class. @@ -100,15 +130,21 @@ Common codes at exit **2** (usage — fix the call): | `missing_network` / `unsupported_network` | `--network` absent, or not a known canonical id | | `unknown_command` | No such command | | `output_exists` | Target file already exists and is never overwritten (`backup --out`, `address generate --out`). Deterministic — retrying the same path always fails | +| `file_not_found` | An input file named by a flag does not exist (`contract create2 --code-file`) | +| `keystore_not_found` | `import keystore`: no file at the given path | +| `invalid_keystore` | `import keystore`: not a valid Web3 V3 keystore — bad JSON, `version` ≠ 3, an unsupported cipher/KDF, or a payload that is not a 32-byte private key | | `invalid_config` | `config.yaml` cannot be read or is not valid YAML — fix or remove the file. The parser detail is withheld: it quotes the offending line, which may carry a credential | | `insecure_config` | `config.yaml` holds service credentials but is a symlink or is group/world-readable — run `chmod 600` on it (POSIX only; not enforced on Windows) | | `token_not_in_book` / `token_is_official` / `token_metadata_unavailable` | Token address-book conditions | +| `unknown_parameter` | No chain parameter by that name or id (`proposal create --set`) | +| `invalid_asset_name` | A TRC10 name or abbreviation outside 1–32 visible ASCII characters | Common codes at exit **1** (execution — runtime failure): | Code | Meaning | |---|---| | `rpc_error` | The TRON node rejected or failed the request | +| `invalid_node_response` | The node's answer contradicts the request or the protocol: a TRC10/exchange record whose id is not the one asked for, a `precision` outside 0..6, or a rate pair that is not a positive int32. These decide signed amounts, so the command stops rather than acting on them. List reads drop the offending record and keep the page | | `timeout` | Aborted waiting for network or device (`--timeout` exceeded) | | `auth_required` | Master password required but not supplied | | `auth_failed` | Wrong master password (decryption failed) | @@ -121,10 +157,38 @@ Common codes at exit **1** (execution — runtime failure): | `gasfree_credentials_missing` / `tronlink_credentials_missing` | Required service credentials are not configured (set them with `config`) | | `tx_expired` | The transaction's expiration passed before signatures were collected | | `history_not_supported` | The endpoint lacks TronGrid history support | +| `not_found` | The addressed thing does not exist — an unactivated account, a contact, a chain parameter, a GasFree or TronLink resource. Lookups that have a group of their own use the specific code below | +| `proposal_not_found` / `contract_not_found` / `asset_not_found` / `exchange_not_found` | Nothing on chain under that proposal id, contract address, TRC10 reference, or exchange pair id | +| `ambiguous_asset_name` | A TRC10 name matches more than one token; `error.details` carries the candidates — see [`error.details.matches`](#errordetailsmatches) | +| `ledger_unsupported` | The Ledger TRON app cannot sign this contract type — refused before the device is touched (`asset` writes, `witness` writes) | +| `not_a_witness` / `already_witness` / `not_proposal_owner` | Governance identity does not meet the operation's rule | +| `already_approved` / `not_approved` / `proposal_expired` / `already_canceled` | Proposal voting conditions | +| `account_not_active` / `chain_parameter_unavailable` | `witness create`: the account is not activated on chain, or the node did not return `getAccountUpgradeCost` | +| `not_contract_deployer` | The account did not deploy that contract | +| `already_issued_asset` / `not_an_issuer` | The account has already issued a TRC10, or has never issued one | +| `not_in_ico_window` / `self_participation` | TRC10 ICO participation conditions | +| `no_frozen_supply` / `not_yet_unfreezable` | Nothing frozen, or nothing matured yet (`asset unfreeze`) | +| `not_exchange_creator` / `token_not_in_exchange` / `exchange_closed` / `same_token` | Exchange-pair access and state conditions | +| `insufficient_reserve` | `exchange withdraw`: more than that side of the pair holds | +| `precision_loss` / `slippage_exceeded` / `exchange_trading_disabled` | Node rejections named from a narrow allowlist — an amount the reserve ratio cannot convert cleanly, a return below the floor, or a network that is not accepting Bancor trades at all | +| `not_exportable` | The account holds no exportable secret (watch-only or Ledger) — `backup` | +| `account_exists` / `wrong_keystore_password` | `import keystore`: the address is already in the wallet, or the file's own password is wrong (distinct from `auth_failed`, which is the master password). A file whose `mac` is missing or not hex is `invalid_keystore`, not a wrong password — hex case is not significant | | `internal_error` | Unexpected internal failure; message is intentionally generic | Unexpected exceptions are **redacted** to `internal_error` with a generic message, so a library error that happens to echo secret material can never reach the envelope. This list is representative, not exhaustive — new codes may be added within v1. +### `error.details.matches` + +Some failures are a **choice**, not a dead end: the call was well formed but names something that resolves to several candidates, and the caller has to pick one. Those errors put the candidates in `error.details.matches` — an array of flat objects sharing one key set: + +```json +{"code":"ambiguous_asset_name","message":"2 TRC10 tokens are named MyToken; re-run with the id","details":{"name":"MyToken","assetIds":["1000123","1000488"],"matches":[{"assetId":"1000123","issuerAddress":"TQkXm4vN...","totalSupply":"1000000000000000","precision":6},{"assetId":"1000488","issuerAddress":"TZx9kP2m...","totalSupply":"5000000000","precision":2}]}} +``` + +`matches` is the convention, not a per-code special case: **any** error may carry it, and any that does gets the same treatment. In text mode the candidates are printed as a table under the `error [...]` line, on stderr. Quantities inside `matches` stay raw (minimal units), matching how the corresponding success payload reports them; the text table scales them for display when the row carries a `precision`. + +Alongside it, an error may carry a scalar list of just the identifiers to retry with — `assetIds` above. Prefer that for scripting; `matches` exists so a human can tell the candidates apart. + ## Secret handling Secrets never travel via argv or environment variables — they would leak into shell history and process listings. Two channels only: @@ -143,12 +207,14 @@ printf '%s' "$MASTER_PASSWORD_FROM_YOUR_VAULT" | wallet-cli tx send \ This is a wallet; a wrong success check loses money. The rules: -1. Broadcast (✍️) commands **by default return after submission**, not confirmation. The payload is a flat object with a `kind` naming the operation (`send`, `stake-freeze`, `permission-update`, `account-activate`, …), a `stage`, and the `txId`; the `submitted` stage carries no block / fee / result (those appear only after `--wait` confirms): +1. Broadcast (✍️) commands **by default return after submission**, not confirmation. The payload is a flat object with a `kind` naming the operation (`send`, `stake-freeze`, `permission-update`, `account-activate`, `proposal-create`, `asset-issue`, `exchange-trade`, …), a `stage`, and the `txId`; the `submitted` stage carries no block / fee / result (those appear only after `--wait` confirms): ```json { "kind": "send", "stage": "submitted", "txId": "7d9b6a08…", "rawAmount": "1000000", "to": "TSx72…" } ``` + **Ids the chain assigns arrive only with confirmation.** A new proposal's `proposalId`, a TRC10's `assetId`, an exchange pair's `exchangeId` do not exist at submission — they are absent from the submitted receipt and appear once `--wait` (or a later query) sees the transaction on chain. Scripts that create one of these must wait for it. + 2. To block until the outcome is known, pass `--wait` (polls until confirmed/failed, capped by `--wait-timeout`, default 60000 ms; on cap it returns the submitted receipt). **A `--wait` receipt reports the transaction outcome in `data.stage`, never in `success`.** A transaction that was accepted, mined, and then reverted is a *successful command* carrying a *failed transaction*: the envelope stays `success: true` and the exit code stays `0`, while `data.stage` is `"failed"`. Exit codes say whether the CLI could carry out the request, not whether the chain accepted the result — so after any `--wait`, branch on `data.stage` (`confirmed` / `failed` / `submitted`) before recording the operation as done. 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 a7ff1adfa..73087b803 100644 --- a/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md +++ b/ts/docs/typescript-wallet-cli-architecture-source-of-truth.md @@ -343,7 +343,9 @@ wallet-cli ├── account balance | info | history | portfolio ├── token balance | info | add | list | remove ├── tx send | broadcast | status | info -├── contract call | send | deploy | info +├── contract call | send | deploy | info | clear-abi | set-origin-energy-limit | set-user-resource-percent | create2 +├── proposal list | show | create | approve | delete +├── witness create | update | set-brokerage ├── stake freeze | unfreeze | withdraw | cancel-unfreeze | delegate | undelegate | info | delegated ├── vote cast | list | status ├── reward balance | withdraw @@ -358,6 +360,7 @@ Neutral commands do not touch a chain. Chain commands are currently all provided - `--dry-run`: build + estimate, no decrypt, no sign, no broadcast. - `--sign-only`: build + estimate + sign, returns a signed transaction. +- Governance writes also support `--build-only` (no signer resolution), `--permission-id`, and an optional `--expiration` extension in build/sign-only modes. - No mode flag: sign + broadcast. - `--wait`: wait for confirmation only after broadcast. @@ -443,7 +446,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, plus stake/delegation/vote/reward and chain (params/prices/node) queries | `TronRpcClient` | +| `TronGateway` | TRON reads/build/estimate/broadcast, plus stake/delegation/vote/reward, proposal/witness, contract-governance, and chain queries | `TronRpcClient` | | `TronHistoryReader` | TronGrid transaction history | `TronGridHistoryReader` | | `TokenRepository` | official/user token book | `TokenBook` | | `PriceProvider` | best-effort USD price | CoinGecko/Null provider | @@ -456,7 +459,7 @@ Application defines capabilities, not concrete technologies: - `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, 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. +- TRON use cases: account, token, transaction, contract, proposal, witness, 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. `TronProposalService` and `TronWitnessService` perform witness/state/fee preflights before entering the shared transaction pipeline. `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. diff --git a/ts/eslint.config.js b/ts/eslint.config.js new file mode 100644 index 000000000..bb3e628f0 --- /dev/null +++ b/ts/eslint.config.js @@ -0,0 +1,43 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import prettier from "eslint-config-prettier"; + +export default tseslint.config( + { + ignores: ["dist/**", "node_modules/**", ".wallet-cli/**", ".private/**"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + // the CLI renders through the streams port, never straight to the console + "no-console": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, + ], + // several renderers and sanitizers match control bytes on purpose, to strip terminal + // escape-sequence injection out of chain-controlled text (see cli/render/scalars.ts) + "no-control-regex": "off", + // `any` sits at the adapter boundary, where TronWeb / Ledger / yargs ship no usable types. + // tsc is the type authority here; flagging every such cast is noise, not signal. + "@typescript-eslint/no-explicit-any": "off", + }, + }, + { + // golden-output assertions match the CLI's literal column spacing + files: ["**/*.test.ts", "test/**/*.ts"], + rules: { + "no-regex-spaces": "off", + }, + }, + { + files: ["**/*.cjs"], + languageOptions: { + sourceType: "commonjs", + globals: { module: "writable", require: "readonly", __dirname: "readonly" }, + }, + }, + // formatting is Prettier's job — must stay last so it can switch stylistic rules off + prettier, +); diff --git a/ts/package-lock.json b/ts/package-lock.json index 16a9c2092..8358f4d74 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "4.11.0", + "version": "4.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tron-walletcli/wallet-cli", - "version": "4.11.0", + "version": "4.12.0", "license": "LGPL-3.0-or-later", "dependencies": { "@ledgerhq/hw-app-trx": "^6.36.3", @@ -31,14 +31,19 @@ "wallet-cli": "dist/index.js" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^25.9.3", "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", "dependency-cruiser": "^17.4.3", + "eslint": "^10.8.1", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.9.6", "tsup": "^8.5.1", "tsx": "^4.22.4", "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0", "vitest": "^4.1.9" }, "engines": { @@ -539,6 +544,200 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1476,6 +1675,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1483,6 +1689,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", @@ -1536,6 +1749,226 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -1723,6 +2156,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1785,6 +2235,16 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1834,6 +2294,19 @@ "readable-stream": "^3.4.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -2033,6 +2506,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2083,6 +2571,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2264,44 +2759,293 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=18" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "engines": { + "node": ">=4.0" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=6" + "node": ">=4.0" } }, "node_modules/estree-walker": { @@ -2314,6 +3058,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ethereum-cryptography": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz", @@ -2493,6 +3247,27 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2511,6 +3286,19 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -2542,6 +3330,27 @@ "rollup": "^4.34.8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -2672,6 +3481,19 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -2805,6 +3627,16 @@ "node": ">= 4" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2847,6 +3679,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2856,6 +3698,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-installed-globally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", @@ -2886,6 +3741,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -2896,6 +3758,27 @@ "node": ">=10" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2909,6 +3792,16 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -2919,6 +3812,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3298,6 +4205,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -3369,6 +4292,13 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", @@ -3449,6 +4379,24 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -3494,6 +4442,16 @@ "node": ">=8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3658,6 +4616,32 @@ "node": ">=10" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -3691,6 +4675,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -4076,6 +5070,29 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4433,6 +5450,19 @@ "node": ">=10" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4561,6 +5591,19 @@ "node": "*" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -4575,6 +5618,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/ufo": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", @@ -4589,6 +5656,16 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/usb": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/usb/-/usb-2.9.0.tgz", @@ -4816,6 +5893,22 @@ "node": "^20.12||^22.13||>=24.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -4839,6 +5932,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -4945,6 +6048,19 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/ts/package.json b/ts/package.json index d92364000..5a5f59d25 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@tron-walletcli/wallet-cli", - "version": "4.11.0", + "version": "4.12.0", "description": "Agent-first TypeScript CLI wallet for TRON — deterministic commands, JSON output, and discoverable schemas", "type": "module", "bin": { @@ -44,6 +44,10 @@ "dev": "tsx src/index.ts", "typecheck": "tsc --noEmit", "depcruise": "depcruise src", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "test": "vitest run", "test:watch": "vitest", "prepublishOnly": "npm run build" @@ -74,14 +78,19 @@ "esbuild": "^0.28.1" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^25.9.3", "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", "dependency-cruiser": "^17.4.3", + "eslint": "^10.8.1", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.9.6", "tsup": "^8.5.1", "tsx": "^4.22.4", "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0", "vitest": "^4.1.9" } } diff --git a/ts/src/adapters/inbound/cli/arity/arity.test.ts b/ts/src/adapters/inbound/cli/arity/arity.test.ts index 9e1b3b19d..198b192be 100644 --- a/ts/src/adapters/inbound/cli/arity/arity.test.ts +++ b/ts/src/adapters/inbound/cli/arity/arity.test.ts @@ -12,7 +12,10 @@ describe("enumOptions", () => { }); it("descends ciEnum's preprocess pipe to find the literals (through default/optional)", () => { expect(enumOptions(ciEnum(["energy", "bandwidth"]))).toEqual(["energy", "bandwidth"]); - expect(enumOptions(ciEnum(["energy", "bandwidth"]).default("bandwidth"))).toEqual(["energy", "bandwidth"]); + expect(enumOptions(ciEnum(["energy", "bandwidth"]).default("bandwidth"))).toEqual([ + "energy", + "bandwidth", + ]); expect(enumOptions(ciEnum(["native", "token"]).optional())).toEqual(["native", "token"]); }); }); diff --git a/ts/src/adapters/inbound/cli/arity/index.ts b/ts/src/adapters/inbound/cli/arity/index.ts index fbb8da372..e93f41768 100644 --- a/ts/src/adapters/inbound/cli/arity/index.ts +++ b/ts/src/adapters/inbound/cli/arity/index.ts @@ -12,8 +12,11 @@ import { z, type ZodObject, type ZodRawShape, type ZodType } from "zod"; // lives on the FINAL schema instance (zod methods clone), so accountRef applies min+describe // itself and must be the terminal call — no further chaining. const ACCOUNT_REF = new WeakSet(); -export function accountRef(describe: string): ZodType { - const s = z.string().min(1).describe(describe); +export function accountRef(describe: string, opts: { optional?: boolean } = {}): ZodType { + const base = z.string().min(1).describe(describe); + // The brand must sit on the instance stored in `fields`, so an optional ref is wrapped HERE — + // chaining `.optional()` at the call site would clone away the brand and silently lose the picker. + const s = opts.optional ? base.optional() : base; ACCOUNT_REF.add(s); return s; } @@ -49,13 +52,22 @@ export function camelToKebab(s: string): string { return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); } -function unwrap(schema: ZodType): { base: ZodType; optional: boolean; hasDefault: boolean; defaultValue?: unknown; description?: string } { +function unwrap(schema: ZodType): { + base: ZodType; + optional: boolean; + hasDefault: boolean; + defaultValue?: unknown; + description?: string; +} { let s: any = schema; let optional = false; let hasDefault = false; let defaultValue: unknown; let description: string | undefined = s?.description; - while (s?.def && (s.def.type === "optional" || s.def.type === "default" || s.def.type === "nullable")) { + while ( + s?.def && + (s.def.type === "optional" || s.def.type === "default" || s.def.type === "nullable") + ) { if (s.def.type === "optional" || s.def.type === "nullable") optional = true; if (s.def.type === "default") { hasDefault = true; @@ -106,7 +118,11 @@ export function introspectFields(fields: ZodObject): FieldInfo[] { /** literal options of an enum field (after unwrapping optional/default), else undefined. */ export function enumOptions(schema: ZodType): string[] | undefined { const { base } = unwrap(schema as ZodType); - let def = (base as unknown as { def?: { type?: string; entries?: Record; out?: { def?: any } } }).def; + let def = ( + base as unknown as { + def?: { type?: string; entries?: Record; out?: { def?: any } }; + } + ).def; // ciEnum() wraps the enum in a preprocess pipe; the literals live on the pipe's output side. if (def?.type === "pipe") def = def.out?.def; if (def?.type !== "enum" || !def.entries) return undefined; diff --git a/ts/src/adapters/inbound/cli/commands/account.ts b/ts/src/adapters/inbound/cli/commands/account.ts index 69fb3374a..5d598ecf5 100644 --- a/ts/src/adapters/inbound/cli/commands/account.ts +++ b/ts/src/adapters/inbound/cli/commands/account.ts @@ -2,6 +2,7 @@ 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 { Schemas } from "../schemas/index.js"; import { TextFormatters } from "../render/index.js"; import { txModeFields } from "./shared.js"; @@ -39,11 +40,11 @@ export const accountActivateSpec: ChainSpec = { capability: "account.activate", summary: "Activate a new TRON account", description: - "Create an AccountCreateContract funded by the active account. The target must not already be\n" - + "active; use --dry-run to inspect current creation fees. Note: a plain transfer also activates\n" - + "the recipient, so use this command only when the address just needs to exist.", + "Create an AccountCreateContract funded by the active account. The target must not already be\n" + + "active; use --dry-run to inspect current creation fees. Note: a plain transfer also activates\n" + + "the recipient, so use this command only when the address just needs to exist.", baseFields: z.object({ - address: z.string().min(1).describe("unactivated TRON base58 address"), + address: Schemas.addressFor("tron").describe("unactivated TRON base58 address"), ...txModeFields, }), baseRefine: transactionModeRefine, @@ -54,9 +55,7 @@ export const accountActivateSpec: ChainSpec = { formatText: TextFormatters.txReceipt, }; -export const accountActivateTronBinding = ( - service: TronAccountService, -): FamilyBinding => ({ +export const accountActivateTronBinding = (service: TronAccountService): FamilyBinding => ({ run: async (ctx, network, input) => service.activate(ctx, network, input), }); @@ -69,11 +68,15 @@ export const accountSetSpec: ChainSpec = { capability: "account.set", summary: "Set the one-time on-chain account name or ID", description: - "Set exactly one immutable account field. Names are 1-32 UTF-8 bytes; IDs are unique and 8-32\n" - + "UTF-8 bytes. Each can be set only once and can never be changed afterwards — rehearse with\n" - + "--dry-run to check the value first. This is not `wallet-cli rename`, which changes the local label.", + "Set exactly one immutable account field. Names are 1-32 UTF-8 bytes; IDs are unique and 8-32\n" + + "UTF-8 bytes. Each can be set only once and can never be changed afterwards — rehearse with\n" + + "--dry-run to check the value first. This is not `wallet-cli rename`, which changes the local label.", baseFields: z.object({ - name: z.string().min(1).optional().describe("one-time on-chain account name (1-32 UTF-8 bytes)"), + name: z + .string() + .min(1) + .optional() + .describe("one-time on-chain account name (1-32 UTF-8 bytes)"), id: z.string().min(1).optional().describe("one-time unique account ID (8-32 UTF-8 bytes)"), ...txModeFields, }), @@ -95,15 +98,15 @@ export const accountSetSpec: ChainSpec = { formatText: TextFormatters.txReceipt, }; -export const accountSetTronBinding = ( - service: TronAccountService, -): FamilyBinding => ({ +export const accountSetTronBinding = (service: TronAccountService): FamilyBinding => ({ run: async (ctx, network, input) => service.setOnChain(ctx, network, input), }); export const accountBalanceSpec: ChainSpec = { path: ["account", "balance"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "account.balance.native", summary: "Show native balance (TRX/SUN)", baseFields: z.object({}), @@ -117,7 +120,9 @@ export const accountBalanceTronBinding = (svc: TronAccountService): FamilyBindin export const accountInfoSpec: ChainSpec = { path: ["account", "info"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", summary: "Show raw account data (getAccount; TRON includes resources)", baseFields: z.object({}), examples: [{ cmd: "wallet-cli account info" }], @@ -130,12 +135,20 @@ export const accountInfoTronBinding = (svc: TronAccountService): FamilyBinding = export const accountHistorySpec: ChainSpec = { path: ["account", "history"], - network: "optional", wallet: "optional", auth: "none", + 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) + 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() + 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" }], @@ -148,7 +161,9 @@ export const accountHistoryTronBinding = (svc: TronAccountService): FamilyBindin export const accountPortfolioSpec: ChainSpec = { path: ["account", "portfolio"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "account.portfolio", summary: "Show native + token balances with best-effort USD value", baseFields: z.object({}), diff --git a/ts/src/adapters/inbound/cli/commands/address.test.ts b/ts/src/adapters/inbound/cli/commands/address.test.ts index 6602ebd4f..b9d70d208 100644 --- a/ts/src/adapters/inbound/cli/commands/address.test.ts +++ b/ts/src/adapters/inbound/cli/commands/address.test.ts @@ -9,8 +9,14 @@ function helpFor(path: string[]): string { registerAddressCommands(registry, { generate: async () => ({}) } as never); let rendered = ""; const streams = { - result(text: string) { rendered = text; }, - diagnostic() {}, errorLine() {}, event() {}, readStdinOnce: () => "", warnings: () => [], + result(text: string) { + rendered = text; + }, + diagnostic() {}, + errorLine() {}, + event() {}, + readStdinOnce: () => "", + warnings: () => [], } as unknown as StreamManager; new HelpService(registry, streams, "0.0.0").handleMeta([...path, "--help"]); return rendered; @@ -21,8 +27,14 @@ function schemaFor(path: string[]): any { registerAddressCommands(registry, { generate: async () => ({}) } as never); let rendered = ""; const streams = { - result(text: string) { rendered = text; }, - diagnostic() {}, errorLine() {}, event() {}, readStdinOnce: () => "", warnings: () => [], + result(text: string) { + rendered = text; + }, + diagnostic() {}, + errorLine() {}, + event() {}, + readStdinOnce: () => "", + warnings: () => [], } as unknown as StreamManager; new HelpService(registry, streams, "0.0.0").handleMeta([...path, "--json-schema"]); return JSON.parse(rendered); @@ -33,8 +45,10 @@ function schemaFor(path: string[]): any { // machine or a CI runner — so the location has to be visible up front, not only in the receipt. describe("address generate --out documents its default location", () => { it("names the default path in the flag description", () => { - const out = helpFor(["address", "generate"]) - .split("\n").find((line) => line.trimStart().startsWith("--out")) ?? ""; + const out = + helpFor(["address", "generate"]) + .split("\n") + .find((line) => line.trimStart().startsWith("--out")) ?? ""; // shape asserted against the writer in keypair-writer.test.ts ("derives the default location…") expect(out).toContain("generated/keypair-
"); }); @@ -48,8 +62,10 @@ describe("address generate --out documents its default location", () => { // The rendered "[optional, default: X]" tag is derived from zod, so faking one here would put a // default in help that --json-schema does not have. it("does not fake a default tag on the rendered flag line", () => { - const out = helpFor(["address", "generate"]) - .split("\n").find((line) => line.trimStart().startsWith("--out")) ?? ""; + const out = + helpFor(["address", "generate"]) + .split("\n") + .find((line) => line.trimStart().startsWith("--out")) ?? ""; expect(out).toContain("[optional]"); expect(out).not.toMatch(/\[optional, default:/); }); diff --git a/ts/src/adapters/inbound/cli/commands/address.ts b/ts/src/adapters/inbound/cli/commands/address.ts index 05fb08a2d..22f21dab3 100644 --- a/ts/src/adapters/inbound/cli/commands/address.ts +++ b/ts/src/adapters/inbound/cli/commands/address.ts @@ -4,17 +4,22 @@ import type { CommandRegistry } from "../registry/index.js"; import type { AddressService } from "../../../../application/use-cases/address-service.js"; import { TextFormatters } from "../render/index.js"; -export function registerAddressCommands( - registry: CommandRegistry, - service: AddressService, -): void { +export function registerAddressCommands(registry: CommandRegistry, service: AddressService): void { const fields = z.object({ // The default lives in SecureKeypairWriter, not in this schema, so it is stated in prose: // a "[optional, default: …]" tag is derived from zod and would claim a default --json-schema // does not have. Readers need the location before running, not only in the receipt. - out: z.string().min(1).max(4096).optional() - .describe("exclusive 0600 output path; existing files are never overwritten (default: /generated/keypair-
)"), - printSecret: z.boolean().default(false) + out: z + .string() + .min(1) + .max(4096) + .optional() + .describe( + "exclusive 0600 output path; existing files are never overwritten (default: /generated/keypair-
)", + ), + printSecret: z + .boolean() + .default(false) .describe("print the private key instead of writing it; use only offline"), }); registry.add({ @@ -22,8 +27,7 @@ export function registerAddressCommands( network: "none", wallet: "none", auth: "none", - summary: - "Generate a random TRON/EVM keypair locally without adding it to the wallet", + summary: "Generate a random TRON/EVM keypair locally without adding it to the wallet", description: "Generate a secp256k1 keypair offline. By default the private key is written exclusively to a 0600 file and never printed or added to the keystore.", fields, diff --git a/ts/src/adapters/inbound/cli/commands/artifact.test.ts b/ts/src/adapters/inbound/cli/commands/artifact.test.ts index 2d4ba7fb2..efd29810c 100644 --- a/ts/src/adapters/inbound/cli/commands/artifact.test.ts +++ b/ts/src/adapters/inbound/cli/commands/artifact.test.ts @@ -13,15 +13,13 @@ afterEach(() => { }); describe("readBoundedTextFile", () => { - it.runIf(process.platform !== "win32")( - "rejects a FIFO without waiting for a writer", - () => { - const root = mkdtempSync(join(tmpdir(), "wallet-cli-artifact-")); - roots.push(root); - const fifo = join(root, "transaction.hex"); - execFileSync("mkfifo", [fifo]); + it.runIf(process.platform !== "win32")("rejects a FIFO without waiting for a writer", () => { + const root = mkdtempSync(join(tmpdir(), "wallet-cli-artifact-")); + roots.push(root); + const fifo = join(root, "transaction.hex"); + execFileSync("mkfifo", [fifo]); - const script = ` + const script = ` import { readBoundedTextFile } from ${JSON.stringify(new URL("./artifact.ts", import.meta.url).href)}; try { readBoundedTextFile(${JSON.stringify(fifo)}, 1024, "transaction hex file"); @@ -30,18 +28,17 @@ describe("readBoundedTextFile", () => { process.stdout.write(JSON.stringify({ code: error.code, message: error.message })); } `; - const result = spawnSync( - process.execPath, - ["--import", "tsx", "--input-type=module", "--eval", script], - { encoding: "utf8", timeout: 1_000 }, - ); + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { encoding: "utf8", timeout: 1_000 }, + ); - expect(result.error).toBeUndefined(); - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ - code: "invalid_value", - message: "transaction hex file must be a regular file", - }); - }, - ); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + code: "invalid_value", + message: "transaction hex file must be a regular file", + }); + }); }); diff --git a/ts/src/adapters/inbound/cli/commands/artifact.ts b/ts/src/adapters/inbound/cli/commands/artifact.ts index 4785d5c9b..2b3f754e2 100644 --- a/ts/src/adapters/inbound/cli/commands/artifact.ts +++ b/ts/src/adapters/inbound/cli/commands/artifact.ts @@ -6,13 +6,12 @@ export function readBoundedTextFile(path: string, maxBytes: number, label: strin try { fd = openSync( path, - constants.O_RDONLY - | (constants.O_NOFOLLOW ?? 0) - | (constants.O_NONBLOCK ?? 0), + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), ); const stat = fstatSync(fd); if (!stat.isFile()) throw new UsageError("invalid_value", `${label} must be a regular file`); - if (stat.size > maxBytes) throw new UsageError("invalid_value", `${label} exceeds the ${maxBytes}-byte limit`); + if (stat.size > maxBytes) + throw new UsageError("invalid_value", `${label} exceeds the ${maxBytes}-byte limit`); return readFileSync(fd, "utf8"); } catch (error) { if (error instanceof UsageError) throw error; diff --git a/ts/src/adapters/inbound/cli/commands/asset.ts b/ts/src/adapters/inbound/cli/commands/asset.ts new file mode 100644 index 000000000..3543830de --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/asset.ts @@ -0,0 +1,247 @@ +/** + * `asset` — TRC10, the TRON protocol's own token type (issuance, ICO window, frozen supply). + * TRC20 contracts live under `token`; TRC10 *transfer* is `tx send` with an asset id. + * + * Deviations from the v4.12.0 command spec for this group are recorded in + * docs/asset-exchange-spec-deviations-v4.12.0.md — notably: Ledger cannot sign any of these + * contract types, `asset list` defaults to one page rather than the whole chain, and an ambiguous + * token name is an error rather than a differently-shaped result. + */ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { TronAssetService } from "../../../../application/use-cases/tron/asset-service.js"; +import { txModeFields } from "./shared.js"; +import { Schemas } from "../schemas/index.js"; +import { TextFormatters } from "../render/index.js"; + +const LEDGER_NOTE = + "The Ledger TRON app cannot decode TRC10 issuance contracts, so this command needs a software account."; + +const assetReference = z + .string() + .min(1) + .describe("token id or name; a numeric value is read as the id"); + +export const assetIssueSpec: ChainSpec = { + path: ["asset", "issue"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "asset.issue", + summary: "Issue a TRC10 token and lock in its ICO terms", + description: + "Issue a TRC10 token and lock in its ICO terms at the same time.\n\n" + + "IRREVERSIBLE: the issuance fee is burned and an account can only ever issue ONE\n" + + "TRC10 token — you cannot amend or re-issue. Only the description, URL and the two\n" + + "free bandwidth limits stay changeable afterward (see 'asset update'); everything\n" + + "else is fixed at issuance. Note --price is converted using --precision, so the\n" + + "same --price at a different --precision yields a different on-chain rate.", + requires: [ + "an account that has never issued a TRC10, with balance >= the issuance fee", + LEDGER_NOTE, + ], + baseFields: z.object({ + name: z.string().min(1).describe("token name, 1-32 visible ASCII chars"), + supply: z.string().min(1).describe("total supply, in whole tokens"), + price: z.string().min(1).describe("ICO rate in whole TRX to whole tokens, e.g. 1:100"), + start: z + .string() + .min(1) + .describe( + 'ICO start, YYYY-MM-DD or "YYYY-MM-DD HH:mm:ss", read as UTC; must be in the future', + ), + end: z.string().min(1).describe("ICO end, same format, must be after --start"), + url: z.string().describe("project page, must not be empty"), + abbr: z.string().optional().describe("token abbreviation"), + precision: z.coerce.number().int().min(0).max(6).default(0).describe("decimal places"), + description: z.string().optional().describe("short description, up to 200 bytes"), + freeNetPerAccount: z.coerce + .number() + .int() + .min(0) + .optional() + .describe("free bandwidth each holder may use"), + publicFreeNet: z.coerce + .number() + .int() + .min(0) + .optional() + .describe("shared free bandwidth pool for holders"), + // repeatable: the arity layer sets yargs `array: true`, so this always arrives as string[] + freeze: z + .array(z.string().min(1)) + .optional() + .describe("frozen tranche :, amount in whole tokens; repeatable"), + ...txModeFields, + }), + examples: [ + { + cmd: "wallet-cli asset issue --name MyToken --supply 1000000000 --price 1:100 --start 2026-08-01 --end 2026-08-31 --url https://mytoken.io --wait", + }, + ], + formatText: TextFormatters.txReceipt, +}; + +export const assetUpdateSpec: ChainSpec = { + path: ["asset", "update"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "asset.update", + summary: "Update the mutable fields of the TRC10 you issued", + description: + "Update the mutable fields of the TRC10 you issued. There is no token argument:\n" + + "it always targets the token issued by the signing account.\n\n" + + "Only these four fields can ever be changed. Supply, ICO price, ICO dates,\n" + + "precision and the frozen tranches were fixed at issuance and cannot be altered.\n\n" + + "Pass only the fields you want to change; the others are read from chain and\n" + + "written back unchanged. At least one field is required.", + requires: ["an account that has issued a TRC10", LEDGER_NOTE], + baseFields: z.object({ + description: z.string().optional().describe("new description, up to 200 bytes"), + url: z.string().optional().describe("new project page, must not be empty"), + freeNetPerAccount: z.coerce + .number() + .int() + .min(0) + .optional() + .describe("free bandwidth each holder may use"), + publicFreeNet: z.coerce + .number() + .int() + .min(0) + .optional() + .describe("shared free bandwidth pool for holders"), + ...txModeFields, + }), + examples: [{ cmd: "wallet-cli asset update --url https://mytoken.io/v2 --wait" }], + formatText: TextFormatters.txReceipt, +}; + +export const assetParticipateSpec: ChainSpec = { + path: ["asset", "participate"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "asset.participate", + summary: "Buy into a TRC10's ICO at its fixed rate", + description: + "Buy into a TRC10's ICO during its funding window, at the fixed rate set when the\n" + + "token was issued. This is participation in the issuance, not a market trade.\n\n" + + "--pay is the amount of TRX you spend, NOT the number of tokens you receive.\n" + + "Tokens are rounded DOWN to a whole unit and the TRX you paid is transferred in\n" + + "full, so a truncated remainder is not refunded. Paying too little to buy even one\n" + + "unit is rejected before broadcast. The issuer's address is resolved from the\n" + + "token automatically.", + requires: ["an account with enough TRX, other than the token's issuer", LEDGER_NOTE], + positionals: [{ field: "assetRef", placeholder: "asset" }], + baseFields: z.object({ + assetRef: assetReference, + pay: z.string().min(1).describe("TRX to spend (decimal, not the number of tokens)"), + ...txModeFields, + }), + examples: [{ cmd: "wallet-cli asset participate 1000124 --pay 100 --wait" }], + formatText: TextFormatters.txReceipt, +}; + +export const assetUnfreezeSpec: ChainSpec = { + path: ["asset", "unfreeze"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "asset.unfreeze", + summary: "Release the matured frozen supply of the TRC10 you issued", + description: + "Release the frozen supply of the TRC10 you issued, once its lock period is over.\n" + + "There is no argument: it always targets the token issued by the signing account,\n" + + "and every tranche that has matured is released in one transaction. Tranches that\n" + + "have not matured yet are untouched — run it again later for those.\n\n" + + "This is unrelated to 'stake unfreeze', which releases staked TRX.", + requires: ["an account that has issued a TRC10 and has matured frozen supply", LEDGER_NOTE], + baseFields: z.object({ ...txModeFields }), + examples: [{ cmd: "wallet-cli asset unfreeze --wait" }], + formatText: TextFormatters.txReceipt, +}; + +export const assetInfoSpec: ChainSpec = { + path: ["asset", "info"], + network: "optional", + wallet: "none", + auth: "none", + capability: "asset.info", + summary: "Show a TRC10 in full", + description: + "Show a TRC10 in full: issuer, supply, precision, ICO rate and window, frozen\n" + + "tranches, description and URL.\n\n" + + "Give exactly one of the argument or --issuer.\n\n" + + "Token names are not guaranteed unique. A name matching more than one token is an\n" + + "error listing the matching ids — re-run with the id you want.", + // The choice here is between a positional and a flag; `exclusive` groups model flag-vs-flag + // only, so the constraint is stated above and enforced in the service. + positionals: [{ field: "assetRef", placeholder: "asset" }], + baseFields: z.object({ + assetRef: assetReference.optional(), + issuer: Schemas.addressFor("tron") + .optional() + .describe("look up the token issued by this address"), + }), + examples: [ + { cmd: "wallet-cli asset info 1000123" }, + { cmd: "wallet-cli asset info MyToken" }, + { cmd: "wallet-cli asset info --issuer TQkXm4vN...5Zt7Uw" }, + ], + formatText: TextFormatters.assetInfo, +}; + +export const assetListSpec: ChainSpec = { + path: ["asset", "list"], + network: "optional", + wallet: "none", + auth: "none", + capability: "asset.list", + summary: "List TRC10 tokens, one page at a time", + description: + "List TRC10 tokens with id, name, total supply, precision and issuer.\n\n" + + "Paged server-side; there are thousands of TRC10s on chain, so raise --limit\n" + + "deliberately rather than expecting the whole list. No total is reported — the\n" + + "chain does not return one without transferring every record.\n" + + "Use 'asset info' for the full detail of one token.", + baseFields: z.object({ + limit: z.coerce + .number() + .int() + .positive() + .max(1000) + .default(10) + .describe("max tokens to return"), + offset: z.coerce.number().int().min(0).default(0).describe("pagination offset"), + }), + examples: [ + { cmd: "wallet-cli asset list" }, + { cmd: "wallet-cli asset list --limit 50 --offset 50" }, + ], + formatText: TextFormatters.assetList, +}; + +export function assetDefinitions( + svc: TronAssetService, +): Array<{ spec: ChainSpec; binding: FamilyBinding }> { + return [ + { spec: assetIssueSpec, binding: { run: (ctx, net, input) => svc.issue(ctx, net, input) } }, + { spec: assetUpdateSpec, binding: { run: (ctx, net, input) => svc.update(ctx, net, input) } }, + { + spec: assetParticipateSpec, + binding: { run: (ctx, net, input) => svc.participate(ctx, net, input) }, + }, + { + spec: assetUnfreezeSpec, + binding: { run: (ctx, net, input) => svc.unfreeze(ctx, net, input) }, + }, + { spec: assetInfoSpec, binding: { run: (_ctx, net, input) => svc.info(net, input) } }, + { spec: assetListSpec, binding: { run: (_ctx, net, input) => svc.list(net, input) } }, + ]; +} diff --git a/ts/src/adapters/inbound/cli/commands/block.ts b/ts/src/adapters/inbound/cli/commands/block.ts index fdc268a89..5cc28f9c8 100644 --- a/ts/src/adapters/inbound/cli/commands/block.ts +++ b/ts/src/adapters/inbound/cli/commands/block.ts @@ -6,10 +6,16 @@ import { TextFormatters } from "../render/index.js"; export const blockSpec: ChainSpec = { path: ["block"], - network: "optional", wallet: "none", auth: "none", + 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") }), + 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, }; diff --git a/ts/src/adapters/inbound/cli/commands/chain.ts b/ts/src/adapters/inbound/cli/commands/chain.ts index 11d93bd5b..baa6f37a2 100644 --- a/ts/src/adapters/inbound/cli/commands/chain.ts +++ b/ts/src/adapters/inbound/cli/commands/chain.ts @@ -3,18 +3,28 @@ 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 }> { +export function chainDefinitions( + service: TronChainService, +): Array<{ spec: ChainSpec; binding: FamilyBinding }> { return [ { spec: { path: ["chain", "params"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", summary: "On-chain governance parameters", description: "Show on-chain governance parameters. Use --key for one value.", baseFields: z.object({ - key: z.string().optional().describe("return only this parameter (e.g. getEnergyFee); omit to list all"), + 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" }], + 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) }, @@ -22,7 +32,9 @@ export function chainDefinitions(service: TronChainService): Array<{ spec: Chain { spec: { path: ["chain", "prices"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", summary: "Energy/bandwidth unit price and memo fee", description: "Show current energy/bandwidth unit price (in SUN; 1 TRX = 1,000,000 SUN)\n" + @@ -36,12 +48,14 @@ export function chainDefinitions(service: TronChainService): Array<{ spec: Chain { spec: { path: ["chain", "node"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", summary: "Connected node status (version / sync / peers)", description: "Show the connected node's status: version, head/solid block height, sync state,\n" + - "and peer connections. Useful to tell \"node out of sync\" from \"problem with my\n" + - "transaction\". Fields the endpoint does not expose are shown as \"—\" (null in json).", + 'and peer connections. Useful to tell "node out of sync" from "problem with my\n' + + 'transaction". Fields the endpoint does not expose are shown as "—" (null in json).', baseFields: z.object({}), examples: [{ cmd: "wallet-cli chain node" }], formatText: TextFormatters.chainNode, diff --git a/ts/src/adapters/inbound/cli/commands/change-password.test.ts b/ts/src/adapters/inbound/cli/commands/change-password.test.ts index 50ceb8aba..86476e305 100644 --- a/ts/src/adapters/inbound/cli/commands/change-password.test.ts +++ b/ts/src/adapters/inbound/cli/commands/change-password.test.ts @@ -38,18 +38,25 @@ function setup(opts: { newPrompt?: string; confirm?: boolean } = {}) { 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 }); + 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" }); + 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" }); + await expect(command.run(ctx, undefined, { yes: false })).rejects.toMatchObject({ + code: "aborted", + }); }); it("skips the confirmation prompt with --yes", async () => { diff --git a/ts/src/adapters/inbound/cli/commands/config.ts b/ts/src/adapters/inbound/cli/commands/config.ts index e770aeefb..f9f263155 100644 --- a/ts/src/adapters/inbound/cli/commands/config.ts +++ b/ts/src/adapters/inbound/cli/commands/config.ts @@ -9,7 +9,9 @@ import { TextFormatters } from "../render/index.js"; export function registerConfigCommands(registry: CommandRegistry, service: ConfigService): void { const fields = z.object({ - key: z.enum(CONFIG_KEYS).optional() + key: z + .enum(CONFIG_KEYS) + .optional() .describe("config key to read or set; omit to show the whole effective config"), value: z.string().min(1).optional().describe("new value; omit to read the key"), }); diff --git a/ts/src/adapters/inbound/cli/commands/contact.ts b/ts/src/adapters/inbound/cli/commands/contact.ts index 041af6d27..3f5e3b45c 100644 --- a/ts/src/adapters/inbound/cli/commands/contact.ts +++ b/ts/src/adapters/inbound/cli/commands/contact.ts @@ -4,17 +4,15 @@ import type { CommandRegistry } from "../registry/index.js"; import type { ContactService } from "../../../../application/use-cases/contact-service.js"; import { TextFormatters } from "../render/index.js"; -export function registerContactCommands( - registry: CommandRegistry, - service: ContactService, -): void { +export function registerContactCommands(registry: CommandRegistry, service: ContactService): void { const addFields = z.object({ - name: z.string().min(1).max(256) + name: z + .string() + .min(1) + .max(256) .describe("local name for this recipient; usable anywhere an address is accepted"), - address: z.string().min(1).max(128) - .describe("recipient address to store under this name"), - note: z.string().max(512).optional() - .describe("free-form note, up to 128 safe characters"), + address: z.string().min(1).max(128).describe("recipient address to store under this name"), + note: z.string().max(512).optional().describe("free-form note, up to 128 safe characters"), }); registry.add({ path: ["contact", "add"], @@ -27,12 +25,13 @@ export function registerContactCommands( "Add a locally stored TRON recipient. The Base58Check address is validated and the name can then be used by tx send and gasfree transfer.", fields: addFields, input: addFields, - examples: [{ - cmd: "wallet-cli contact add alice TBy6... --note 'Alice mainnet'", - }], + examples: [ + { + cmd: "wallet-cli contact add alice TBy6... --note 'Alice mainnet'", + }, + ], formatText: TextFormatters.contactAdd, - run: async (_context, _network, input) => - service.add(input.name, input.address, input.note), + run: async (_context, _network, input) => service.add(input.name, input.address, input.note), } satisfies CommandDefinition); const empty = z.object({}); @@ -42,8 +41,7 @@ export function registerContactCommands( wallet: "none", auth: "none", summary: "List recipients", - description: - "List every recipient in the local plaintext address book.", + description: "List every recipient in the local plaintext address book.", fields: empty, input: empty, examples: [{ cmd: "wallet-cli contact list" }], diff --git a/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts new file mode 100644 index 000000000..dd1b16397 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/contract.deploy.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from "vitest"; +import { contractDeployTronBinding } from "./contract.js"; +import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; + +/** + * `contract deploy` input guards. + * + * The contract under test is ALIGNMENT: every input TronWeb's createSmartContract encoder accepts + * must still reach it, and every input it refuses must still be refused. The guards only change how + * two of those refusals are worded — one of which TronWeb does not word at all, but crashes on. + * + * Each expectation below was first measured against the real encoder + * (tronweb/lib/commonjs/lib/TransactionBuilder/TransactionBuilder.js:541, + * `'payable' === func.stateMutability.toLowerCase()`), and the measured behaviour is named in the + * test so a TronWeb upgrade that moves the boundary shows up as a failure here. + */ + +function deployWith(input: { abi: string; params?: string }) { + const deploy = vi.fn(async (_ctx: unknown, _net: unknown, _input: { parameters: unknown[] }) => ({ + kind: "tx-receipt" as const, + })); + const binding = contractDeployTronBinding({ deploy } as unknown as TronContractService); + const run = () => + binding.run( + {} as never, + {} as never, + { bytecode: "6080", feeLimit: "1000000", ...input } as never, + ); + return { run, deploy }; +} + +const ctor = (over: Record = {}) => + JSON.stringify([{ type: "constructor", inputs: [{ name: "x", type: "uint256" }], ...over }]); + +describe("contract deploy — ABI constructor guard", () => { + // Measured: TronWeb only reads stateMutability on constructor entries (its && short-circuits), + // so an ABI without one encodes fine no matter what else it carries. + it("passes an ABI with no constructor straight through", async () => { + const { run, deploy } = deployWith({ abi: JSON.stringify([{ type: "function", name: "f" }]) }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + it("passes a constructor carrying stateMutability", async () => { + const { run, deploy } = deployWith({ abi: ctor({ stateMutability: "nonpayable" }) }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + it("passes a payable constructor", async () => { + const { run, deploy } = deployWith({ abi: ctor({ stateMutability: "payable" }) }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + // Measured: TronWeb SUCCEEDS on "" — ''.toLowerCase() is legal, the constructor is just not + // payable. Rejecting it would make this CLI stricter than the encoder it fronts. + it("passes an empty-string stateMutability, which TronWeb accepts", async () => { + const { run, deploy } = deployWith({ abi: ctor({ stateMutability: "" }) }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + // Measured: each of these crashes the encoder — "Cannot read properties of undefined/null + // (reading 'toLowerCase')" or "func.stateMutability.toLowerCase is not a function" — surfacing as + // rpc_error/exit 1 with no request ever sent. + it.each([ + ["absent", {}], + ["null", { stateMutability: null }], + ["a number", { stateMutability: 1 }], + ["a boolean", { stateMutability: false }], + ["an object", { stateMutability: {} }], + ])("rejects a constructor whose stateMutability is %s", async (_label, over) => { + const { run, deploy } = deployWith({ abi: ctor(over) }); + await expect(run()).rejects.toMatchObject({ + code: "invalid_value", + message: expect.stringContaining("stateMutability"), + }); + expect(deploy).not.toHaveBeenCalled(); + }); + + // The crash does not depend on the constructor taking arguments — TronWeb reads the key before + // it ever looks at `inputs`. + it.each([ + ["an empty inputs list", JSON.stringify([{ type: "constructor", inputs: [] }])], + ["no inputs key at all", JSON.stringify([{ type: "constructor" }])], + ])("rejects a stateMutability-less constructor with %s", async (_label, abi) => { + const { run } = deployWith({ abi }); + await expect(run()).rejects.toMatchObject({ code: "invalid_value" }); + }); + + // Measured: TronWeb reads `abi.entrys` when present, so the guard has to see through that + // wrapper or it would wave through an ABI that still crashes. + it("looks inside the { entrys } wrapper TronWeb also accepts", async () => { + const { run } = deployWith({ abi: JSON.stringify({ entrys: [{ type: "constructor" }] }) }); + await expect(run()).rejects.toMatchObject({ code: "invalid_value" }); + }); + + it("passes an { entrys } wrapper whose constructor is well-formed", async () => { + const abi = JSON.stringify({ + entrys: [{ type: "constructor", stateMutability: "nonpayable" }], + }); + const { run, deploy } = deployWith({ abi }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + // Measured: TronWeb answers this one clearly by itself ("Invalid options.abi provided"), so + // adding our own rejection would only move the goalposts. + it("leaves an ABI that is neither array nor { entrys } to TronWeb", async () => { + const { run, deploy } = deployWith({ abi: JSON.stringify({ foo: 1 }) }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + it("still rejects an ABI that is not JSON at all", async () => { + const { run } = deployWith({ abi: "{not json" }); + await expect(run()).rejects.toMatchObject({ code: "invalid_value", message: /valid JSON/ }); + }); +}); + +describe("contract deploy — --params form guard", () => { + const ABI = ctor({ stateMutability: "nonpayable" }); + + it("passes raw positional values, the documented deploy form", async () => { + const { run, deploy } = deployWith({ + abi: ABI, + params: '[100, "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"]', + }); + await expect(run()).resolves.toBeDefined(); + expect(deploy.mock.calls[0]![2]).toMatchObject({ + parameters: [100, "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"], + }); + }); + + it("defaults to no constructor args when --params is omitted", async () => { + const { run, deploy } = deployWith({ abi: ABI }); + await expect(run()).resolves.toBeDefined(); + expect(deploy.mock.calls[0]![2]).toMatchObject({ parameters: [] }); + }); + + // Measured: TronWeb rejects this too, as ethers' `invalid BigNumberish value (argument="value")` + // — an internal argument name that collides with the user's own key. Same refusal, named. + it("rejects the {type,value} form that contract call/send take", async () => { + const params = '[{"type":"uint256","value":"100"}]'; + const { run, deploy } = deployWith({ abi: ABI, params }); + await expect(run()).rejects.toMatchObject({ + code: "invalid_value", + message: expect.stringContaining("raw positional values"), + }); + expect(deploy).not.toHaveBeenCalled(); + }); + + it("rejects a multi-entry {type,value} array", async () => { + const params = '[{"type":"uint256","value":"1"},{"type":"address","value":"T..."}]'; + const { run } = deployWith({ abi: ABI, params }); + await expect(run()).rejects.toMatchObject({ code: "invalid_value" }); + }); + + // Only the unambiguous all-typed array is claimed. Anything else could be a legitimate struct or + // a half-edited command line, and TronWeb's arity/type errors read fine on their own + // ("constructor needs 1 but 2 provided"). + it.each([ + ["a mixed array", '[100, {"type":"uint256","value":"1"}]'], + ["objects carrying a third key", '[{"type":"uint256","value":"1","name":"cap"}]'], + ["objects whose type is not a string", '[{"type":1,"value":"1"}]'], + ["objects whose type is empty", '[{"type":"","value":"1"}]'], + ["an empty array", "[]"], + ])("leaves %s to TronWeb", async (_label, params) => { + const { run, deploy } = deployWith({ abi: ABI, params }); + await expect(run()).resolves.toBeDefined(); + expect(deploy).toHaveBeenCalledOnce(); + }); + + it("still rejects --params that is not a JSON array", async () => { + const { run } = deployWith({ abi: ABI, params: '{"type":"uint256"}' }); + await expect(run()).rejects.toMatchObject({ code: "invalid_value", message: /JSON array/ }); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index 153e7d63a..00cbbd00e 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -1,10 +1,11 @@ import { z } from "zod"; +import { readFile } from "node:fs/promises"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; import { UsageError } from "../../../../domain/errors/index.js"; import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; import type { TronContractParameter } from "../../../../application/ports/chain/tron-gateway.js"; import { Schemas } from "../schemas/index.js"; -import { txModeFields } from "./shared.js"; +import { governanceTxModeFields, governanceTxRefine } from "./shared.js"; import { TextFormatters } from "../render/index.js"; function jsonArray(raw: string | undefined, flag = "--params"): unknown[] { @@ -36,85 +37,186 @@ function typedParams(raw: string | undefined): TronContractParameter[] { return arr as TronContractParameter[]; } +// ── deploy input guards ──────────────────────────────────────────────────────── +// Both guards below only restate a rejection TronWeb already makes — the accepted input set is +// unchanged. They exist because TronWeb states these two in terms of its own internals, and one +// of them not as a rejection at all but as a crash. + +/** the ABI's entry list, in either shape TronWeb reads (`abi` itself, or `abi.entrys`). */ +function abiEntries(abi: unknown): unknown[] | undefined { + if (Array.isArray(abi)) return abi; + const wrapped = (abi as { entrys?: unknown } | null)?.entrys; + return Array.isArray(wrapped) ? wrapped : undefined; +} + +/** + * TronWeb decides whether a constructor may take call value with an unguarded read — + * `'payable' === func.stateMutability.toLowerCase()` (TransactionBuilder.js) — so a constructor + * entry whose `stateMutability` is not a string dies inside the encoder as "Cannot read properties + * of undefined (reading 'toLowerCase')", naming neither the ABI nor the missing key, and arriving + * as rpc_error/exit 1 despite no request having been sent. + * + * Rejected here iff that read would throw — i.e. the value is not a string. An empty string is + * left alone on purpose: TronWeb accepts it (`''.toLowerCase()` is fine, the constructor is simply + * not payable), and rejecting it would make this CLI stricter than the encoder it fronts. + * A non-array, non-`entrys` ABI is likewise left alone — TronWeb's own "Invalid options.abi + * provided" already says that plainly. + */ +function assertConstructorEncodable(abi: unknown): void { + for (const entry of abiEntries(abi) ?? []) { + const e = entry as { type?: unknown; stateMutability?: unknown } | null; + if (e?.type !== "constructor") continue; // TronWeb's && short-circuits the same way + if (typeof e.stateMutability !== "string") { + throw new UsageError( + "invalid_value", + '--abi constructor entry needs a string "stateMutability" ("nonpayable" or "payable"); ' + + "solc emits it — add it by hand if the ABI was trimmed or came from solc < 0.5", + ); + } + } +} + +/** + * Constructor args are RAW positional values here (`[100, "T..."]`) — types come from the ABI — + * whereas `contract call` / `send` take `{type,value}` entries. TronWeb rejects the wrong one too, + * but as ethers' `invalid BigNumberish value (argument="value", ...)`: an internal argument name + * that collides with the user's own `value` key and reads like a bad number rather than a wrong + * format. The two-format split is this CLI's own design, so name it in our own words. + * + * Only the unambiguous case is claimed — every entry an object with exactly `type` (a non-empty + * string) and `value`. A mixed or partial array is left to TronWeb rather than guessed at, and a + * genuine struct arg with those two field names can still be passed in positional array form. + */ +function deployParameters(raw: string | undefined): unknown[] { + const values = jsonArray(raw); + const allTyped = + values.length > 0 && + values.every((v) => { + if (!v || typeof v !== "object" || Array.isArray(v)) return false; + const keys = Object.keys(v); + return ( + keys.length === 2 && + keys.includes("type") && + keys.includes("value") && + typeof (v as { type: unknown }).type === "string" && + (v as { type: string }).type !== "" + ); + }); + if (allTyped) { + throw new UsageError( + "invalid_value", + '--params takes raw positional values for deploy (e.g. [100, "T..."]); {"type","value"} ' + + "entries are the `contract call`/`send` form — deploy reads the types from the ABI constructor", + ); + } + return values; +} + 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() + 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", + 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..."}]'`, - }], + 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), - ), + 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() + params: z + .string() + .optional() .describe("JSON array of ABI parameters as {type,value}; omit to pass no parameters"), - callValueSun: Schemas.uintString().default("0") + callValueSun: Schemas.uintString() + .default("0") .describe("native TRX attached to the call, in SUN"), - feeLimit: Schemas.positiveIntString().default("100000000") + feeLimit: Schemas.positiveIntString() + .default("100000000") .describe("maximum energy fee to burn, in SUN"), - ...txModeFields, + ...governanceTxModeFields, }); export const contractSendSpec: ChainSpec = { path: ["contract", "send"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", 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 '[...]'`, - }], + baseRefine: governanceTxRefine, + 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), - }), + 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, + 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', + ), + ...governanceTxModeFields, }); export const contractDeploySpec: ChainSpec = { path: ["contract", "deploy"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", 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"], + 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...\"]'", - }], + baseRefine: governanceTxRefine, + examples: [ + { + cmd: "wallet-cli contract deploy --abi '[...]' --bytecode 60... --fee-limit 1000000000 --params '[100, \"T...\"]'", + }, + ], formatText: TextFormatters.txReceipt, }; @@ -126,10 +228,11 @@ export const contractDeployTronBinding = (svc: TronContractService): FamilyBindi } catch { throw new UsageError("invalid_value", "--abi must be valid JSON"); } + assertConstructorEncodable(abi); return svc.deploy(ctx, net, { ...input, abi, - parameters: jsonArray(input.params), + parameters: deployParameters(input.params), }); }, }); @@ -140,7 +243,9 @@ const infoFields = z.object({ export const contractInfoSpec: ChainSpec = { path: ["contract", "info"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "contract.call", summary: "Show contract ABI + metadata", baseFields: infoFields, @@ -151,3 +256,145 @@ export const contractInfoSpec: ChainSpec = { export const contractInfoTronBinding = (svc: TronContractService): FamilyBinding => ({ run: async (_ctx, net, input) => svc.info(net, input.contract), }); + +const contractGovernanceBase = { + network: "optional" as const, + wallet: "optional" as const, + auth: "required" as const, + broadcasts: true, + capability: "contract.governance", + baseRefine: governanceTxRefine, + formatText: TextFormatters.governanceReceipt, +}; + +const governedContract = Schemas.addressFor("tron").describe( + "contract address; the selected account must be its deployer", +); + +export const contractClearAbiSpec: ChainSpec = { + path: ["contract", "clear-abi"], + ...contractGovernanceBase, + positionals: [{ field: "address" }], + summary: "Irreversibly clear a contract's on-chain ABI", + description: + "Clear the ABI metadata stored on-chain. This is irreversible, but does not change the\n" + + "contract bytecode or state. Only the contract deployer may perform the operation.", + requires: ["the contract deployer account"], + baseFields: z.object({ address: governedContract, ...governanceTxModeFields }), + examples: [{ cmd: "wallet-cli contract clear-abi TQ5... --wait" }], +}; + +export const contractClearAbiTronBinding = (svc: TronContractService): FamilyBinding => ({ + run: async (ctx, net, input) => svc.clearAbi(ctx, net, input), +}); + +export const contractSetOriginEnergyLimitSpec: ChainSpec = { + path: ["contract", "set-origin-energy-limit"], + ...contractGovernanceBase, + positionals: [{ field: "address" }, { field: "energy" }], + summary: "Set the deployer's per-call energy contribution cap", + description: + "Set origin_energy_limit, the maximum energy the deployer covers per call. The actual\n" + + "contribution is also limited by the deployer's available staked energy.", + requires: ["the contract deployer account"], + baseFields: z.object({ + address: governedContract, + energy: Schemas.positiveIntString() + .refine( + (value) => !/^\d+$/.test(value) || BigInt(value) <= (1n << 63n) - 1n, + "must not exceed signed int64 max", + ) + .describe("deployer energy contribution limit; integer > 0"), + ...governanceTxModeFields, + }), + examples: [{ cmd: "wallet-cli contract set-origin-energy-limit TQ5... 50000000 --wait" }], +}; + +export const contractSetOriginEnergyLimitTronBinding = ( + svc: TronContractService, +): FamilyBinding => ({ + run: async (ctx, net, input) => svc.setOriginEnergyLimit(ctx, net, input), +}); + +export const contractSetUserResourcePercentSpec: ChainSpec = { + path: ["contract", "set-user-resource-percent"], + ...contractGovernanceBase, + positionals: [{ field: "address" }, { field: "percent" }], + summary: "Set the caller-paid energy percentage", + description: + "Set consume_user_resource_percent. 100 means the caller pays all energy; 0 means the\n" + + "deployer pays, subject to origin_energy_limit and available staked energy.", + requires: ["the contract deployer account"], + baseFields: z.object({ + address: governedContract, + percent: z.coerce + .number() + .int() + .min(0) + .max(100) + .describe("percentage of energy paid by the caller (0-100)"), + ...governanceTxModeFields, + }), + examples: [{ cmd: "wallet-cli contract set-user-resource-percent TQ5... 100 --wait" }], +}; + +export const contractSetUserResourcePercentTronBinding = ( + svc: TronContractService, +): FamilyBinding => ({ + run: async (ctx, net, input) => svc.setUserResourcePercent(ctx, net, input), +}); + +function create2Refine(value: { code?: string; codeFile?: string }, ctx: z.RefinementCtx): void { + if ([value.code !== undefined, value.codeFile !== undefined].filter(Boolean).length !== 1) { + ctx.addIssue({ code: "custom", message: "provide exactly one of --code or --code-file" }); + } +} + +export const contractCreate2Spec: ChainSpec = { + path: ["contract", "create2"], + network: "optional", + wallet: "none", + auth: "none", + capability: "contract.create2", + summary: "Compute a TVM CREATE2 contract address locally", + description: + "Compute the TRON CREATE2 address locally without contacting a node. code must be creation\n" + + "bytecode with constructor arguments appended; salt is a signed decimal 64-bit integer.", + baseFields: z.object({ + deployer: Schemas.addressFor("tron").describe("account or factory contract performing CREATE2"), + code: z + .string() + .optional() + .describe("creation bytecode as hex; whitespace and an optional 0x prefix are stripped"), + codeFile: z.string().min(1).optional().describe("path containing creation bytecode hex"), + salt: z + .string() + .regex(/^-?\d+$/) + .describe("signed decimal 64-bit salt"), + }), + baseRefine: create2Refine, + examples: [ + { + cmd: "wallet-cli contract create2 --deployer TQk... --code-file ./Token.creation.hex --salt 1", + }, + { cmd: "wallet-cli contract create2 --deployer TQk... --code 60806040 --salt 255" }, + ], + formatText: TextFormatters.contractCreate2, +}; + +export const contractCreate2TronBinding = (svc: TronContractService): FamilyBinding => ({ + run: async (_ctx, _net, input) => { + let code = input.code; + if (input.codeFile) { + try { + code = await readFile(input.codeFile, "utf8"); + } catch (error) { + const codeValue = (error as NodeJS.ErrnoException).code; + if (codeValue === "ENOENT") + throw new UsageError("file_not_found", `code file not found: ${input.codeFile}`); + throw new UsageError("invalid_value", `cannot read code file: ${input.codeFile}`); + } + } + return svc.create2(input.deployer, code!, input.salt); + }, +}); diff --git a/ts/src/adapters/inbound/cli/commands/encoding.ts b/ts/src/adapters/inbound/cli/commands/encoding.ts index 014e5954e..4a020faca 100644 --- a/ts/src/adapters/inbound/cli/commands/encoding.ts +++ b/ts/src/adapters/inbound/cli/commands/encoding.ts @@ -9,8 +9,13 @@ export function registerEncodingCommands( service: EncodingService, ): void { const fields = z.object({ - input: z.string().min(1).max(2 * 1024 * 1024) - .describe("value to convert: TRON base58 / hex address, EVM 0x address, public key, hex, or Base64"), + input: z + .string() + .min(1) + .max(2 * 1024 * 1024) + .describe( + "value to convert: TRON base58 / hex address, EVM 0x address, public key, hex, or Base64", + ), }); registry.add({ path: ["encoding", "convert"], @@ -18,8 +23,7 @@ export function registerEncodingCommands( wallet: "none", auth: "none", positionals: [{ field: "input" }], - summary: - "Convert and validate address, hex, Base64, and Base58Check encodings", + summary: "Convert and validate address, hex, Base64, and Base58Check encodings", description: "Auto-detect an address/public-key or generic encoding and print all equivalent forms. Runs locally; 32-byte private-key-shaped values are rejected from argv.", fields, @@ -29,7 +33,6 @@ export function registerEncodingCommands( { cmd: "wallet-cli encoding convert deadbeef0102" }, ], formatText: TextFormatters.encodingConvert, - run: async (_context, _network, input) => - service.convert(input.input), + run: async (_context, _network, input) => service.convert(input.input), } satisfies CommandDefinition); } diff --git a/ts/src/adapters/inbound/cli/commands/exchange.ts b/ts/src/adapters/inbound/cli/commands/exchange.ts new file mode 100644 index 000000000..ec50c6352 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/exchange.ts @@ -0,0 +1,251 @@ +/** + * `exchange` — TRON's protocol-level Bancor market maker for TRX and TRC10. + * + * Four facts that run against intuition, and shape every command here: + * - only the pair's creator may inject or withdraw; this is private market-making, not a pool + * anyone can join, and the binding cannot be transferred; + * - TRX's on-chain token id is `_`; we accept `TRX`, `_` or a numeric TRC10 id; + * - `--min-received` is a floor that reverts the trade, not an expected return; + * - the protocol takes no fee — only `create` costs anything beyond bandwidth. + * + * Deviations from the v4.12.0 spec are recorded in + * docs/asset-exchange-spec-deviations-v4.12.0.md. + */ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { TronExchangeService } from "../../../../application/use-cases/tron/exchange-service.js"; +import { txModeFields } from "./shared.js"; +import { TextFormatters } from "../render/index.js"; + +const NO_NAMES = + "Tokens are named by id only — TRX or a numeric TRC10 id. A TRC10 name may contain ':', which " + + "would make a pair flag ambiguous; find an id with 'asset info '."; + +const exchangeId = z.coerce.number().int().min(0).describe("exchange pair id"); +const tokenField = (what: string) => z.string().min(1).describe(`${what}: TRX or a TRC10 id`); +const amountFields = (side: string) => ({ + amount: z.string().min(1).optional().describe(`${side}, in whole tokens`), + rawAmount: z.string().regex(/^\d+$/).optional().describe(`${side}, in minimal units`), +}); + +export const exchangeCreateSpec: ChainSpec = { + path: ["exchange", "create"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "exchange.create", + summary: "Create a Bancor pair and seed both sides", + description: + "Create a Bancor exchange pair and seed it with liquidity on both sides.\n\n" + + "IRREVERSIBLE in one respect: the creator is the ONLY account that can ever\n" + + "inject or withdraw liquidity for this pair, and that binding cannot be moved to\n" + + "another account. The creation fee is burned, and both initial amounts leave your\n" + + "account on top of it.\n\n" + + "Either side may be TRX or a TRC10 id; the two must differ. The ratio of the two\n" + + "initial amounts is the pair's starting price. Sides keep the order you type.\n\n" + + NO_NAMES, + requires: ["an account with enough TRX for the fee and enough of both tokens"], + exclusive: [{ label: "how to size both sides", flags: ["amounts", "raw-amounts"] }], + baseFields: z.object({ + pair: z.string().min(1).describe("the two sides as :, TRX or a TRC10 id"), + amounts: z + .string() + .min(1) + .optional() + .describe("amount for each side as :, in whole tokens"), + rawAmounts: z + .string() + .min(1) + .optional() + .describe("amount for each side as :, in minimal units"), + ...txModeFields, + }), + examples: [ + { cmd: "wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 --wait" }, + ], + formatText: TextFormatters.txReceipt, +}; + +export const exchangeInjectSpec: ChainSpec = { + path: ["exchange", "inject"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "exchange.inject", + summary: "Add liquidity to a pair you created", + description: + "Add liquidity to an exchange pair, in proportion to its current reserves.\n\n" + + "You name one side and its amount; the other side is computed from the current\n" + + "ratio and debited as well, so you need enough of BOTH tokens. Only the account\n" + + "that created the pair can do this.\n\n" + + NO_NAMES, + requires: ["the account that created the pair, holding enough of both tokens"], + positionals: [{ field: "id" }], + exclusive: [{ label: "how to size the amount", flags: ["amount", "raw-amount"] }], + baseFields: z.object({ + id: exchangeId, + token: tokenField("the side you are specifying"), + ...amountFields("amount for that side; the other side follows the ratio"), + ...txModeFields, + }), + examples: [{ cmd: "wallet-cli exchange inject 12 --token TRX --amount 1000 --wait" }], + formatText: TextFormatters.txReceipt, +}; + +export const exchangeWithdrawSpec: ChainSpec = { + path: ["exchange", "withdraw"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "exchange.withdraw", + summary: "Take liquidity out of a pair you created", + description: + "Take liquidity out of an exchange pair, in proportion to its current reserves.\n\n" + + "You name one side and its amount; the other side follows the ratio and is\n" + + "returned as well. Only the account that created the pair can do this.\n\n" + + "Amounts that do not divide cleanly by the reserve ratio are rejected on chain\n" + + "for lack of precision (the quotient must be exact to within 0.01%) — round the\n" + + "amount and try again.\n\n" + + NO_NAMES, + requires: ["the account that created the pair"], + positionals: [{ field: "id" }], + exclusive: [{ label: "how to size the amount", flags: ["amount", "raw-amount"] }], + baseFields: z.object({ + id: exchangeId, + token: tokenField("the side you are specifying"), + ...amountFields("amount for that side; the other side follows the ratio"), + ...txModeFields, + }), + examples: [{ cmd: "wallet-cli exchange withdraw 12 --token TRX --amount 1000 --wait" }], + formatText: TextFormatters.txReceipt, +}; + +export const exchangeTradeSpec: ChainSpec = { + path: ["exchange", "trade"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "exchange.trade", + summary: "Swap one side of a pair for the other", + description: + "Swap one side of an exchange pair for the other, priced by the Bancor curve —\n" + + "settles immediately, no counterparty, anyone may trade. The protocol takes no\n" + + "fee; only bandwidth is spent.\n\n" + + "--min-received is a FLOOR, not an expected return: if the trade would return\n" + + "less, it reverts and you lose only the bandwidth. --slippage derives that floor\n" + + "from the reserves at build time, less the percentage you give.\n\n" + + "WITH NEITHER FLAG THERE IS NO SLIPPAGE PROTECTION: the trade accepts any\n" + + "non-zero return at any price, and the response carries a warning saying so.\n\n" + + NO_NAMES, + requires: ["an account holding enough of the token being sold"], + positionals: [{ field: "id" }], + exclusive: [ + { label: "how to size the amount", flags: ["amount", "raw-amount"] }, + { + label: "slippage protection (omit for none)", + flags: ["min-received", "raw-min-received", "slippage"], + select: "at-most-one", + }, + ], + baseFields: z.object({ + id: exchangeId, + sell: tokenField("the side you are selling"), + ...amountFields("how much to sell"), + minReceived: z + .string() + .min(1) + .optional() + .describe("lowest acceptable return, in whole tokens; below this the trade reverts"), + rawMinReceived: z + .string() + .regex(/^\d+$/) + .optional() + .describe("lowest acceptable return, in minimal units"), + slippage: z.coerce + .number() + .gt(0) + .lt(100) + .optional() + .describe("derive the floor from current reserves, less this percentage"), + ...txModeFields, + }), + examples: [ + { cmd: "wallet-cli exchange trade 12 --sell TRX --amount 100 --slippage 1 --wait" }, + { cmd: "wallet-cli exchange trade 12 --sell TRX --amount 100 --min-received 4900 --wait" }, + { + cmd: "wallet-cli exchange trade 12 --sell TRX --amount 100 --slippage 1 --dry-run", + note: "price it first", + }, + ], + formatText: TextFormatters.txReceipt, +}; + +export const exchangeShowSpec: ChainSpec = { + path: ["exchange", "show"], + network: "optional", + wallet: "none", + auth: "none", + capability: "exchange.show", + summary: "Show one exchange pair", + description: + "Show one exchange pair: creator, creation time, and both tokens with their\n" + + "reserves in whole tokens.\n\n" + + "No price is shown. The reserve ratio is only a quoted rate, not what a real\n" + + "trade returns — any trade with size moves along the curve and gets less. Price a\n" + + "specific amount with 'exchange trade --dry-run'.", + positionals: [{ field: "id" }], + baseFields: z.object({ id: exchangeId }), + examples: [{ cmd: "wallet-cli exchange show 12" }], + formatText: TextFormatters.exchangeShow, +}; + +export const exchangeListSpec: ChainSpec = { + path: ["exchange", "list"], + network: "optional", + wallet: "none", + auth: "none", + capability: "exchange.list", + summary: "List exchange pairs, one page at a time", + description: + "List exchange pairs with their two token ids, reserves and creator.\n\n" + + "This is one RPC per call and never looks tokens up, so reserves are shown in\n" + + "MINIMAL UNITS and tokens by id — the record carries no name or precision. Use\n" + + "'exchange show' for one pair in whole tokens.\n\n" + + "No total is reported: the chain does not return one without transferring every\n" + + "record. Page until you get a short page.", + baseFields: z.object({ + limit: z.coerce.number().int().positive().max(1000).default(10).describe("max pairs to return"), + offset: z.coerce.number().int().min(0).default(0).describe("pagination offset"), + }), + examples: [ + { cmd: "wallet-cli exchange list" }, + { cmd: "wallet-cli exchange list --limit 50 --offset 50" }, + ], + formatText: TextFormatters.exchangeList, +}; + +export function exchangeDefinitions( + svc: TronExchangeService, +): Array<{ spec: ChainSpec; binding: FamilyBinding }> { + return [ + { + spec: exchangeCreateSpec, + binding: { run: (ctx, net, input) => svc.create(ctx, net, input) }, + }, + { + spec: exchangeInjectSpec, + binding: { run: (ctx, net, input) => svc.inject(ctx, net, input) }, + }, + { + spec: exchangeWithdrawSpec, + binding: { run: (ctx, net, input) => svc.withdraw(ctx, net, input) }, + }, + { spec: exchangeTradeSpec, binding: { run: (ctx, net, input) => svc.trade(ctx, net, input) } }, + { spec: exchangeShowSpec, binding: { run: (_ctx, net, input) => svc.show(net, input) } }, + { spec: exchangeListSpec, binding: { run: (_ctx, net, input) => svc.list(net, input) } }, + ]; +} diff --git a/ts/src/adapters/inbound/cli/commands/gasfree.ts b/ts/src/adapters/inbound/cli/commands/gasfree.ts index 068d42b2f..95274b983 100644 --- a/ts/src/adapters/inbound/cli/commands/gasfree.ts +++ b/ts/src/adapters/inbound/cli/commands/gasfree.ts @@ -9,9 +9,7 @@ export const gasFreeInfoSpec: ChainSpec = { wallet: "optional", auth: "none", capability: "gasfree.info", - requires: [ - "config gasfreeApiKey / gasfreeApiSecret", - ], + requires: ["config gasfreeApiKey / gasfreeApiSecret"], summary: "Show GasFree address, activation status, nonce, balances, and fees", description: "Show this account's GasFree address, activation status, nonce, supported tokens, balances, and current token-denominated fees.", @@ -20,25 +18,30 @@ export const gasFreeInfoSpec: ChainSpec = { formatText: TextFormatters.gasFreeInfo, }; -export const gasFreeInfoTronBinding = ( - service: GasFreeService, -): FamilyBinding => ({ +export const gasFreeInfoTronBinding = (service: GasFreeService): FamilyBinding => ({ run: async (context, network) => service.info(context, network), }); const transferFields = z.object({ - to: z.string().trim().min(1).max(128) - .describe("recipient TRON address or local contact name"), - amount: z.string().regex(/^\d+(\.\d+)?$/, "must be a positive decimal amount") - .refine( - (value) => !/^0+(\.0+)?$/.test(value), - "must be greater than zero", - ) + to: z.string().trim().min(1).max(128).describe("recipient TRON address or local contact name"), + amount: z + .string() + .regex(/^\d+(\.\d+)?$/, "must be a positive decimal amount") + .refine((value) => !/^0+(\.0+)?$/.test(value), "must be greater than zero") .describe("human token amount to transfer, in token units"), - token: z.string().trim().min(1).max(32).default("USDT") + token: z + .string() + .trim() + .min(1) + .max(32) + .default("USDT") .describe("token symbol supported by the GasFree provider"), - dryRun: z.boolean().default(false) - .describe("check token balance and the fee breakdown without unlocking, signing, or submitting"), + dryRun: z + .boolean() + .default(false) + .describe( + "check token balance and the fee breakdown without unlocking, signing, or submitting", + ), }); export const gasFreeTransferSpec: ChainSpec = { @@ -48,9 +51,7 @@ export const gasFreeTransferSpec: ChainSpec = { auth: "conditional", broadcasts: true, capability: "gasfree.transfer", - requires: [ - "config gasfreeApiKey / gasfreeApiSecret", - ], + requires: ["config gasfreeApiKey / gasfreeApiSecret"], summary: "Sign and submit a TIP-712 GasFree token transfer", description: "Sign a GasFree PermitTransfer and submit it to the provider. No TRX is needed; --dry-run checks the token balance and fee breakdown without unlocking or signing.", @@ -75,15 +76,16 @@ export const gasFreeTransferSpec: ChainSpec = { formatText: TextFormatters.gasFreeTransfer, }; -export const gasFreeTransferTronBinding = ( - service: GasFreeService, -): FamilyBinding => ({ - run: async (context, network, input) => - service.transfer(context, network, input), +export const gasFreeTransferTronBinding = (service: GasFreeService): FamilyBinding => ({ + run: async (context, network, input) => service.transfer(context, network, input), }); const traceFields = z.object({ - traceId: z.string().trim().min(1).max(128) + traceId: z + .string() + .trim() + .min(1) + .max(128) .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/) .describe("trace id returned by `gasfree transfer`"), }); @@ -94,9 +96,7 @@ export const gasFreeTraceSpec: ChainSpec = { wallet: "none", auth: "none", capability: "gasfree.trace", - requires: [ - "config gasfreeApiKey / gasfreeApiSecret", - ], + requires: ["config gasfreeApiKey / gasfreeApiSecret"], positionals: [{ field: "traceId", placeholder: "traceId" }], summary: "Track a GasFree transfer by provider trace id", description: @@ -110,9 +110,6 @@ export const gasFreeTraceSpec: ChainSpec = { formatText: TextFormatters.gasFreeTrace, }; -export const gasFreeTraceTronBinding = ( - service: GasFreeService, -): FamilyBinding => ({ - run: async (_context, network, input) => - service.trace(network, input.traceId), +export const gasFreeTraceTronBinding = (service: GasFreeService): FamilyBinding => ({ + run: async (_context, network, input) => service.trace(network, input.traceId), }); diff --git a/ts/src/adapters/inbound/cli/commands/message.sign.test.ts b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts index b266a4b18..cb468ac1a 100644 --- a/ts/src/adapters/inbound/cli/commands/message.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/message.sign.test.ts @@ -13,7 +13,8 @@ describe("message sign exclusive group", () => { }); it("states the constraint once — in the group, not also in the field description", () => { - const description = (messageSignSpec.baseFields.shape.message as { description?: string }).description ?? ""; + const description = + (messageSignSpec.baseFields.shape.message as { description?: string }).description ?? ""; expect(description).not.toMatch(/exactly one|OR --message-stdin/i); expect(description).toBeTruthy(); }); @@ -30,7 +31,9 @@ describe("message sign exclusive group", () => { activeAccount: "main", secrets: { pick: (inline: string | undefined) => inline ?? "from-stdin" }, } as never; - await messageSignBinding(service as never).run(ctx, { family: "tron" } as never, { message: "hello" }); + await messageSignBinding(service as never).run(ctx, { family: "tron" } as never, { + message: "hello", + }); expect(received).toBe("hello"); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/network.ts b/ts/src/adapters/inbound/cli/commands/network.ts index 14f7f2a59..16a292e23 100644 --- a/ts/src/adapters/inbound/cli/commands/network.ts +++ b/ts/src/adapters/inbound/cli/commands/network.ts @@ -11,13 +11,21 @@ export function registerNetworkCommands(reg: CommandRegistry): void { // ── networks ──────────────────────────────────────────────────────────────── reg.add({ - path: ["networks"], network: "none", wallet: "none", auth: "none", - summary: "List known networks", fields: empty, input: empty, + path: ["networks"], + network: "none", + wallet: "none", + auth: "none", + summary: "List known networks", + fields: empty, + input: empty, examples: [{ cmd: "wallet-cli networks" }], formatText: TextFormatters.networks, run: async (ctx) => ctx.networkRegistry.all().map((n) => ({ - id: n.id, family: n.family, chainId: n.chainId, feeModel: n.feeModel, + id: n.id, + family: n.family, + chainId: n.chainId, + feeModel: n.feeModel, })), } satisfies CommandDefinition); } diff --git a/ts/src/adapters/inbound/cli/commands/permission.ts b/ts/src/adapters/inbound/cli/commands/permission.ts index d8f689f02..8f17329b5 100644 --- a/ts/src/adapters/inbound/cli/commands/permission.ts +++ b/ts/src/adapters/inbound/cli/commands/permission.ts @@ -16,7 +16,8 @@ export const permissionShowSpec: ChainSpec = { auth: "none", capability: "permission.read", summary: "Show owner, witness, and active permission groups", - description: "Show thresholds, authorized keys, and decoded operation bitmaps. --account may be a local account or any activated TRON address.", + description: + "Show thresholds, authorized keys, and decoded operation bitmaps. --account may be a local account or any activated TRON address.", baseFields: showFields, examples: [ { cmd: "wallet-cli permission show" }, @@ -28,8 +29,14 @@ export const permissionShowSpec: ChainSpec = { const updateFields = z.object({ file: z.string().min(1).optional().describe("complete replacement permission JSON file"), json: z.string().min(1).optional().describe("inline complete replacement permission JSON"), - dryRun: z.boolean().default(false).describe("validate, build, and estimate without signing or broadcasting"), - signOnly: z.boolean().default(false).describe("build and sign, then output complete transaction hex"), + dryRun: z + .boolean() + .default(false) + .describe("validate, build, and estimate without signing or broadcasting"), + signOnly: z + .boolean() + .default(false) + .describe("build and sign, then output complete transaction hex"), buildOnly: txModeFields.buildOnly, // dry-run/sign-only wording is specific to a permission replacement, but the permission group // and expiration semantics are the shared ones — reuse them rather than keep a second copy. @@ -46,21 +53,33 @@ export const permissionUpdateSpec: ChainSpec = { capability: "permission.update", summary: "Replace the complete account permission structure", description: - "Replaces owner/witness/active permissions in one AccountPermissionUpdateContract. The input is\n" - + "the complete structure, in the same shape as `permission show -o json` data.\n" - + "There is no confirmation prompt: it warns about a permanent lockout but does not block the\n" - + "submission, so rehearse with --dry-run first.", + "Replaces owner/witness/active permissions in one AccountPermissionUpdateContract. The input is\n" + + "the complete structure, in the same shape as `permission show -o json` data.\n" + + "There is no confirmation prompt: it warns about a permanent lockout but does not block the\n" + + "submission, so rehearse with --dry-run first.", baseFields: updateFields, exclusive: [{ label: "the new permission structure", flags: ["file", "json"] }], baseRefine: (input, context) => { if ([input.file, input.json].filter((value) => value !== undefined).length !== 1) { - context.addIssue({ code: "custom", path: ["file"], message: "provide exactly one of --file or --json" }); + context.addIssue({ + code: "custom", + path: ["file"], + message: "provide exactly one of --file or --json", + }); } if ([input.dryRun, input.signOnly, input.buildOnly].filter(Boolean).length > 1) { - context.addIssue({ code: "custom", path: ["dryRun"], message: "choose at most one of --dry-run, --sign-only, --build-only" }); + context.addIssue({ + code: "custom", + path: ["dryRun"], + message: "choose at most one of --dry-run, --sign-only, --build-only", + }); } if (input.expiration !== undefined && !input.signOnly && !input.buildOnly) { - context.addIssue({ code: "custom", path: ["expiration"], message: "--expiration is only valid with --sign-only or --build-only" }); + context.addIssue({ + code: "custom", + path: ["expiration"], + message: "--expiration is only valid with --sign-only or --build-only", + }); } }, examples: [ @@ -98,7 +117,9 @@ function normalizeLossless(value: unknown): unknown { } if (Array.isArray(value)) return value.map(normalizeLossless); if (value && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, normalizeLossless(entry)])); + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, normalizeLossless(entry)]), + ); } return value; } diff --git a/ts/src/adapters/inbound/cli/commands/proposal.ts b/ts/src/adapters/inbound/cli/commands/proposal.ts new file mode 100644 index 000000000..07b49e097 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/proposal.ts @@ -0,0 +1,142 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { TronProposalService } from "../../../../application/use-cases/tron/proposal-service.js"; +import { ciEnum } from "../arity/index.js"; +import { governanceTxModeFields, governanceTxRefine } from "./shared.js"; +import { TextFormatters } from "../render/index.js"; + +export const proposalListSpec: ChainSpec = { + path: ["proposal", "list"], + network: "optional", + wallet: "none", + auth: "none", + capability: "proposal.read", + summary: "List on-chain governance proposals", + description: + "List governance proposals. Each proposal is a set of chain parameters with the values\n" + + "it would set, for super representatives to vote on. Parameters are shown by name; the\n" + + "value column is what the proposal sets, not the current value on chain — see\n" + + "'chain params' for those. Active proposals are shown by default.", + baseFields: z.object({ + state: ciEnum(["active", "all"]) + .default("active") + .describe("active voting proposals, or all proposal history"), + limit: z.coerce.number().int().positive().optional().describe("maximum proposals to return"), + offset: z.coerce.number().int().min(0).default(0).describe("pagination offset"), + }), + examples: [ + { cmd: "wallet-cli proposal list" }, + { cmd: "wallet-cli proposal list --state all --limit 50" }, + ], + formatText: TextFormatters.proposalList, +}; + +export const proposalListTronBinding = (service: TronProposalService): FamilyBinding => ({ + run: async (_ctx, net, input) => service.list(net, input), +}); + +export const proposalShowSpec: ChainSpec = { + path: ["proposal", "show"], + network: "optional", + wallet: "none", + auth: "none", + capability: "proposal.read", + positionals: [{ field: "id" }], + summary: "Show one governance proposal", + description: + "Show a single proposal in full: each parameter it sets (name, value, unit), approval\n" + + "progress, proposer, and voting-window timestamps. The addresses that approved are in\n" + + "the json output only.\n" + + "\n" + + "The value shown is the one the proposal sets, not the current value on chain — a\n" + + "proposal does not record what the parameter was. Use 'chain params' for the values in\n" + + "effect now.", + baseFields: z.object({ + id: z.coerce.number().int().positive().describe("proposal id"), + }), + examples: [{ cmd: "wallet-cli proposal show 47" }], + formatText: TextFormatters.proposalShow, +}; + +export const proposalShowTronBinding = (service: TronProposalService): FamilyBinding => ({ + run: async (_ctx, net, input) => service.show(net, input.id), +}); + +const proposalWriteBase = { + network: "optional" as const, + wallet: "optional" as const, + auth: "required" as const, + broadcasts: true, + capability: "proposal.write", + baseRefine: governanceTxRefine, + formatText: TextFormatters.governanceReceipt, +}; + +export const proposalCreateSpec: ChainSpec = { + path: ["proposal", "create"], + ...proposalWriteBase, + summary: "Create a chain-parameter proposal", + description: + "Create a proposal containing one or more chain-parameter changes. Only registered\n" + + "witnesses can create proposals; --set accepts the chain-parameter name or numeric id.", + requires: ["a registered witness account"], + baseFields: z.object({ + set: z + .array(z.string().min(3)) + .min(1) + .describe("=; repeatable; duplicate ids use the last value"), + ...governanceTxModeFields, + }), + examples: [ + { cmd: "wallet-cli proposal create --set getTransactionFee=15 --wait" }, + { + cmd: "wallet-cli proposal create --set getTransactionFee=15 --set getCreateAccountFee=200000 --wait", + }, + ], +}; + +export const proposalCreateTronBinding = (service: TronProposalService): FamilyBinding => ({ + run: async (ctx, net, input) => service.create(ctx, net, input), +}); + +export const proposalApproveSpec: ChainSpec = { + path: ["proposal", "approve"], + ...proposalWriteBase, + positionals: [{ field: "id" }], + summary: "Approve or un-approve a proposal", + description: + "Approve a proposal; --cancel removes your approval. TRON has approval/un-approval only,\n" + + "not an against vote. Only registered witnesses can submit this transaction.", + requires: ["a registered witness account"], + baseFields: z.object({ + id: z.coerce.number().int().positive().describe("proposal id"), + cancel: z.boolean().default(false).describe("remove this witness's existing approval"), + ...governanceTxModeFields, + }), + examples: [ + { cmd: "wallet-cli proposal approve 47" }, + { cmd: "wallet-cli proposal approve 47 --cancel" }, + ], +}; + +export const proposalApproveTronBinding = (service: TronProposalService): FamilyBinding => ({ + run: async (ctx, net, input) => service.approve(ctx, net, input), +}); + +export const proposalDeleteSpec: ChainSpec = { + path: ["proposal", "delete"], + ...proposalWriteBase, + positionals: [{ field: "id" }], + summary: "Delete a proposal during its voting window", + description: "Delete a proposal that you created while it is still in its voting window.", + requires: ["the proposal creator account"], + baseFields: z.object({ + id: z.coerce.number().int().positive().describe("proposal id"), + ...governanceTxModeFields, + }), + examples: [{ cmd: "wallet-cli proposal delete 48" }], +}; + +export const proposalDeleteTronBinding = (service: TronProposalService): FamilyBinding => ({ + run: async (ctx, net, input) => service.delete(ctx, net, input), +}); diff --git a/ts/src/adapters/inbound/cli/commands/reward.ts b/ts/src/adapters/inbound/cli/commands/reward.ts index 7cf79373d..da2236772 100644 --- a/ts/src/adapters/inbound/cli/commands/reward.ts +++ b/ts/src/adapters/inbound/cli/commands/reward.ts @@ -6,7 +6,9 @@ import { TextFormatters } from "../render/index.js"; export const rewardBalanceSpec: ChainSpec = { path: ["reward", "balance"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "reward.balance", summary: "Show claimable voting/block reward and withdraw status", description: @@ -24,7 +26,9 @@ export const rewardBalanceTronBinding = (svc: TronRewardService): FamilyBinding export const rewardWithdrawSpec: ChainSpec = { path: ["reward", "withdraw"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "reward.withdraw", summary: "Withdraw accrued voting/block rewards", @@ -32,10 +36,7 @@ export const rewardWithdrawSpec: ChainSpec = { "Withdraw accrued voting/block rewards into your available balance.\n" + "Rewards can be withdrawn at most once every 24 hours.", baseFields: z.object({ ...txModeFields }), - examples: [ - { cmd: "wallet-cli reward withdraw" }, - { cmd: "wallet-cli reward withdraw --wait" }, - ], + examples: [{ cmd: "wallet-cli reward withdraw" }, { cmd: "wallet-cli reward withdraw --wait" }], formatText: TextFormatters.txReceipt, }; diff --git a/ts/src/adapters/inbound/cli/commands/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts index 368d8c19d..0e9bdc731 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -12,24 +12,77 @@ import type { MessageService } from "../../../../application/use-cases/message-s // ── execution-mode flags shared by every signing command ───────────────────────── /** Transaction execution fields; default (no mode flag) = sign and broadcast on-chain. */ export const txModeFields = { - dryRun: z.boolean().default(false).describe("build and estimate only, with no signature and no broadcast"), - signOnly: z.boolean().default(false).describe("sign and output complete transaction hex without broadcasting"), + dryRun: z + .boolean() + .default(false) + .describe("build and estimate only, with no signature and no broadcast"), + signOnly: z + .boolean() + .default(false) + .describe("sign and output complete transaction hex without broadcasting"), // Both multi-sig routes start from this artifact: the hex relay (`tx sign --file --out`) and the // TronLink queue (`tx multisig --create`). Naming only one would read as "service path only". - buildOnly: z.boolean().default(false) - .describe("build and output unsigned complete transaction hex without unlocking; the entry point for multi-party signing (relay it with `tx sign`, or open a queue with `tx multisig --create`)"), - permissionId: z.coerce.number().int().min(0).max(9).default(0) + buildOnly: z + .boolean() + .default(false) + .describe( + "build and output unsigned complete transaction hex without unlocking; the entry point for multi-party signing (relay it with `tx sign`, or open a queue with `tx multisig --create`)", + ), + permissionId: z.coerce + .number() + .int() + .min(0) + .max(9) + .default(0) .describe("TRON permission group to sign with (0=owner, 1=witness, 2-9=active)"), // The 24h bound is the chain's, enforced by max() above; the omitted case is the node's own // ~60s, which is why extending it is the whole point of this flag when collecting signatures. - expiration: z.coerce.number().int().min(1).max(86_400_000).optional() - .describe("transaction expiration in ms, up to 86400000 (24h); only with --sign-only or --build-only; omitted = node default (~60s)"), + expiration: z.coerce + .number() + .int() + .min(1) + .max(86_400_000) + .optional() + .describe( + "transaction expiration in ms, up to 86400000 (24h); only with --sign-only or --build-only; omitted = node default (~60s)", + ), }; + +/** Full transaction controls required by governance/administrative writes. */ +export const governanceTxModeFields = { + ...txModeFields, + buildOnly: z + .boolean() + .default(false) + .describe( + "build an unsigned transaction without signing or broadcasting; mutually exclusive with --dry-run/--sign-only", + ), +}; + +export function governanceTxRefine( + value: { dryRun?: boolean; signOnly?: boolean; buildOnly?: boolean; expiration?: number }, + ctx: z.RefinementCtx, +): void { + if ([value.dryRun, value.signOnly, value.buildOnly].filter(Boolean).length > 1) { + ctx.addIssue({ + code: "custom", + message: "choose at most one of --dry-run, --sign-only, --build-only", + }); + } + if (value.expiration !== undefined && !value.signOnly && !value.buildOnly) { + ctx.addIssue({ + code: "custom", + path: ["expiration"], + message: "only valid with --sign-only or --build-only", + }); + } +} // ── unified --amount / --raw-amount selector (shared by every chain's `tx send`) ──── // A transfer of 0 is meaningless on any chain — reject it here (exit 2) rather than let the node // reject it with an opaque error. regex-based zero check (never BigInt): zod v4 keeps running // refinements after the regex fails, so a throwing check would escape safeParse. -const positiveDecimalAmount = z.string() +const positiveDecimalAmount = z + .string() .regex(/^\d+(\.\d+)?$/, "must be a non-negative decimal string") .refine((v) => !/^0+(\.0+)?$/.test(v), { message: "must be greater than zero" }); @@ -42,9 +95,17 @@ export function unifiedAmountFields(amountDesc: string, rawDesc: string) { } /** superRefine: exactly one of --amount or --raw-amount must be present. */ -export function amountSelector(v: { amount?: string; rawAmount?: string }, ctx: z.RefinementCtx): void { +export function amountSelector( + v: { amount?: string; rawAmount?: string }, + ctx: z.RefinementCtx, +): void { const n = [v.amount !== undefined, v.rawAmount !== undefined].filter(Boolean).length; - if (n !== 1) ctx.addIssue({ code: "custom", path: ["amount"], message: "provide exactly one of --amount or --raw-amount" }); + if (n !== 1) + ctx.addIssue({ + code: "custom", + path: ["amount"], + message: "provide exactly one of --amount or --raw-amount", + }); } const messageSignFields = z.object({ @@ -61,7 +122,9 @@ export const messageSignSpec: ChainSpec = { summary: "Sign an arbitrary message (TIP-191/V2 · EIP-191)", baseFields: messageSignFields, // SecretResolver.pick enforces this: both sources → invalid_option, neither → missing_option. - exclusive: [{ label: "the message to sign", flags: ["message", "message-stdin"], select: "exactly-one" }], + exclusive: [ + { label: "the message to sign", flags: ["message", "message-stdin"], select: "exactly-one" }, + ], examples: [{ cmd: `wallet-cli message sign --message "hello"` }], formatText: TextFormatters.messageSign, }; diff --git a/ts/src/adapters/inbound/cli/commands/stake.ts b/ts/src/adapters/inbound/cli/commands/stake.ts index faa82c78f..e7d3cb5a9 100644 --- a/ts/src/adapters/inbound/cli/commands/stake.ts +++ b/ts/src/adapters/inbound/cli/commands/stake.ts @@ -1,10 +1,6 @@ import { z } from "zod"; import type { NetworkDescriptor } from "../../../../domain/types/index.js"; -import type { - ChainSpec, - ExecutionContext, - FamilyBinding, -} from "../contracts/index.js"; +import type { ChainSpec, ExecutionContext, 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"; @@ -38,7 +34,9 @@ function stakeCommand( return { spec: { path: ["stake", action], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: options.capability ?? "staking.freeze", summary, @@ -52,7 +50,9 @@ function stakeCommand( }; } -export function stakeDefinitions(service: TronStakeService): Array<{ spec: ChainSpec; binding: FamilyBinding }> { +export function stakeDefinitions( + service: TronStakeService, +): Array<{ spec: ChainSpec; binding: FamilyBinding }> { return [ stakeCommand( "freeze", @@ -85,7 +85,9 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain { // The Ledger TRON app firmware rejects CancelAllUnfreezeV2Contract (APDU 0x6a80), // even with blind-signing enabled; software accounts sign it fine. - requires: ["a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type"], + requires: [ + "a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type", + ], }, ), stakeCommand( @@ -93,14 +95,19 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain "Delegate resource to another address (DelegateResourceV2)", (context, network, input) => service.delegate(context, network, input), { - amountSun: Schemas.positiveIntString() - .describe("staked-TRX amount backing the delegated resource, in SUN"), - receiver: Schemas.addressFor("tron") - .describe("TRON address receiving the delegated resource"), + amountSun: Schemas.positiveIntString().describe( + "staked-TRX amount backing the delegated resource, in SUN", + ), + receiver: Schemas.addressFor("tron").describe( + "TRON address receiving the delegated resource", + ), resource: resourceField("resource type to delegate or reclaim"), - lock: z.boolean().default(false) + lock: z + .boolean() + .default(false) .describe("lock the delegation and prevent early undelegation"), - lockPeriod: Schemas.positiveIntString().optional() + lockPeriod: Schemas.positiveIntString() + .optional() .describe("lock duration in blocks, approximately 3 seconds per block; requires --lock"), }, { @@ -121,10 +128,12 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain "Reclaim delegated resource (UnDelegateResourceV2)", (context, network, input) => service.undelegate(context, network, input), { - amountSun: Schemas.positiveIntString() - .describe("staked-TRX amount backing the resource to reclaim, in SUN"), - receiver: Schemas.addressFor("tron") - .describe("TRON address that previously received the delegated resource"), + amountSun: Schemas.positiveIntString().describe( + "staked-TRX amount backing the resource to reclaim, in SUN", + ), + receiver: Schemas.addressFor("tron").describe( + "TRON address that previously received the delegated resource", + ), resource: resourceField("resource type to delegate or reclaim"), }, { capability: "staking.delegate" }, @@ -132,13 +141,19 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain { spec: { path: ["stake", "info"], - network: "optional", wallet: "optional", auth: "none", - summary: "Staking & resource overview (staked / voting power / resource / unfreezing / withdrawable)", + network: "optional", + wallet: "optional", + auth: "none", + summary: + "Staking & resource overview (staked / voting power / resource / unfreezing / withdrawable)", description: "Staking & resource overview: staked amounts, voting power (TP), energy/bandwidth\n" + "usage, pending unstakes, currently withdrawable TRX, and available unfreeze slots.", baseFields: z.object({}), - examples: [{ cmd: "wallet-cli stake info" }, { cmd: "wallet-cli stake info --account main -o json" }], + 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) }, @@ -146,23 +161,32 @@ export function stakeDefinitions(service: TronStakeService): Array<{ spec: Chain { spec: { path: ["stake", "delegated"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", summary: "Delegation details and max delegatable size", description: "Delegation details (outbound/inbound) plus the maximum size you can still delegate.\n" + - "Outbound shows \"Locked until\" (you cannot reclaim before then); inbound shows\n" + - "\"Guaranteed until\" (the delegator cannot reclaim before then).", + 'Outbound shows "Locked until" (you cannot reclaim before then); inbound shows\n' + + '"Guaranteed until" (the delegator cannot reclaim before then).', baseFields: z.object({ - direction: ciEnum(["out", "in"]).default("out") + direction: ciEnum(["out", "in"]) + .default("out") .describe("out = delegated to others; in = delegated to me"), - resource: ciEnum(RESOURCES).optional() + resource: ciEnum(RESOURCES) + .optional() .describe("filter to a single resource type; omit to show both"), - to: Schemas.addressFor("tron").optional() + 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" }); + context.addIssue({ + code: "custom", + path: ["to"], + message: "--to only applies to --direction out", + }); } }, examples: [ 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 8d6447932..b71e0125a 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -3,7 +3,10 @@ import { CommandRegistry } from "../registry/index.js"; import { registerWalletCommands } from "./wallet.js"; import { registerConfigCommands } from "./config.js"; import { registerNetworkCommands } from "./network.js"; -import { registerTronChainCommands, type TronChainCommandDependencies } from "../../../../bootstrap/families/tron.js"; +import { + registerTronChainCommands, + type TronChainCommandDependencies, +} from "../../../../bootstrap/families/tron.js"; import { commandId } from "../command-id.js"; import { TextFormatters } from "../render/index.js"; import { introspectFields } from "../arity/index.js"; @@ -14,7 +17,10 @@ import { registerContactCommands } from "./contact.js"; import { registerAddressCommands } from "./address.js"; import { registerEncodingCommands } from "./encoding.js"; -const ctx = (over: Partial = {}): TextRenderContext => ({ command: "x", ...over }); +const ctx = (over: Partial = {}): TextRenderContext => ({ + command: "x", + ...over, +}); describe("text formatters", () => { it("every registered command has a command-owned text formatter", () => { @@ -27,8 +33,11 @@ describe("text formatters", () => { registerEncodingCommands(registry, {} as never); registerTronChainCommands(registry, {} as TronChainCommandDependencies); - const missing = registry.all() - .filter((cmd) => typeof (isChainCommand(cmd) ? cmd.spec.formatText : cmd.formatText) !== "function") + const missing = registry + .all() + .filter( + (cmd) => typeof (isChainCommand(cmd) ? cmd.spec.formatText : cmd.formatText) !== "function", + ) .map((cmd) => commandId(isChainCommand(cmd) ? { path: cmd.spec.path } : cmd)) .sort(); @@ -65,15 +74,27 @@ describe("text formatters", () => { describe("permissionShow formatter", () => { const view = { address: "Towner", - owner: { id: 0, name: "owner", threshold: 1, keys: [{ address: "Towner", weight: 1, local: "main" }] }, - actives: [{ - id: 2, name: "finance", threshold: 2, - keys: [{ address: "TQkX", weight: 1 }, { address: "TXe4", weight: 1, local: "cold" }], - operations: ["TransferContract"], - operationsHex: "7fff1fc0033e0100000000000000000000000000000000000000000000000000", - operationLabels: ["Transfer TRX"], - unknownOperationIds: [], - }], + owner: { + id: 0, + name: "owner", + threshold: 1, + keys: [{ address: "Towner", weight: 1, local: "main" }], + }, + actives: [ + { + id: 2, + name: "finance", + threshold: 2, + keys: [ + { address: "TQkX", weight: 1 }, + { address: "TXe4", weight: 1, local: "cold" }, + ], + operations: ["TransferContract"], + operationsHex: "7fff1fc0033e0100000000000000000000000000000000000000000000000000", + operationLabels: ["Transfer TRX"], + unknownOperationIds: [], + }, + ], } as any; // Doc §3.1.1 keeps operationsHex in json — it is a machine value, and the human column already @@ -89,22 +110,31 @@ describe("permissionShow formatter", () => { expect(out).toContain("Transfer TRX"); expect(out).toContain("(1 total)"); expect(out).toContain("(this wallet: cold)"); - expect(out).toContain('finance (id 2, active)'); + expect(out).toContain("finance (id 2, active)"); }); }); describe("accountBalance formatter", () => { it("converts native balance to the human coin amount using decimals + symbol", () => { - const out = TextFormatters.accountBalance({ address: "TXaddress", balance: "1983993000", decimals: 6, symbol: "TRX" }, ctx()); + const out = TextFormatters.accountBalance( + { address: "TXaddress", balance: "1983993000", decimals: 6, symbol: "TRX" }, + ctx(), + ); expect(out).toContain("1983.993 TRX"); expect(out).not.toContain("sun"); }); it("falls back to raw scalar balance when decimals are missing", () => { - const out = TextFormatters.accountBalance({ address: "TXaddress", balance: "1983993000" }, ctx()); + const out = TextFormatters.accountBalance( + { address: "TXaddress", balance: "1983993000" }, + ctx(), + ); expect(out).toContain("1983993000"); }); it("prefers the account label over the address when present", () => { - const out = TextFormatters.accountBalance({ address: "TXaddress", balance: "1", decimals: 6, symbol: "TRX" }, ctx({ accountLabel: "main" })); + const out = TextFormatters.accountBalance( + { address: "TXaddress", balance: "1", decimals: 6, symbol: "TRX" }, + ctx({ accountLabel: "main" }), + ); expect(out).toContain("main"); expect(out).not.toContain("TXaddress"); }); @@ -125,19 +155,20 @@ describe("walletCurrent formatter", () => { expect(out).toContain("Selected account: treasury"); expect(out).toContain("█▀█\n▀▄▀"); - expect(out).toMatch( - /█▀█\n▀▄▀\nReceive address\s+TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC/, - ); + expect(out).toMatch(/█▀█\n▀▄▀\nReceive address\s+TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC/); }); }); 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 stake = TextFormatters.stakeDelegated( + { + direction: "out", + canDelegateMaxSun: { energy: "1234456789", bandwidth: "0" }, + delegations: [], + }, + ctx(), + ); const chain = TextFormatters.chainPrices({ energy: { currentSunPerUnit: 210 }, bandwidth: { currentSunPerUnit: 1000 }, @@ -183,16 +214,31 @@ describe("stakeInfo unfreezing list", () => { 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()); + const out = TextFormatters.tokenBalance( + { + address: "TXaddress", + token: "TR7token", + balance: "1204560000", + symbol: "USDT", + decimals: 6, + }, + ctx(), + ); expect(out).toContain("1204.56"); expect(out).toContain("USDT"); }); it("falls back to raw scalar balance when metadata is missing", () => { - const out = TextFormatters.tokenBalance({ address: "TXaddress", token: "TR7token", balance: "1204560000" }, ctx()); + const out = TextFormatters.tokenBalance( + { address: "TXaddress", token: "TR7token", balance: "1204560000" }, + ctx(), + ); expect(out).toContain("1204560000"); }); it("prefers the account label over the address when present", () => { - const out = TextFormatters.tokenBalance({ address: "TXaddress", token: "t", balance: "1" }, ctx({ accountLabel: "main" })); + const out = TextFormatters.tokenBalance( + { address: "TXaddress", token: "t", balance: "1" }, + ctx({ accountLabel: "main" }), + ); expect(out).toContain("main"); expect(out).not.toContain("TXaddress"); }); @@ -201,8 +247,25 @@ describe("tokenBalance formatter", () => { describe("txReceipt formatter (typed kind, narrowed — no command-id matching)", () => { it("tx send submitted (default): pending receipt with txid + track hint, no fee/energy", () => { const out = TextFormatters.txReceipt( - { kind: "send", stage: "submitted", txId: "abc123", rawAmount: "5000000", token: "USDT", decimals: 6, to: "TrecipientAddress" }, - ctx({ net: { id: "tron:nile", family: "tron", chainId: "nile", feeModel: "tron-resource", aliases: [], capabilities: [] } }), + { + kind: "send", + stage: "submitted", + txId: "abc123", + rawAmount: "5000000", + token: "USDT", + decimals: 6, + to: "TrecipientAddress", + }, + ctx({ + net: { + id: "tron:nile", + family: "tron", + chainId: "nile", + feeModel: "tron-resource", + aliases: [], + capabilities: [], + }, + }), ); expect(out).toContain("⏳"); expect(out).toContain("Sent 5 USDT"); @@ -213,17 +276,39 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" expect(out).not.toContain("Fee"); }); it("tx send TRC20 via --contract --raw-amount (no symbol): never mislabels as TRX", () => { - const out = TextFormatters.txReceipt({ kind: "send", stage: "submitted", txId: "t20", rawAmount: "10000", contract: "TXYZtokenContract", to: "Tdest" }); + const out = TextFormatters.txReceipt({ + kind: "send", + stage: "submitted", + txId: "t20", + rawAmount: "10000", + contract: "TXYZtokenContract", + to: "Tdest", + }); expect(out).toContain("Sent 10000 TXYZtokenContract"); expect(out).not.toContain("TRX"); }); it("tx send TRC10 via --asset-id --raw-amount (no symbol): labels by asset id, not TRX", () => { - const out = TextFormatters.txReceipt({ kind: "send", stage: "submitted", txId: "t10", rawAmount: "500000", assetId: "1005416", to: "Tdest" }); + const out = TextFormatters.txReceipt({ + kind: "send", + stage: "submitted", + txId: "t10", + rawAmount: "500000", + assetId: "1005416", + to: "Tdest", + }); expect(out).toContain("Sent 500000 asset 1005416"); expect(out).not.toContain("TRX"); }); it("tx send confirmed (--wait): success receipt with real block + fee", () => { - const out = TextFormatters.txReceipt({ kind: "send", stage: "confirmed", txId: "abc", rawAmount: "1000000", to: "Tdest", blockNumber: 66000000, feeSun: "268000" }); + const out = TextFormatters.txReceipt({ + kind: "send", + stage: "confirmed", + txId: "abc", + rawAmount: "1000000", + to: "Tdest", + blockNumber: 66000000, + feeSun: "268000", + }); expect(out).toContain("✅"); expect(out).toContain("Sent 1 TRX"); expect(out).toContain("#66,000,000"); @@ -232,15 +317,30 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("confirmed receipt preserves legitimate zero-valued chain fields", () => { const out = TextFormatters.txReceipt({ - kind: "send", stage: "confirmed", txId: "zero", - rawAmount: "0", to: "Tdest", blockNumber: 0, energyUsed: 0, feeSun: 0, + kind: "send", + stage: "confirmed", + txId: "zero", + rawAmount: "0", + to: "Tdest", + blockNumber: 0, + energyUsed: 0, + feeSun: 0, }); expect(out).toContain("#0"); expect(out).toMatch(/Energy\s+0/); expect(out).toContain("0 TRX"); }); it("contract send failed (--wait): failure receipt with reason", () => { - const out = TextFormatters.txReceipt({ kind: "contract-send", stage: "failed", txId: "abc", method: "transfer(address,uint256)", contract: "TR7contract", result: "OUT_OF_ENERGY", blockNumber: 1, failed: true }); + const out = TextFormatters.txReceipt({ + kind: "contract-send", + stage: "failed", + txId: "abc", + method: "transfer(address,uint256)", + contract: "TR7contract", + result: "OUT_OF_ENERGY", + blockNumber: 1, + failed: true, + }); expect(out).toContain("❌"); expect(out).toContain("Called transfer"); expect(out).toContain("TR7contract"); @@ -248,8 +348,22 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("contract deploy submitted: renders populated Address row", () => { const out = TextFormatters.txReceipt( - { kind: "contract-deploy", stage: "submitted", txId: "dep1", contractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" }, - ctx({ net: { id: "tron:nile", family: "tron", chainId: "nile", feeModel: "tron-resource", aliases: [], capabilities: [] } }), + { + kind: "contract-deploy", + stage: "submitted", + txId: "dep1", + contractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + }, + ctx({ + net: { + id: "tron:nile", + family: "tron", + chainId: "nile", + feeModel: "tron-resource", + aliases: [], + capabilities: [], + }, + }), ); expect(out).toContain("Contract deployed"); expect(out).toContain("Address"); @@ -257,9 +371,13 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("dry-run with an energy estimate (TRC20/contract): renders energy, never [object Object]", () => { const out = TextFormatters.txReceipt({ - kind: "send", mode: "dry-run", + kind: "send", + mode: "dry-run", fee: { feeModel: "tron-resource", energy: 29650, availableEnergy: 133440569 } as any, - tx: { txID: "deadbeef" } as any, rawAmount: "10000", contract: "TXYZtoken", to: "Tdest", + tx: { txID: "deadbeef" } as any, + rawAmount: "10000", + contract: "TXYZtoken", + to: "Tdest", } as any); expect(out).toContain("Dry run"); expect(out).not.toContain("[object Object]"); @@ -268,9 +386,13 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("dry-run energy estimate with insufficient available energy: no 'covered' note", () => { const out = TextFormatters.txReceipt({ - kind: "send", mode: "dry-run", + kind: "send", + mode: "dry-run", fee: { feeModel: "tron-resource", energy: 29650, availableEnergy: 100 } as any, - tx: { txID: "deadbeef" } as any, rawAmount: "10000", contract: "TXYZtoken", to: "Tdest", + tx: { txID: "deadbeef" } as any, + rawAmount: "10000", + contract: "TXYZtoken", + to: "Tdest", } as any); expect(out).toContain("29,650 energy"); expect(out).not.toContain("covered by staked energy"); @@ -285,10 +407,15 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" minimumFeeSun: "1100000", balanceSun: "1862126000", }; - const dryRun = (fee: unknown) => TextFormatters.txReceipt({ - kind: "account-activate", mode: "dry-run", - fee, tx: { txID: "cc0a6f68" }, address: "TEF2CvkixrkzwbreCRFCQ7sZGj9AVFAkQq", payer: "TMSgJxtPw29", - } as any) as string; + const dryRun = (fee: unknown) => + TextFormatters.txReceipt({ + kind: "account-activate", + mode: "dry-run", + fee, + tx: { txID: "cc0a6f68" }, + address: "TEF2CvkixrkzwbreCRFCQ7sZGj9AVFAkQq", + payer: "TMSgJxtPw29", + } as any) as string; it("account activate dry-run: renders the total creation fee, not [object Object]", () => { const out = dryRun(activateFee); @@ -339,17 +466,31 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" // multi-sign fee was non-zero (receiptRows pushed one row, the dry-run branch another). The QA // pass missed the duplicate because its sample fee was 0, which is falsy. const broadcastApproval = { - txId: "abc123", contractType: "TransferContract", operation: "Transfer TRX", - from: "Towner", to: "Trecipient", rawAmount: "1000000", + txId: "abc123", + contractType: "TransferContract", + operation: "Transfer TRX", + from: "Towner", + to: "Trecipient", + rawAmount: "1000000", permission: { id: 2, name: "finance", threshold: 2 }, - currentWeight: 2, missingWeight: 0, thresholdReached: true, - approved: [{ address: "TQkX", weight: 1 }, { address: "TXe4", weight: 1 }], - expiration: 1784388720000, expired: false, signatures: 2, + currentWeight: 2, + missingWeight: 0, + thresholdReached: true, + approved: [ + { address: "TQkX", weight: 1 }, + { address: "TXe4", weight: 1 }, + ], + expiration: 1784388720000, + expired: false, + signatures: 2, }; it("broadcast dry-run: projects the permission and approval block json already carries", () => { const out = TextFormatters.txReceipt({ - kind: "broadcast", mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: 1000000, + kind: "broadcast", + mode: "dry-run", + transaction: broadcastApproval, + multiSignFeeSun: 1000000, } as any) as string; expect(out).toContain("Dry run tx broadcast"); expect(out).toContain('Permission active "finance" (id 2) threshold 2'); @@ -360,7 +501,10 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" it("broadcast dry-run: identifies the transaction instead of leaving an empty Tx row", () => { const out = TextFormatters.txReceipt({ - kind: "broadcast", mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: 0, + kind: "broadcast", + mode: "dry-run", + transaction: broadcastApproval, + multiSignFeeSun: 0, } as any) as string; expect(out).toContain("abc123"); }); @@ -370,7 +514,10 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" ["zero multi-sign fee", 0, "0 TRX"], ])("broadcast dry-run: states the multi-sign fee exactly once (%s)", (_n, fee, expected) => { const out = TextFormatters.txReceipt({ - kind: "broadcast", mode: "dry-run", transaction: broadcastApproval, multiSignFeeSun: fee, + kind: "broadcast", + mode: "dry-run", + transaction: broadcastApproval, + multiSignFeeSun: fee, } as any) as string; expect(out.match(/multi-sign fee/gi) ?? []).toHaveLength(1); expect(out).toContain(expected); @@ -378,8 +525,11 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" it("broadcast submitted: keeps txid, status and the tracking hint, and does not duplicate the fee", () => { const out = TextFormatters.txReceipt({ - kind: "broadcast", stage: "submitted", txId: "abc123", - transaction: broadcastApproval, multiSignFeeSun: 1000000, + kind: "broadcast", + stage: "submitted", + txId: "abc123", + transaction: broadcastApproval, + multiSignFeeSun: 1000000, } as any) as string; expect(out).toContain("abc123"); expect(out).toContain("pending — not yet on-chain"); @@ -388,7 +538,13 @@ describe("txReceipt formatter (typed kind, narrowed — no command-id matching)" }); it("stake freeze submitted: renders staked amount and resource", () => { - const out = TextFormatters.txReceipt({ kind: "stake-freeze", stage: "submitted", txId: "abc", amountSun: "2000000", resource: "energy" }); + const out = TextFormatters.txReceipt({ + kind: "stake-freeze", + stage: "submitted", + txId: "abc", + amountSun: "2000000", + resource: "energy", + }); expect(out).toContain("Staked"); expect(out).toContain("2 TRX"); expect(out).toContain("energy"); @@ -442,10 +598,10 @@ describe("local multisig formatters", () => { approval, }) as string; expect(out).toContain("Signature added"); - expect(out).toContain("Tsigner (weight 1)"); // signer weight on the action block + expect(out).toContain("Tsigner (weight 1)"); // signer weight on the action block expect(out).toContain('Permission active "operations" (id 2) threshold 2'); expect(out).toContain("Progress 1 / 2"); - expect(out).toContain("Approved signer"); // weight table header + expect(out).toContain("Approved signer"); // weight table header expect(out).not.toContain("local inspection"); expect(out).not.toContain("was not checked online"); }); @@ -465,11 +621,19 @@ describe("local multisig formatters", () => { it("prints the human operation name on the offline sign receipt too", () => { const out = TextFormatters.txSign({ - kind: "tx-sign", signer: "Tsigner", checked: false, hex: "aabb", + kind: "tx-sign", + signer: "Tsigner", + checked: false, + hex: "aabb", transaction: { - txId: approval.txId, contractType: "TransferContract", operation: "Transfer TRX", - rawAmount: "1000000", permissionId: 2, expiration: approval.expiration, - expired: false, signatures: 1, + txId: approval.txId, + contractType: "TransferContract", + operation: "Transfer TRX", + rawAmount: "1000000", + permissionId: 2, + expiration: approval.expiration, + expired: false, + signatures: 1, }, } as any) as string; expect(out).toContain("Transfer TRX"); @@ -541,30 +705,73 @@ describe("local multisig formatters", () => { describe("txStatus formatter (family-agnostic; command supplies `state`)", () => { it("tron: confirmed when not failed", () => { - const out = TextFormatters.txStatus({ txid: "abc", state: "confirmed", confirmed: true, failed: false, blockNumber: 123 }); + const out = TextFormatters.txStatus({ + txid: "abc", + state: "confirmed", + confirmed: true, + failed: false, + blockNumber: 123, + }); expect(out).toContain("confirmed"); expect(out).toContain("#123"); }); it("tron: failed when command flags it", () => { - const out = TextFormatters.txStatus({ txid: "abc", state: "failed", confirmed: true, failed: true, blockNumber: 1 }); + const out = TextFormatters.txStatus({ + txid: "abc", + state: "failed", + confirmed: true, + failed: true, + blockNumber: 1, + }); expect(out).toContain("failed"); }); it("pending when known but not yet confirmed", () => { - const out = TextFormatters.txStatus({ txid: "abc", state: "pending", confirmed: false, failed: false }); + const out = TextFormatters.txStatus({ + txid: "abc", + state: "pending", + confirmed: false, + failed: false, + }); expect(out).toContain("pending"); }); it("not found when the node has no record of the tx", () => { - const out = TextFormatters.txStatus({ txid: "abc", state: "not_found", confirmed: false, failed: false }); + const out = TextFormatters.txStatus({ + txid: "abc", + state: "not_found", + confirmed: false, + failed: false, + }); expect(out).toContain("not found"); }); }); describe("txInfo formatter (per-family, narrowed on ctx.net.family)", () => { it("tron: shows TRX amount, energy and fee in TRX", () => { - const out = TextFormatters.txInfo({ - txid: "abc", from: "Tfrom", to: "Tto", amount: "1.5", symbol: "TRX", - status: "SUCCESS", blockNumber: 66000000, energyUsed: 28000, feeSun: 268000, transaction: {}, info: {}, - }, ctx({ net: { id: "tron:nile", family: "tron", chainId: "nile", feeModel: "tron-resource", aliases: [], capabilities: [] } })); + const out = TextFormatters.txInfo( + { + txid: "abc", + from: "Tfrom", + to: "Tto", + amount: "1.5", + symbol: "TRX", + status: "SUCCESS", + blockNumber: 66000000, + energyUsed: 28000, + feeSun: 268000, + transaction: {}, + info: {}, + }, + ctx({ + net: { + id: "tron:nile", + family: "tron", + chainId: "nile", + feeModel: "tron-resource", + aliases: [], + capabilities: [], + }, + }), + ); expect(out).toContain("1.5 TRX"); expect(out).toContain("#66,000,000"); expect(out).toContain("28,000"); @@ -574,11 +781,15 @@ describe("txInfo formatter (per-family, narrowed on ctx.net.family)", () => { }); describe("accountInfo staking summary", () => { - const accountInfo = (amount: unknown) => TextFormatters.accountInfo({ - address: "Towner", - account: { balance: 0, frozenV2: [{ type: "ENERGY", amount }] }, - resources: {}, - }, ctx()); + const accountInfo = (amount: unknown) => + TextFormatters.accountInfo( + { + address: "Towner", + account: { balance: 0, frozenV2: [{ type: "ENERGY", amount }] }, + resources: {}, + }, + ctx(), + ); it("preserves staking amounts above Number.MAX_SAFE_INTEGER when supplied as strings", () => { expect(accountInfo("9007199254740993")).toContain("9007199254.740993 TRX"); @@ -591,13 +802,21 @@ describe("accountInfo staking summary", () => { describe("contractInfo formatter", () => { it("uses normalized methods + count", () => { - const out = TextFormatters.contractInfo({ address: "TR7c", name: "Foo", methods: ["a", "b"], functionCount: 2 }); + const out = TextFormatters.contractInfo({ + address: "TR7c", + name: "Foo", + methods: ["a", "b"], + functionCount: 2, + }); expect(out).toContain("Foo"); expect(out).toContain("Methods"); expect(out).toContain("2 (a / b)"); }); it("falls back to raw contract/info ABI shape", () => { - const out = TextFormatters.contractInfo({ address: "TR7c", contract: { name: "Bar", abi: { entrys: [{ type: "Function", name: "x" }] } } }); + const out = TextFormatters.contractInfo({ + address: "TR7c", + contract: { name: "Bar", abi: { entrys: [{ type: "Function", name: "x" }] } }, + }); expect(out).toContain("Bar"); expect(out).toContain("1 (x)"); }); @@ -605,17 +824,34 @@ describe("contractInfo formatter", () => { describe("accountHistory formatter", () => { it("renders normalized rows", () => { - const out = TextFormatters.accountHistory({ - address: "TXaddr", - records: [{ time: 1700000000000, type: "Transfer", amount: "1000000", symbol: "TRX", counterparty: "Tother", status: "ok" }], - }, ctx()); + const out = TextFormatters.accountHistory( + { + address: "TXaddr", + records: [ + { + time: 1700000000000, + type: "Transfer", + amount: "1000000", + symbol: "TRX", + counterparty: "Tother", + status: "ok", + }, + ], + }, + ctx(), + ); expect(out).toContain("Transfer"); expect(out).toContain("Tother"); }); }); describe("sign-only receipt", () => { - const base = { kind: "sign" as const, mode: "sign-only" as const, address: "TSigner", txId: "abc123" }; + const base = { + kind: "sign" as const, + mode: "sign-only" as const, + address: "TSigner", + txId: "abc123", + }; const ctx = { command: "tx sign", net: { family: "tron", id: "nile" } } as never; // The signature is the product of a signing command and has to be copied somewhere, so it must @@ -623,7 +859,10 @@ describe("sign-only receipt", () => { // useless as output. it("prints the signature in full", () => { const sig = "16a2ec10".repeat(16) + "1C"; - const out = TextFormatters.txReceipt({ ...base, signed: { txID: "abc123", signature: [sig] } }, ctx) as string; + const out = TextFormatters.txReceipt( + { ...base, signed: { txID: "abc123", signature: [sig] } }, + ctx, + ) as string; expect(out).toContain(sig); expect(out).not.toMatch(/\.\.\./); expect(out).toContain("Signature"); @@ -641,7 +880,10 @@ describe("sign-only receipt", () => { // tx sign estimates nothing, so there is no fee to report and the row is dropped entirely // rather than rendered as "unknown". it("omits the fee row when nothing was estimated", () => { - const out = TextFormatters.txReceipt({ ...base, signed: { signature: ["aa".repeat(65)] } }, ctx) as string; + const out = TextFormatters.txReceipt( + { ...base, signed: { signature: ["aa".repeat(65)] } }, + ctx, + ) as string; expect(out).not.toContain("Fee"); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/token-selector.ts b/ts/src/adapters/inbound/cli/commands/token-selector.ts index 24277b112..6cb452f21 100644 --- a/ts/src/adapters/inbound/cli/commands/token-selector.ts +++ b/ts/src/adapters/inbound/cli/commands/token-selector.ts @@ -5,8 +5,9 @@ export function tokenSelector( value: { contract?: string; assetId?: string }, context: z.RefinementCtx, ): void { - const count = [value.contract, value.assetId] - .filter((candidate) => candidate !== undefined).length; + const count = [value.contract, value.assetId].filter( + (candidate) => candidate !== undefined, + ).length; if (count !== 1) { context.addIssue({ code: "custom", diff --git a/ts/src/adapters/inbound/cli/commands/token.ts b/ts/src/adapters/inbound/cli/commands/token.ts index 7abc1d90d..783512fc7 100644 --- a/ts/src/adapters/inbound/cli/commands/token.ts +++ b/ts/src/adapters/inbound/cli/commands/token.ts @@ -6,15 +6,21 @@ import { TextFormatters } from "../render/index.js"; import { tokenSelector } from "./token-selector.js"; const selectorFields = z.object({ - contract: Schemas.addressFor("tron").optional() + contract: Schemas.addressFor("tron") + .optional() .describe("TRC20 contract address; provide exactly one of --contract or --asset-id"), - assetId: z.string().regex(/^\d+$/).optional() + 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", + network: "optional", + wallet: "optional", + auth: "none", capability: "account.balance.token", summary: "Show a single token balance (--contract / --asset-id)", baseFields: selectorFields, @@ -29,7 +35,9 @@ export const tokenBalanceTronBinding = (svc: TronTokenService): FamilyBinding => export const tokenInfoSpec: ChainSpec = { path: ["token", "info"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "account.balance.token", summary: "Show token metadata (name/symbol/decimals/totalSupply)", baseFields: selectorFields, @@ -44,7 +52,9 @@ export const tokenInfoTronBinding = (svc: TronTokenService): FamilyBinding => ({ export const tokenAddSpec: ChainSpec = { path: ["token", "add"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "token.tokenbook", summary: "Add a token to the address book (fetches symbol/decimals)", baseFields: selectorFields, @@ -59,7 +69,9 @@ export const tokenAddTronBinding = (svc: TronTokenService): FamilyBinding => ({ export const tokenListSpec: ChainSpec = { path: ["token", "list"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "token.tokenbook", summary: "List the address book (official + user)", baseFields: z.object({}), @@ -73,7 +85,9 @@ export const tokenListTronBinding = (svc: TronTokenService): FamilyBinding => ({ export const tokenRemoveSpec: ChainSpec = { path: ["token", "remove"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "token.tokenbook", summary: "Remove a user-added token from the address book", baseFields: selectorFields, diff --git a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts index 2254aa26d..6b4b02319 100644 --- a/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts +++ b/ts/src/adapters/inbound/cli/commands/transaction-options.test.ts @@ -3,7 +3,7 @@ import { readFileSync, readdirSync } from "node:fs"; import { join, relative } from "node:path"; import { z } from "zod"; import { permissionUpdateSpec } from "./permission.js"; -import { txModeFields } from "./shared.js"; +import { governanceTxModeFields, txModeFields } from "./shared.js"; describe("transaction option argv coercion", () => { it("accepts numeric --permission-id and --expiration values from argv", () => { @@ -88,7 +88,9 @@ describe("reference pages keep up with the shared transaction options", () => { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? pages(join(directory, entry.name)) - : entry.name.endsWith(".md") ? [join(directory, entry.name)] : [] + : entry.name.endsWith(".md") + ? [join(directory, entry.name)] + : [], ); } @@ -103,7 +105,12 @@ describe("reference pages keep up with the shared transaction options", () => { expect(key).toBeTruthy(); const behind = pages(DOCS) - .map((path) => ({ path, rows: readFileSync(path, "utf8").split("\n").filter((l) => l.startsWith("| `--permission-id")) })) + .map((path) => ({ + path, + rows: readFileSync(path, "utf8") + .split("\n") + .filter((l) => l.startsWith("| `--permission-id")), + })) .filter(({ rows }) => rows.some((row) => !row.includes(key!))) .map(({ path }) => relative(DOCS, path)); @@ -112,8 +119,15 @@ describe("reference pages keep up with the shared transaction options", () => { it("every --expiration row gives the readable cap and the omitted-case default", () => { const behind = pages(DOCS) - .map((path) => ({ path, rows: readFileSync(path, "utf8").split("\n").filter((l) => l.startsWith("| `--expiration")) })) - .filter(({ rows }) => rows.some((row) => !(row.includes("24h") && /node default.*60s/.test(row)))) + .map((path) => ({ + path, + rows: readFileSync(path, "utf8") + .split("\n") + .filter((l) => l.startsWith("| `--expiration")), + })) + .filter(({ rows }) => + rows.some((row) => !(row.includes("24h") && /node default.*60s/.test(row))), + ) .map(({ path }) => relative(DOCS, path)); expect(behind).toEqual([]); @@ -131,3 +145,26 @@ describe("reference pages keep up with the shared transaction options", () => { expect(behind).toEqual([]); }); }); + +/** + * The governance group re-declared `--permission-id` with an int32 ceiling, so `--help` advertised a + * range the application layer then refused: `transactionMode()` accepts 0..9 and rejects anything + * above it with `invalid_option`, after the command has already started. TRON has at most eight + * active permissions (ids 2..9) plus owner (0), so 0..9 is the real bound and the override was + * simply wrong — it also downgraded the failure from a schema `invalid_value` to a runtime + * `invalid_option`, classifying the same mistake differently from every non-governance command. + */ +describe("governance --permission-id shares the protocol bound with every other command", () => { + const field = (fields: Record) => z.object(fields as never); + + it("rejects a permission id above the protocol maximum at the schema, as txModeFields does", () => { + expect(field(governanceTxModeFields).safeParse({ permissionId: "10" }).success).toBe(false); + expect(field(txModeFields).safeParse({ permissionId: "10" }).success).toBe(false); + }); + + it("still accepts the whole valid range", () => { + for (const id of ["0", "2", "9"]) { + expect(field(governanceTxModeFields).safeParse({ permissionId: id }).success).toBe(true); + } + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts index bedd21335..e98e72085 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.sign.test.ts @@ -52,20 +52,29 @@ describe("tx sign binding", () => { return { kind: "sign" }; }, }; - await txSignTronBinding(svc as never, {} as never, {} as never, {} as never) - .run(ctx, net, { transaction: '{"txID":"abc"}' }); + await txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run(ctx, net, { + transaction: '{"txID":"abc"}', + }); expect(received).toEqual({ txID: "abc" }); }); it("rejects malformed JSON with invalid_value", async () => { const svc = { sign: async () => ({}) }; - await expect(txSignTronBinding(svc as never, {} as never, {} as never, {} as never) - .run(ctx, net, { transaction: "not json" })) - .rejects.toMatchObject({ code: "invalid_value" }); + await expect( + txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run(ctx, net, { + transaction: "not json", + }), + ).rejects.toMatchObject({ code: "invalid_value" }); }); const offlineSigner = () => ({ - sign: async () => ({ kind: "tx-sign", hex: "beef", signer: "T1", checked: false, transaction: {} }), + sign: async () => ({ + kind: "tx-sign", + hex: "beef", + signer: "T1", + checked: false, + transaction: {}, + }), }); const checkedSigner = () => ({ signChecked: async () => ({ @@ -78,36 +87,69 @@ describe("tx sign binding", () => { approval: {}, }), }); - const rejectOffline = { sign: async () => { throw new Error("unexpected offline route"); } }; - const rejectChecked = { signChecked: async () => { throw new Error("unexpected checked route"); } }; + const rejectOffline = { + sign: async () => { + throw new Error("unexpected offline route"); + }, + }; + const rejectChecked = { + signChecked: async () => { + throw new Error("unexpected checked route"); + }, + }; // Default: verify signer permission and resulting weight online (doc §3.2.1). A co-signer who is // not in the permission group, or who already signed, must fail before a signature is produced — // not silently emit a hex that only `tx broadcast` will reject, after it has been passed on. it("routes hex signing through the multisig authorization service by default", async () => { - await expect(txSignTronBinding({} as never, rejectOffline as never, checkedSigner() as never, {} as never) - .run(ctx, net, { hex: "abcd", offline: false })) - .resolves.toMatchObject({ checked: true, signerWeight: 1 }); + await expect( + txSignTronBinding( + {} as never, + rejectOffline as never, + checkedSigner() as never, + {} as never, + ).run(ctx, net, { hex: "abcd", offline: false }), + ).resolves.toMatchObject({ checked: true, signerWeight: 1 }); }); it("treats an absent --offline exactly like --offline false", async () => { - await expect(txSignTronBinding({} as never, rejectOffline as never, checkedSigner() as never, {} as never) - .run(ctx, net, { hex: "abcd" })) - .resolves.toMatchObject({ checked: true }); + await expect( + txSignTronBinding( + {} as never, + rejectOffline as never, + checkedSigner() as never, + {} as never, + ).run(ctx, net, { hex: "abcd" }), + ).resolves.toMatchObject({ checked: true }); }); it("routes --offline through the local signing service, never touching the node", async () => { - await expect(txSignTronBinding({} as never, offlineSigner() as never, rejectChecked as never, {} as never) - .run(ctx, net, { hex: "abcd", offline: true })) - .resolves.toMatchObject({ checked: false }); + await expect( + txSignTronBinding( + {} as never, + offlineSigner() as never, + rejectChecked as never, + {} as never, + ).run(ctx, net, { hex: "abcd", offline: true }), + ).resolves.toMatchObject({ checked: false }); }); it("writes --out on both routes", async () => { - for (const input of [{ hex: "abcd", out: "signed.hex" }, { hex: "abcd", out: "signed.hex", offline: true }]) { + for (const input of [ + { hex: "abcd", out: "signed.hex" }, + { hex: "abcd", out: "signed.hex", offline: true }, + ]) { let written: unknown; - const writer = { write: (path: string, hex: string) => { written = { path, hex }; } }; + const writer = { + write: (path: string, hex: string) => { + written = { path, hex }; + }, + }; const result = await txSignTronBinding( - {} as never, offlineSigner() as never, checkedSigner() as never, writer as never, + {} as never, + offlineSigner() as never, + checkedSigner() as never, + writer as never, ).run(ctx, net, input); expect(written).toEqual({ path: "signed.hex", hex: "beef" }); expect(result).toMatchObject({ out: "signed.hex", hex: "beef" }); @@ -116,9 +158,12 @@ describe("tx sign binding", () => { it("rejects --offline with the JSON payload route, which has no online check to skip", async () => { const svc = { sign: async () => ({ kind: "sign" }) }; - await expect(txSignTronBinding(svc as never, {} as never, {} as never, {} as never) - .run(ctx, net, { transaction: "{}", offline: true })) - .rejects.toMatchObject({ code: "invalid_option" }); + await expect( + txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run(ctx, net, { + transaction: "{}", + offline: true, + }), + ).rejects.toMatchObject({ code: "invalid_option" }); }); }); @@ -127,10 +172,19 @@ describe("tx sign binding", () => { // new `kind:"tx-sign"` shape appears only for --hex/--file, which did not exist in 4.10.0. describe("tx sign 4.10.0 JSON compatibility", () => { it("returns the transaction service result unwrapped and unannotated", async () => { - const legacy = { kind: "sign", mode: "sign-only", signed: { txID: "abc" }, address: "T1", txId: "abc" }; + const legacy = { + kind: "sign", + mode: "sign-only", + signed: { txID: "abc" }, + address: "T1", + txId: "abc", + }; const svc = { sign: async () => legacy }; - const result = await txSignTronBinding(svc as never, {} as never, {} as never, {} as never) - .run(ctx, net, { transaction: '{"txID":"abc"}' }); + const result = await txSignTronBinding(svc as never, {} as never, {} as never, {} as never).run( + ctx, + net, + { transaction: '{"txID":"abc"}' }, + ); expect(result).toEqual(legacy); expect(result).not.toHaveProperty("checked"); expect(result).not.toHaveProperty("approval"); @@ -164,20 +218,22 @@ describe("tx send exclusive groups", () => { }); it("states each exclusivity once — in the group, not also in a field description", () => { - const descriptions = Object.values(txSendSpec.baseFields.shape) - .map((field) => (field as { description?: string }).description ?? ""); + const descriptions = Object.values(txSendSpec.baseFields.shape).map( + (field) => (field as { description?: string }).description ?? "", + ); expect(descriptions.filter((d) => d.includes("mutually exclusive"))).toEqual([]); }); }); describe("tx broadcast binding", () => { - const broadcastContext = (stdin?: string, wait = false) => ({ - wait, - secrets: { - has: (kind: string) => kind === "tx" && stdin !== undefined, - pick: (inline: string | undefined) => inline ?? stdin, - }, - }) as never; + const broadcastContext = (stdin?: string, wait = false) => + ({ + wait, + secrets: { + has: (kind: string) => kind === "tx" && stdin !== undefined, + pick: (inline: string | undefined) => inline ?? stdin, + }, + }) as never; it("retains JSON/stdin inputs and adds hex/file inputs", () => { expect(txBroadcastSpec.baseFields.safeParse({ transaction: "{}" }).success).toBe(true); @@ -190,10 +246,14 @@ describe("tx broadcast binding", () => { // description is a second copy that drifts the moment an input is added or renamed. it("declares every input in one exclusive group and nowhere else", () => { expect(txBroadcastSpec.exclusive).toEqual([ - { label: "the signed transaction to broadcast", flags: ["transaction", "tx-stdin", "hex", "file"] }, + { + label: "the signed transaction to broadcast", + flags: ["transaction", "tx-stdin", "hex", "file"], + }, ]); - const descriptions = Object.values(txBroadcastSpec.baseFields.shape) - .map((field) => (field as { description?: string }).description ?? ""); + const descriptions = Object.values(txBroadcastSpec.baseFields.shape).map( + (field) => (field as { description?: string }).description ?? "", + ); expect(descriptions.filter((d) => d.includes("mutually exclusive"))).toEqual([]); }); @@ -207,9 +267,12 @@ describe("tx broadcast binding", () => { throw new Error("unexpected JSON route"); }, }; - await expect(txBroadcastTronBinding(service as never) - .run(broadcastContext(), net, { hex: "aabb", dryRun: true })) - .resolves.toEqual({ hex: "aabb", dryRun: true }); + await expect( + txBroadcastTronBinding(service as never).run(broadcastContext(), net, { + hex: "aabb", + dryRun: true, + }), + ).resolves.toEqual({ hex: "aabb", dryRun: true }); }); it("routes the retained --tx-stdin JSON source", async () => { @@ -223,27 +286,39 @@ describe("tx broadcast binding", () => { return { txId: "abc" }; }, }; - await txBroadcastTronBinding(service as never) - .run(broadcastContext('{"txID":"abc"}'), net, { dryRun: false }); + await txBroadcastTronBinding(service as never).run(broadcastContext('{"txID":"abc"}'), net, { + dryRun: false, + }); expect(received).toEqual({ txID: "abc" }); }); it("rejects ambiguous input and --wait with --dry-run", async () => { const service = { broadcastHex: async () => ({}), broadcastJson: async () => ({}) }; - await expect(txBroadcastTronBinding(service as never) - .run(broadcastContext(), net, { transaction: "{}", hex: "aabb", dryRun: false })) - .rejects.toMatchObject({ code: "invalid_option" }); - await expect(txBroadcastTronBinding(service as never) - .run(broadcastContext(undefined, true), net, { hex: "aabb", dryRun: true })) - .rejects.toMatchObject({ code: "invalid_option" }); + await expect( + txBroadcastTronBinding(service as never).run(broadcastContext(), net, { + transaction: "{}", + hex: "aabb", + dryRun: false, + }), + ).rejects.toMatchObject({ code: "invalid_option" }); + await expect( + txBroadcastTronBinding(service as never).run(broadcastContext(undefined, true), net, { + hex: "aabb", + dryRun: true, + }), + ).rejects.toMatchObject({ code: "invalid_option" }); }); }); describe("tx multisig spec", () => { it("supports list, unsigned create, sign, and WebSocket watch modes", () => { expect(txTronLinkMultisigSpec.baseFields.safeParse({}).success).toBe(true); - expect(txTronLinkMultisigSpec.baseFields.safeParse({ create: true, hex: "aabb" }).success).toBe(true); - expect(txTronLinkMultisigSpec.baseFields.safeParse({ sign: "ab".repeat(32) }).success).toBe(true); + expect(txTronLinkMultisigSpec.baseFields.safeParse({ create: true, hex: "aabb" }).success).toBe( + true, + ); + expect(txTronLinkMultisigSpec.baseFields.safeParse({ sign: "ab".repeat(32) }).success).toBe( + true, + ); expect(txTronLinkMultisigSpec.baseFields.safeParse({ watch: true }).success).toBe(true); }); diff --git a/ts/src/adapters/inbound/cli/commands/tx.ts b/ts/src/adapters/inbound/cli/commands/tx.ts index c49aff550..2c07320dc 100644 --- a/ts/src/adapters/inbound/cli/commands/tx.ts +++ b/ts/src/adapters/inbound/cli/commands/tx.ts @@ -7,26 +7,30 @@ import type { TronMultisigService } from "../../../../application/use-cases/tron import type { TronMultisigCollaborationService } from "../../../../application/use-cases/tron/multisig-collaboration-service.js"; import type { TransactionArtifactWriter } from "../../../../application/ports/transaction-artifact-writer.js"; import { Schemas } from "../schemas/index.js"; -import { - amountSelector, - txModeFields, - unifiedAmountFields, -} from "./shared.js"; +import { amountSelector, txModeFields, unifiedAmountFields } from "./shared.js"; import { TextFormatters } from "../render/index.js"; import { exactlyOne, readBoundedTextFile } from "./artifact.js"; // baseFields today (single family). When EVM lands, move feeLimit/assetId/contract into the TRON // binding.fields and put gasPrice/gasLimit/nonce into the EVM binding.fields (spec §4 base/delta). const sendFields = z.object({ - to: z.string().trim().min(1).max(128) + to: z + .string() + .trim() + .min(1) + .max(128) .describe("recipient TRON base58 address or local contact name"), - token: z.string().min(1).optional() - .describe("token symbol from the address book"), - contract: Schemas.addressFor("tron").optional() + token: z.string().min(1).optional().describe("token symbol from the address book"), + contract: Schemas.addressFor("tron") + .optional() .describe("TRC20 contract address; omit with --asset-id for native TRX"), - assetId: z.string().regex(/^\d+$/).optional() + assetId: z + .string() + .regex(/^\d+$/) + .optional() .describe("TRC10 numeric asset id; omit with --contract for native TRX"), - feeLimit: Schemas.positiveIntString().default("100000000") + 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", @@ -37,7 +41,9 @@ const sendFields = z.object({ export const txSendSpec: ChainSpec = { path: ["tx", "send"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "tx.send", summary: "Send native TRX or TRC20/TRC10 tokens with human --amount", @@ -45,7 +51,11 @@ export const txSendSpec: ChainSpec = { exclusive: [ { label: "the amount to send", flags: ["amount", "raw-amount"], select: "exactly-one" }, // omitting all three is the native-TRX path, so this set is optional as a whole. - { label: "which asset to send; omit for native TRX", flags: ["token", "contract", "asset-id"], select: "at-most-one" }, + { + label: "which asset to send; omit for native TRX", + flags: ["token", "contract", "asset-id"], + select: "at-most-one", + }, ], baseRefine: amountSelector, examples: [ @@ -65,22 +75,39 @@ export const txSendTronBinding = (svc: TronTransactionService): FamilyBinding => const broadcastFields = z.object({ transaction: z.string().optional().describe("signed TRON transaction JSON"), hex: z.string().min(2).optional().describe("complete signed protocol.Transaction hex"), - file: z.string().min(1).optional().describe("file containing complete signed protocol.Transaction hex"), - dryRun: z.boolean().default(false) - .describe("validate signatures, threshold, expiration, and dynamic multi-sign fee without broadcasting"), + file: z + .string() + .min(1) + .optional() + .describe("file containing complete signed protocol.Transaction hex"), + dryRun: z + .boolean() + .default(false) + .describe( + "validate signatures, threshold, expiration, and dynamic multi-sign fee without broadcasting", + ), }); export const txBroadcastSpec: ChainSpec = { path: ["tx", "broadcast"], stdin: "tx", - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", broadcasts: true, capability: "tx.broadcast", summary: "Validate and broadcast a presigned JSON or protobuf-hex transaction", baseFields: broadcastFields, - exclusive: [{ label: "the signed transaction to broadcast", flags: ["transaction", "tx-stdin", "hex", "file"] }], + exclusive: [ + { + label: "the signed transaction to broadcast", + flags: ["transaction", "tx-stdin", "hex", "file"], + }, + ], baseRefine: (input, context) => { - if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length > 1) { + if ( + [input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length > 1 + ) { context.addIssue({ code: "custom", path: ["transaction"], @@ -106,7 +133,8 @@ export const txBroadcastTronBinding = (service: TronMultisigService): FamilyBind "provide exactly one of --transaction, --tx-stdin, --hex, or --file", ); if (input.hex || input.file) { - const hex = input.hex ?? readBoundedTextFile(input.file, 1024 * 1024 + 4096, "transaction hex file"); + const hex = + input.hex ?? readBoundedTextFile(input.file, 1024 * 1024 + 4096, "transaction hex file"); return service.broadcastHex(ctx, net, hex, input.dryRun); } const raw = ctx.secrets.pick(input.transaction, "tx", "transaction"); @@ -130,10 +158,13 @@ const approvalsFields = z.object(artifactFields); export const txApprovalsSpec: ChainSpec = { path: ["tx", "approvals"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "tx.multisig.local", summary: "Show permission, signature approvals, current weight, and expiration", - description: "Inspect the transaction, selected permission group, approved signers, accumulated weight, missing weight, and expiration without signing.", + description: + "Inspect the transaction, selected permission group, approved signers, accumulated weight, missing weight, and expiration without signing.", baseFields: approvalsFields, exclusive: [{ label: "the transaction to inspect", flags: ["hex", "file"] }], baseRefine: hexOrFileRefine, @@ -146,17 +177,30 @@ export const txApprovalsTronBinding = (service: TronMultisigService): FamilyBind }); const signFields = z.object({ - transaction: z.string().min(1).optional() + transaction: z + .string() + .min(1) + .optional() .describe("unsigned TRON transaction JSON; retained for direct single-signature compatibility"), ...artifactFields, - offline: z.boolean().default(false) - .describe("sign locally without contacting a node; skips the signer-permission and approval-weight checks"), - out: z.string().min(1).optional().describe("atomically write co-signed transaction hex to this file"), + offline: z + .boolean() + .default(false) + .describe( + "sign locally without contacting a node; skips the signer-permission and approval-weight checks", + ), + out: z + .string() + .min(1) + .optional() + .describe("atomically write co-signed transaction hex to this file"), }); export const txSignSpec: ChainSpec = { path: ["tx", "sign"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", + wallet: "optional", + auth: "required", broadcasts: false, capability: "tx.sign", summary: "Sign transaction JSON or append a signature to transaction hex", @@ -170,7 +214,9 @@ export const txSignSpec: ChainSpec = { // --hex/--file first: --transaction is the compatibility path, not the co-signing one. exclusive: [{ label: "the transaction to co-sign", flags: ["hex", "file", "transaction"] }], baseRefine: (input, context) => { - if ([input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length !== 1) { + if ( + [input.transaction, input.hex, input.file].filter((entry) => entry !== undefined).length !== 1 + ) { context.addIssue({ code: "custom", path: ["transaction"], @@ -178,14 +224,24 @@ export const txSignSpec: ChainSpec = { }); } if (input.out && input.transaction) { - context.addIssue({ code: "custom", path: ["out"], message: "--out is only valid with --hex or --file" }); + context.addIssue({ + code: "custom", + path: ["out"], + message: "--out is only valid with --hex or --file", + }); } if (input.offline && input.transaction) { - context.addIssue({ code: "custom", path: ["offline"], message: "--offline is only valid with --hex or --file" }); + context.addIssue({ + code: "custom", + path: ["offline"], + message: "--offline is only valid with --hex or --file", + }); } }, examples: [ - { cmd: `wallet-cli tx sign --transaction '{"txID":"...","raw_data":{...},"raw_data_hex":"..."}'` }, + { + cmd: `wallet-cli tx sign --transaction '{"txID":"...","raw_data":{...},"raw_data_hex":"..."}'`, + }, { cmd: "wallet-cli tx sign --file partially-signed.hex --out signed.hex --password-stdin" }, { cmd: "wallet-cli tx sign --file partially-signed.hex --offline --password-stdin" }, ], @@ -199,7 +255,10 @@ export const txSignTronBinding = ( writer: TransactionArtifactWriter, ): FamilyBinding => ({ run: async (ctx, net, input) => { - exactlyOne([input.transaction, input.hex, input.file], "provide exactly one of --transaction, --hex, or --file"); + exactlyOne( + [input.transaction, input.hex, input.file], + "provide exactly one of --transaction, --hex, or --file", + ); if (!input.transaction) { const hex = hexInput(input); const result = input.offline @@ -209,8 +268,10 @@ export const txSignTronBinding = ( writer.write(input.out, result.hex); return { ...result, out: input.out }; } - if (input.out) throw new UsageError("invalid_option", "--out is only valid with --hex or --file"); - if (input.offline) throw new UsageError("invalid_option", "--offline is only valid with --hex or --file"); + if (input.out) + throw new UsageError("invalid_option", "--out is only valid with --hex or --file"); + if (input.offline) + throw new UsageError("invalid_option", "--offline is only valid with --hex or --file"); let tx: unknown; try { tx = JSON.parse(input.transaction); @@ -222,19 +283,38 @@ export const txSignTronBinding = ( }); const tronLinkMultisigFields = z.object({ - create: z.boolean().default(false) + create: z + .boolean() + .default(false) .describe("sign one unsigned transaction and open a TronLink signature collection with it"), - hex: z.string().min(2).optional().describe("unsigned protocol.Transaction hex used with --create"), - file: z.string().min(1).optional().describe("file containing unsigned transaction hex used with --create"), - sign: z.string().regex(/^(?:0x)?[0-9a-fA-F]{64}$/).optional() + hex: z + .string() + .min(2) + .optional() + .describe("unsigned protocol.Transaction hex used with --create"), + file: z + .string() + .min(1) + .optional() + .describe("file containing unsigned transaction hex used with --create"), + sign: z + .string() + .regex(/^(?:0x)?[0-9a-fA-F]{64}$/) + .optional() .describe("fetch and co-sign one pending TronLink transaction by txId"), - watch: z.boolean().default(false) - .describe("keep a WebSocket open and report only the count of transactions awaiting this account"), + watch: z + .boolean() + .default(false) + .describe( + "keep a WebSocket open and report only the count of transactions awaiting this account", + ), }); export const txTronLinkMultisigSpec: ChainSpec = { path: ["tx", "multisig"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", capability: "tx.multisig.tronlink", summary: "Coordinate multi-signature collection through the TronLink service", description: @@ -246,7 +326,13 @@ export const txTronLinkMultisigSpec: ChainSpec = { "TronLink service credentials — config tronlinkSecretId / tronlinkSecretKey / tronlinkChannel", ], baseFields: tronLinkMultisigFields, - exclusive: [{ label: "which mode to run; omit all three to list", flags: ["create", "sign", "watch"], select: "at-most-one" }], + exclusive: [ + { + label: "which mode to run; omit all three to list", + flags: ["create", "sign", "watch"], + select: "at-most-one", + }, + ], baseRefine: tronLinkMultisigRefine, examples: [ { cmd: "wallet-cli tx multisig" }, @@ -257,7 +343,9 @@ export const txTronLinkMultisigSpec: ChainSpec = { formatText: TextFormatters.txTronLinkMultisig, }; -export const txTronLinkMultisigBinding = (service: TronMultisigCollaborationService): FamilyBinding => ({ +export const txTronLinkMultisigBinding = ( + service: TronMultisigCollaborationService, +): FamilyBinding => ({ run: async (ctx, network, input) => { const address = ctx.resolveAddress("tron"); if (input.create) return service.create(ctx, network, hexInput(input)); @@ -286,7 +374,9 @@ const statusFields = z.object({ txid: z.string().min(1).describe("TRON transacti export const txStatusSpec: ChainSpec = { path: ["tx", "status"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", summary: "Show confirmation status of a transaction", baseFields: statusFields, examples: [{ cmd: "wallet-cli tx status --txid abc123" }], @@ -301,7 +391,9 @@ const infoFields = z.object({ txid: z.string().min(1).describe("TRON transaction export const txInfoSpec: ChainSpec = { path: ["tx", "info"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", summary: "Show full transaction detail + receipt", baseFields: infoFields, examples: [{ cmd: "wallet-cli tx info --txid abc123" }], @@ -316,8 +408,9 @@ 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; + const count = [value.token, value.contract, value.assetId].filter( + (candidate) => candidate !== undefined, + ).length; if (count > 1) { context.addIssue({ code: "custom", @@ -329,7 +422,11 @@ function tokenOptional( function hexOrFileRefine(value: { hex?: string; file?: string }, context: z.RefinementCtx): void { if ([value.hex, value.file].filter((entry) => entry !== undefined).length !== 1) { - context.addIssue({ code: "custom", path: ["hex"], message: "provide exactly one of --hex or --file" }); + context.addIssue({ + code: "custom", + path: ["hex"], + message: "provide exactly one of --hex or --file", + }); } } diff --git a/ts/src/adapters/inbound/cli/commands/typed-data.test.ts b/ts/src/adapters/inbound/cli/commands/typed-data.test.ts index 1ea139fb2..5f9f5bc1e 100644 --- a/ts/src/adapters/inbound/cli/commands/typed-data.test.ts +++ b/ts/src/adapters/inbound/cli/commands/typed-data.test.ts @@ -6,7 +6,10 @@ const net = { family: "tron", id: "nile", chainId: "728126428" } as never; const PAYLOAD = JSON.stringify({ domain: { name: "SunPerp", version: "1", chainId: 728126428 }, - types: { EIP712Domain: [{ name: "name", type: "string" }], Order: [{ name: "size", type: "uint256" }] }, + types: { + EIP712Domain: [{ name: "name", type: "string" }], + Order: [{ name: "size", type: "uint256" }], + }, message: { size: "1" }, }); @@ -47,8 +50,9 @@ describe("typed-data sign binding", () => { it("rejects malformed JSON with invalid_value", async () => { const svc = { sign: async () => stubResult }; - await expect(typedDataSignBinding(svc as never).run(ctx, net, { typedData: "nope" })) - .rejects.toMatchObject({ code: "invalid_value" }); + await expect( + typedDataSignBinding(svc as never).run(ctx, net, { typedData: "nope" }), + ).rejects.toMatchObject({ code: "invalid_value" }); }); it("rejects a structurally invalid payload with invalid_value", async () => { diff --git a/ts/src/adapters/inbound/cli/commands/typed-data.ts b/ts/src/adapters/inbound/cli/commands/typed-data.ts index 421f932b4..3aa001c05 100644 --- a/ts/src/adapters/inbound/cli/commands/typed-data.ts +++ b/ts/src/adapters/inbound/cli/commands/typed-data.ts @@ -6,13 +6,17 @@ import type { TypedDataService } from "../../../../application/use-cases/typed-d import { TextFormatters } from "../render/index.js"; const typedDataFields = z.object({ - typedData: z.string().min(1) + typedData: z + .string() + .min(1) .describe(`EIP-712/TIP-712 JSON: {"domain":…,"types":…,"primaryType"?:…,"message":…}`), }); export const typedDataSignSpec: ChainSpec = { path: ["typed-data", "sign"], - network: "optional", wallet: "optional", auth: "required", + network: "optional", + wallet: "optional", + auth: "required", broadcasts: false, capability: "typedData.sign", summary: "Sign EIP-712 / TIP-712 structured data", @@ -22,7 +26,9 @@ export const typedDataSignSpec: ChainSpec = { "addresses work in address fields.", baseFields: typedDataFields, examples: [ - { cmd: `wallet-cli typed-data sign --typed-data '{"domain":{...},"types":{...},"message":{...}}'` }, + { + cmd: `wallet-cli typed-data sign --typed-data '{"domain":{...},"types":{...},"message":{...}}'`, + }, ], formatText: TextFormatters.typedDataSign, }; diff --git a/ts/src/adapters/inbound/cli/commands/vote.ts b/ts/src/adapters/inbound/cli/commands/vote.ts index deb172938..c07476d73 100644 --- a/ts/src/adapters/inbound/cli/commands/vote.ts +++ b/ts/src/adapters/inbound/cli/commands/vote.ts @@ -6,12 +6,19 @@ 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 (positive integer); repeatable; the set replaces all prior votes (at least 1, at most 30 entries)"); +const voteForField = z + .array(z.string().min(1)) + .min(1) + .max(30) + .describe( + "witness address = vote count (positive integer); repeatable; the set replaces all prior votes (at least 1, at most 30 entries)", + ); export const voteCastSpec: ChainSpec = { path: ["vote", "cast"], - network: "optional", wallet: "optional", auth: "conditional", + network: "optional", + wallet: "optional", + auth: "conditional", broadcasts: true, capability: "vote.cast", summary: "Cast or replace your full SR vote allocation", @@ -33,15 +40,21 @@ export const voteCastTronBinding = (svc: TronVoteService): FamilyBinding => ({ export const voteListSpec: ChainSpec = { path: ["vote", "list"], - network: "optional", wallet: "none", auth: "none", + network: "optional", + wallet: "none", + auth: "none", capability: "vote.list", summary: "List super representatives and candidates", description: "List super representatives (elected by default) with votes, APR, and reward ratio.", baseFields: z.object({ - limit: z.coerce.number().int().positive().max(127).default(27) + 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"), + candidates: z.boolean().default(false).describe("include non-elected candidates"), }), examples: [ { cmd: "wallet-cli vote list" }, @@ -56,7 +69,9 @@ export const voteListTronBinding = (svc: TronVoteService): FamilyBinding => ({ export const voteStatusSpec: ChainSpec = { path: ["vote", "status"], - network: "optional", wallet: "optional", auth: "none", + network: "optional", + wallet: "optional", + auth: "none", capability: "vote.status", summary: "Show current votes, voting power, and reward overview", description: diff --git a/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts index 831b46c42..f9d7919f0 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts @@ -23,8 +23,9 @@ import { registerWalletCommands } from "./wallet.js"; import type { SessionRef } from "../contracts/index.js"; // Cheap KDF for keystore encryption in this suite — see cheap-scrypt.ts. Production untouched. -vi.mock("@noble/hashes/scrypt.js", async () => - import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), +vi.mock( + "@noble/hashes/scrypt.js", + async () => import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), ); const VALID_MNEMONIC = "test test test test test test test test test test test junk"; @@ -37,8 +38,12 @@ function fixture(opts: { tty: boolean }) { const streams = new StreamManager("text", false); const prompter = new Prompter({ isTTY: () => opts.tty, - async question(_prompt: string, _hidden: boolean) { return VALID_PASSWORD; }, - async readKey() { return { name: "return" }; }, + async question(_prompt: string, _hidden: boolean) { + return VALID_PASSWORD; + }, + async readKey() { + return { name: "return" }; + }, write() {}, beginRaw() {}, endRaw() {}, @@ -51,12 +56,13 @@ function fixture(opts: { tty: boolean }) { const networkRegistry = new NetworkRegistry(config); const formatter = createOutputFormatter("text", streams, Date.now()); const registry = new CommandRegistry(); - registerWalletCommands(registry, { - walletService: new WalletService(keystore, {} as any, { - write: () => ({ out: "unused", fileMode: "0600", bytes: 0 }), - }), - ledger: {} as any, - } as any); + const walletService = new WalletService( + keystore, + {} as any, + { write: () => ({ out: "unused", fileMode: "0600", bytes: 0 }) }, + { append: () => {}, list: () => [] }, + ); + registerWalletCommands(registry, { walletService, ledger: {} as any } as any); const session: SessionRef = {}; const shellOpts: ShellOptions = { @@ -69,7 +75,7 @@ function fixture(opts: { tty: boolean }) { formatter, session, }; - return { shellOpts, keystore, secrets, spyPrime }; + return { shellOpts, keystore, secrets, spyPrime, walletService }; } describe("backup password gating", () => { @@ -81,8 +87,9 @@ describe("backup password gating", () => { address: LEDGER_ADDRESS, }); - await expect(buildCli(shellOpts).parseAsync(["backup", accountId])) - .rejects.toMatchObject({ code: "not_exportable" }); + await expect(buildCli(shellOpts).parseAsync(["backup", accountId])).rejects.toMatchObject({ + code: "not_exportable", + }); expect(spyPrime).not.toHaveBeenCalled(); }); @@ -99,3 +106,46 @@ describe("backup password gating", () => { expect(spyPrime.mock.calls[0]![0].mode).toBe("verify"); }); }); + +/** + * The log filters only mean anything in --records mode; accepting one on an export would silently + * ignore what the caller asked for. --offset is the one that has to be asserted deliberately: it + * reads as a plain pagination flag, so a default value would hide it from the guard entirely. + */ +describe("backup --records flag gating", () => { + for (const args of [ + ["--from", "2026-08-01"], + ["--to", "2026-08-01"], + ["--limit", "5"], + ["--offset", "5"], + ]) { + it(`rejects ${args[0]} without --records`, async () => { + const { shellOpts, spyPrime } = fixture({ tty: false }); + + await expect( + buildCli(shellOpts).parseAsync(["backup", "main", ...args]), + ).rejects.toMatchObject({ code: "invalid_value" }); + expect(spyPrime).not.toHaveBeenCalled(); + }); + } + + it("passes the filters through with --records", async () => { + const { shellOpts, walletService } = fixture({ tty: false }); + const spy = vi.spyOn(walletService, "backupRecords"); + + await buildCli(shellOpts).parseAsync(["backup", "--records", "--offset", "1", "--limit", "5"]); + + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ offset: 1, limit: 5 })); + }); + + // offset is optional now; the 0 has to come from the service, or the emitted pagination changes. + it("still reports offset 0 when --offset is omitted", async () => { + const { shellOpts, walletService } = fixture({ tty: false }); + const spy = vi.spyOn(walletService, "backupRecords"); + + await buildCli(shellOpts).parseAsync(["backup", "--records"]); + + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ offset: undefined })); + expect(spy.mock.results[0]!.value).toMatchObject({ pagination: { offset: 0, limit: null } }); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts index 29089216b..9de68246f 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.current.test.ts @@ -14,18 +14,18 @@ const descriptor = { addresses: { tron: ADDRESS }, } satisfies AccountDescriptor; -function command(options: { - output?: "text" | "json"; - encoded?: string | null; - account?: string; -} = {}) { +function command( + options: { + output?: "text" | "json"; + encoded?: string | null; + account?: string; + } = {}, +) { const walletService = { current: vi.fn(() => descriptor), }; const qr = { - encode: vi.fn(() => - options.encoded === undefined ? "QR-MATRIX" : options.encoded - ), + encode: vi.fn(() => (options.encoded === undefined ? "QR-MATRIX" : options.encoded)), }; const registry = new CommandRegistry(); registerWalletCommands(registry, { @@ -48,15 +48,9 @@ function command(options: { describe("current --qr", () => { it("encodes exactly the selected account's TRON address in text mode", async () => { const fixture = command({ account: "wlt_selected", encoded: "QR" }); - const result = await fixture.current.run( - fixture.context as never, - undefined, - { qr: true }, - ); + const result = await fixture.current.run(fixture.context as never, undefined, { qr: true }); - expect(fixture.walletService.current).toHaveBeenCalledWith( - "wlt_selected", - ); + expect(fixture.walletService.current).toHaveBeenCalledWith("wlt_selected"); expect(fixture.qr.encode).toHaveBeenCalledWith(ADDRESS); expect(result).toMatchObject({ receiveQr: "QR", @@ -66,11 +60,7 @@ describe("current --qr", () => { it("keeps JSON data unchanged and never builds terminal art", async () => { const fixture = command({ output: "json" }); - const result = await fixture.current.run( - fixture.context as never, - undefined, - { qr: true }, - ); + const result = await fixture.current.run(fixture.context as never, undefined, { qr: true }); expect(result).toEqual(descriptor); expect(fixture.qr.encode).not.toHaveBeenCalled(); @@ -78,15 +68,9 @@ describe("current --qr", () => { it("warns and returns the full normal descriptor on a narrow terminal", async () => { const fixture = command({ encoded: null }); - const result = await fixture.current.run( - fixture.context as never, - undefined, - { qr: true }, - ); + const result = await fixture.current.run(fixture.context as never, undefined, { qr: true }); expect(result).toEqual(descriptor); - expect(fixture.context.warn).toHaveBeenCalledWith( - expect.stringContaining("too narrow"), - ); + expect(fixture.context.warn).toHaveBeenCalledWith(expect.stringContaining("too narrow")); }); }); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts new file mode 100644 index 000000000..706004393 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts @@ -0,0 +1,331 @@ +/** + * The `backup` mode switch and `import keystore`, exercised through real dispatch — the parts that + * only exist there: which envelope `command` a mode reports, whether a password is demanded, whether + * the TTY is asked to pick an account, and the flag combinations each mode refuses. + */ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildCli, type ShellOptions } from "../shell/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"; +import { WalletService } from "../../../../application/use-cases/wallet-service.js"; +import type { BackupRecord } from "../../../../application/ports/backup-records.js"; +import { KeystoreV3 } from "../../../../domain/keystore/index.js"; +import { registerWalletCommands } from "./wallet.js"; +import type { SessionRef } from "../contracts/index.js"; + +// Cheap KDF for keystore encryption in this suite — see cheap-scrypt.ts. Production untouched. +vi.mock( + "@noble/hashes/scrypt.js", + async () => import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), +); + +const VALID_MNEMONIC = "test test test test test test test test test test test junk"; +const VALID_PASSWORD = "Abcdef1!"; +const RAW_KEY = "4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d"; +const KEYSTORE_PW = "keystore-file-pw"; + +const record = (over: Partial = {}): BackupRecord => ({ + operation: "backup", + accountId: "wlt_seeded.0", + account: "TSeeded", + label: "seeded", + out: "./seeded.json", + timestamp: "2026-08-05T11:40:00Z", + ...over, +}); + +function fixture(opts: { tty: boolean; records?: BackupRecord[] }) { + const root = mkdtempSync(join(tmpdir(), "wallet-keystore-test-")); + const store = new AtomicFileStore(); + const streams = new StreamManager("json", false); + const emitted: string[] = []; + const asked: string[] = []; + const prompter = new Prompter({ + isTTY: () => opts.tty, + async question(prompt: string, _hidden: boolean) { + asked.push(prompt); + // the keystore file's own password is a distinct prompt from the master password + return /keystore/i.test(prompt) ? KEYSTORE_PW : VALID_PASSWORD; + }, + async readKey() { + return { name: "return" }; + }, + write() {}, + beginRaw() {}, + endRaw() {}, + }); + const secrets = new SecretResolver(streams, {}, prompter); + const keystore = new Keystore(root, store, () => secrets.masterPassword()); + const spyPrime = vi.spyOn(secrets, "primePassword"); + const spySelect = vi.spyOn(prompter, "select"); + + const config = ConfigLoader.load(); + const networkRegistry = new NetworkRegistry(config); + const formatter = createOutputFormatter("json", streams, Date.now()); + vi.spyOn(streams, "result").mockImplementation((line: string) => void emitted.push(line)); + + const writes: Array<{ out: string; payload: unknown }> = []; + const registry = new CommandRegistry(); + registerWalletCommands(registry, { + walletService: new WalletService( + keystore, + {} as any, + { + write: (accountId: string, requested: string | undefined, payload: unknown) => { + const out = requested ?? `./${accountId}-1700000000000.json`; + writes.push({ out, payload }); + return { out, fileMode: "0600" as const, bytes: 491 }; + }, + }, + { append: () => {}, list: () => opts.records ?? [] }, + ), + ledger: {} as any, + } as any); + + const session: SessionRef = {}; + const shellOpts: ShellOptions = { + registry, + globals: { output: "json", verbose: false }, + deps: { config, networkRegistry, streams, secrets, keystore, prompter, formatter }, + targetResolver: new TargetResolver({ networkRegistry, keystore }), + caps: new CapabilityRegistry(), + streams, + formatter, + session, + }; + const envelope = () => JSON.parse(emitted.at(-1)!); + return { shellOpts, keystore, secrets, spyPrime, spySelect, root, asked, writes, envelope }; +} + +/** an account whose secret can be exported, with the master password already established. */ +async function seedWallet( + f: ReturnType, + secret = VALID_MNEMONIC, + type: "seed" | "privateKey" = "seed", +) { + await f.secrets.primePassword({ mode: "set" }); + const { accountId } = f.keystore.import({ secret, type, label: "main" }); + f.secrets.clearPrimed(); + f.spyPrime.mockClear(); + f.spySelect.mockClear(); + return accountId; +} + +describe("backup --keystore", () => { + it("writes a V3 keystore the master password opens, and reports command 'backup'", async () => { + const f = fixture({ tty: true }); + const accountId = await seedWallet(f, RAW_KEY, "privateKey"); + + await buildCli(f.shellOpts).parseAsync(["backup", accountId, "--keystore"]); + + const env = f.envelope(); + expect(env.command).toBe("backup"); + expect(env.data).toMatchObject({ + accountId, + format: "keystore", + secretType: "privateKey", + fileMode: "0600", + }); + expect(KeystoreV3.decrypt(f.writes[0]!.payload, VALID_PASSWORD)).toHaveLength(32); + }); + + it("still verifies the master password", async () => { + const f = fixture({ tty: true }); + const accountId = await seedWallet(f); + await buildCli(f.shellOpts).parseAsync(["backup", accountId, "--keystore"]); + expect(f.spyPrime).toHaveBeenCalledOnce(); + expect(f.spyPrime.mock.calls[0]![0].mode).toBe("verify"); + }); + + it("honours an explicit --out path", async () => { + const f = fixture({ tty: true }); + const accountId = await seedWallet(f); + await buildCli(f.shellOpts).parseAsync([ + "backup", + accountId, + "--keystore", + "--out", + "./main.keystore.json", + ]); + expect(f.envelope().data.out).toBe("./main.keystore.json"); + }); +}); + +describe("backup --records", () => { + it("reports the distinct command id 'backup.records' — the data shape differs", async () => { + const f = fixture({ tty: false, records: [record()] }); + await buildCli(f.shellOpts).parseAsync(["backup", "--records"]); + expect(f.envelope().command).toBe("backup.records"); + }); + + it("demands no master password and exports nothing", async () => { + const f = fixture({ tty: false, records: [record()] }); + await buildCli(f.shellOpts).parseAsync(["backup", "--records"]); + expect(f.spyPrime).not.toHaveBeenCalled(); + expect(f.writes).toEqual([]); + }); + + it("does not ask a TTY user to pick an account — nothing is being exported", async () => { + const f = fixture({ tty: true, records: [record()] }); + await seedWallet(f); + await buildCli(f.shellOpts).parseAsync(["backup", "--records"]); + expect(f.spySelect).not.toHaveBeenCalled(); + expect(f.envelope().command).toBe("backup.records"); + }); + + // The service returns `pagination` inside its view; the json formatter lifts it into envelope + // `meta` (and removes it from `data`) whenever it carries a full offset/limit/total triple. + it("returns records, with pagination lifted into envelope meta", async () => { + const f = fixture({ + tty: false, + records: [record({ out: "./1.json" }), record({ out: "./2.json" })], + }); + await buildCli(f.shellOpts).parseAsync(["backup", "--records", "--limit", "1"]); + const env = f.envelope(); + expect(env.data.records.map((r: BackupRecord) => r.out)).toEqual(["./1.json"]); + expect(env.meta.pagination).toEqual({ offset: 0, limit: 1, total: 2 }); + expect(env.data.pagination).toBeUndefined(); + }); + + it("rejects export flags, which it could only ignore", async () => { + const f = fixture({ tty: false, records: [] }); + for (const argv of [ + ["backup", "--records", "--keystore"], + ["backup", "--records", "--out", "./x.json"], + ]) { + await expect(buildCli(f.shellOpts).parseAsync(argv)).rejects.toMatchObject({ + code: "invalid_value", + }); + } + }); + + it("rejects record filters when not in records mode", async () => { + const f = fixture({ tty: false }); + await expect( + buildCli(f.shellOpts).parseAsync(["backup", "main", "--from", "2026-08-01"]), + ).rejects.toMatchObject({ code: "invalid_value" }); + }); + + it.each([ + ["a malformed shape", "01-08-2026"], + ["an impossible calendar date", "2026-02-31"], + ["an impossible time", "2026-08-01 25:00:00"], + ["a local-time offset", "2026-08-01T00:00:00+08:00"], + ])("rejects %s in --from", async (_label, value) => { + const f = fixture({ tty: false, records: [] }); + await expect( + buildCli(f.shellOpts).parseAsync(["backup", "--records", "--from", value]), + ).rejects.toMatchObject({ code: "invalid_value" }); + }); + + it("accepts both accepted time spellings", async () => { + const f = fixture({ tty: false, records: [record()] }); + for (const value of ["2026-08-01", "2026-08-01 09:30:00"]) { + await buildCli(f.shellOpts).parseAsync(["backup", "--records", "--from", value]); + expect(f.envelope().success).toBe(true); + } + }); + + it("requires an account when NOT in records mode and no TTY can be asked", async () => { + const f = fixture({ tty: false }); + await expect(buildCli(f.shellOpts).parseAsync(["backup"])).rejects.toMatchObject({ + code: "invalid_value", + }); + }); +}); + +describe("import keystore", () => { + function keystoreFile( + root: string, + name = "export.json", + keyHex = RAW_KEY, + password = KEYSTORE_PW, + ) { + const path = join(root, name); + writeFileSync( + path, + JSON.stringify( + KeystoreV3.encrypt(Buffer.from(keyHex, "hex"), password, `41${"00".repeat(20)}`), + ), + ); + return path; + } + + it("imports the file's key, reporting command 'import.keystore'", async () => { + const f = fixture({ tty: true }); + const path = keystoreFile(f.root); + + await buildCli(f.shellOpts).parseAsync(["import", "keystore", path, "--label", "imported"]); + + const env = f.envelope(); + expect(env.command).toBe("import.keystore"); + expect(env.data).toMatchObject({ + status: "created", + label: "imported", + type: "privateKey", + index: null, + active: true, + }); + }); + + it("asks for the master password and the keystore's own password, separately", async () => { + const f = fixture({ tty: true }); + await buildCli(f.shellOpts).parseAsync(["import", "keystore", keystoreFile(f.root)]); + expect(f.asked.some((p) => /keystore file password/i.test(p))).toBe(true); + expect(f.spyPrime).toHaveBeenCalled(); + }); + + it("refuses to run without a TTY — both passwords are hidden-input only", async () => { + const f = fixture({ tty: false }); + await expect( + buildCli(f.shellOpts).parseAsync(["import", "keystore", keystoreFile(f.root)]), + ).rejects.toMatchObject({ code: "tty_required" }); + }); + + it("reports a missing file distinctly from a malformed one, before asking for any password", async () => { + const f = fixture({ tty: true }); + await expect( + buildCli(f.shellOpts).parseAsync(["import", "keystore", join(f.root, "nope.json")]), + ).rejects.toMatchObject({ code: "keystore_not_found" }); + expect(f.spyPrime).not.toHaveBeenCalled(); + + const bad = join(f.root, "bad.json"); + writeFileSync(bad, "{not json"); + await expect( + buildCli(f.shellOpts).parseAsync(["import", "keystore", bad]), + ).rejects.toMatchObject({ code: "invalid_keystore" }); + }); + + it("rejects a version-1 blob of ours as not a keystore", async () => { + const f = fixture({ tty: true }); + const path = join(f.root, "vault.json"); + const { crypto } = KeystoreV3.encrypt( + Buffer.from(RAW_KEY, "hex"), + KEYSTORE_PW, + `41${"00".repeat(20)}`, + ); + writeFileSync(path, JSON.stringify({ version: 1, type: "raw-privkey", id: "key_x", crypto })); + await expect( + buildCli(f.shellOpts).parseAsync(["import", "keystore", path]), + ).rejects.toMatchObject({ code: "invalid_keystore" }); + }); + + it("refuses a same-address account with account_exists", async () => { + const f = fixture({ tty: true }); + await seedWallet(f, RAW_KEY, "privateKey"); + await expect( + buildCli(f.shellOpts).parseAsync(["import", "keystore", keystoreFile(f.root)]), + ).rejects.toMatchObject({ code: "account_exists" }); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/wallet.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.test.ts index 58b9acc72..394f65fc7 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.test.ts @@ -1,8 +1,7 @@ -import { describe, it, expect , vi } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { z } from "zod"; import { Keystore } from "../../../outbound/keystore/index.js"; import { AtomicFileStore } from "../../../outbound/persistence/fs/index.js"; import { SecretResolver } from "../input/secret/index.js"; @@ -11,17 +10,17 @@ import { Prompter } from "../input/prompt/index.js"; import { ConfigLoader, NetworkRegistry } from "../../../outbound/config/index.js"; import { buildExecutionContext, RuntimeDeps } from "../context/index.js"; import { createOutputFormatter } from "../output/index.js"; -import { registerWalletCommands, walletImportLedgerFields, walletImportLedgerInput } from "./wallet.js"; +import { registerWalletCommands } from "./wallet.js"; import { CommandRegistry } from "../registry/index.js"; import { commandId } from "../command-id.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"; // Cheap KDF for keystore encryption in this suite — see cheap-scrypt.ts. Production untouched. -vi.mock("@noble/hashes/scrypt.js", async () => - import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), +vi.mock( + "@noble/hashes/scrypt.js", + async () => import("../../../outbound/persistence/crypto/__test-support__/cheap-scrypt.js"), ); // ── test constants ───────────────────────────────────────────────────────────── @@ -39,7 +38,7 @@ interface FakePromptOpts { } function makeFakeBackend(opts: FakePromptOpts = {}): ConstructorParameters[0] { - const { tty = true, hiddenAnswers = [], confirmResult = true, confirmAnswer } = opts; + const { tty = true, hiddenAnswers = [], confirmAnswer } = opts; let hiddenIdx = 0; return { @@ -51,7 +50,9 @@ function makeFakeBackend(opts: FakePromptOpts = {}): ConstructorParameters:` pair flag into its two halves. */ +export function splitPair(value: string, flag: string): [string, string] { + const parts = value.split(":"); + if (parts.length !== 2 || !parts[0]?.trim() || !parts[1]?.trim()) { + throw new UsageError("invalid_value", `${flag} must be :`); + } + return [parts[0]!, parts[1]!]; +} diff --git a/ts/src/domain/family/index.ts b/ts/src/domain/family/index.ts index 8641a2d13..1cc2f738a 100644 --- a/ts/src/domain/family/index.ts +++ b/ts/src/domain/family/index.ts @@ -26,7 +26,15 @@ export interface FamilyMeta { } export const FAMILIES: Record = { - tron: { family: "tron", nativeUnit: "sun", nativeSymbol: "TRX", nativeDecimals: 6, coinType: 195, codec: new TronAddress(), ledger: { app: "tron" } }, + tron: { + family: "tron", + nativeUnit: "sun", + nativeSymbol: "TRX", + nativeDecimals: 6, + coinType: 195, + codec: new TronAddress(), + ledger: { app: "tron" }, + }, }; /** every known family, in declaration order. */ diff --git a/ts/src/domain/gasfree/gasfree.test.ts b/ts/src/domain/gasfree/gasfree.test.ts index 5e6c9f3f0..5e380652b 100644 --- a/ts/src/domain/gasfree/gasfree.test.ts +++ b/ts/src/domain/gasfree/gasfree.test.ts @@ -53,16 +53,19 @@ describe("GasFree Java-compatible TIP-712", () => { ); expect(digest).toBe(`0x${bytesToHex(gasFreeDigest(DOMAIN, AUTHORIZATION))}`); expect(normalizeGasFreeSignature(SIGNATURE)).toBe(SIGNATURE.slice(2)); - expect(recoverGasFreeSigner(gasFreeDigest(DOMAIN, AUTHORIZATION), SIGNATURE)) - .toBe(AUTHORIZATION.user); + expect(recoverGasFreeSigner(gasFreeDigest(DOMAIN, AUTHORIZATION), SIGNATURE)).toBe( + AUTHORIZATION.user, + ); }); it("derives the same TRON address from compressed and uncompressed keys", () => { const privateKey = hexToBytes(`${"00".repeat(31)}01`); const address = new TronAddress(); - expect(address.fromPublicKey(secp256k1.getPublicKey(privateKey, true))) - .toBe(AUTHORIZATION.user); - expect(address.fromPublicKey(secp256k1.getPublicKey(privateKey, false))) - .toBe(AUTHORIZATION.user); + expect(address.fromPublicKey(secp256k1.getPublicKey(privateKey, true))).toBe( + AUTHORIZATION.user, + ); + expect(address.fromPublicKey(secp256k1.getPublicKey(privateKey, false))).toBe( + AUTHORIZATION.user, + ); }); }); diff --git a/ts/src/domain/gasfree/index.ts b/ts/src/domain/gasfree/index.ts index d86620e4b..bc047ff79 100644 --- a/ts/src/domain/gasfree/index.ts +++ b/ts/src/domain/gasfree/index.ts @@ -1,11 +1,7 @@ import { secp256k1 } from "@noble/curves/secp256k1.js"; import { keccak_256 } from "@noble/hashes/sha3.js"; import { bytesToHex, concatBytes, hexToBytes } from "@noble/hashes/utils.js"; -import type { - Bytes, - GasFreeAuthorization, - TypedDataPayload, -} from "../types/index.js"; +import type { Bytes, GasFreeAuthorization, TypedDataPayload } from "../types/index.js"; import { TronAddress, tronAddressBytes } from "../address/index.js"; export const GASFREE_DOMAIN_TYPE = @@ -40,29 +36,33 @@ export interface GasFreeDomainInput { /** Java GasFreeApi.getDomainSeparator byte-for-byte equivalent. */ export function gasFreeDomainSeparator(domain: GasFreeDomainInput): Bytes { - return keccak_256(concatBytes( - keccak_256(UTF8.encode(GASFREE_DOMAIN_TYPE)), - keccak_256(UTF8.encode(GASFREE_DOMAIN_NAME)), - keccak_256(UTF8.encode(GASFREE_DOMAIN_VERSION)), - uint256Word(domain.controllerChainId, "controllerChainId"), - addressWord(domain.verifyingContract), - )); + return keccak_256( + concatBytes( + keccak_256(UTF8.encode(GASFREE_DOMAIN_TYPE)), + keccak_256(UTF8.encode(GASFREE_DOMAIN_NAME)), + keccak_256(UTF8.encode(GASFREE_DOMAIN_VERSION)), + uint256Word(domain.controllerChainId, "controllerChainId"), + addressWord(domain.verifyingContract), + ), + ); } /** Java GasFreeApi.buildMessage byte-for-byte equivalent. */ export function gasFreeMessageHash(authorization: GasFreeAuthorization): Bytes { - return keccak_256(concatBytes( - keccak_256(UTF8.encode(GASFREE_PERMIT_TYPE)), - addressWord(authorization.token), - addressWord(authorization.serviceProvider), - addressWord(authorization.user), - addressWord(authorization.receiver), - uint256Word(authorization.value, "value"), - uint256Word(authorization.maxFee, "maxFee"), - uint256Word(authorization.deadline, "deadline"), - uint256Word(authorization.version, "version"), - uint256Word(authorization.nonce, "nonce"), - )); + return keccak_256( + concatBytes( + keccak_256(UTF8.encode(GASFREE_PERMIT_TYPE)), + addressWord(authorization.token), + addressWord(authorization.serviceProvider), + addressWord(authorization.user), + addressWord(authorization.receiver), + uint256Word(authorization.value, "value"), + uint256Word(authorization.maxFee, "maxFee"), + uint256Word(authorization.deadline, "deadline"), + uint256Word(authorization.version, "version"), + uint256Word(authorization.nonce, "nonce"), + ), + ); } /** keccak256(0x1901 || domainSeparator || messageHash), matching Java signOffChain input. */ @@ -70,11 +70,13 @@ export function gasFreeDigest( domain: GasFreeDomainInput, authorization: GasFreeAuthorization, ): Bytes { - return keccak_256(concatBytes( - Uint8Array.of(0x19, 0x01), - gasFreeDomainSeparator(domain), - gasFreeMessageHash(authorization), - )); + return keccak_256( + concatBytes( + Uint8Array.of(0x19, 0x01), + gasFreeDomainSeparator(domain), + gasFreeMessageHash(authorization), + ), + ); } /** Build the exact TIP-712 payload used by software and Ledger signers. */ @@ -129,10 +131,7 @@ export function recoverGasFreeSigner(digest: Bytes, signatureHex: string): strin if (digest.length !== 32) throw new Error("GasFree digest must be 32 bytes"); const canonical = hexToBytes(normalizeGasFreeSignature(signatureHex)); const recovery = canonical[64]! - 27; - const recoveredSignature = concatBytes( - Uint8Array.of(recovery), - canonical.slice(0, 64), - ); + const recoveredSignature = concatBytes(Uint8Array.of(recovery), canonical.slice(0, 64)); const compressed = secp256k1.recoverPublicKey(recoveredSignature, digest, { prehash: false }); const uncompressed = secp256k1.Point.fromBytes(compressed).toBytes(false); return new TronAddress().fromPublicKey(uncompressed); diff --git a/ts/src/domain/governance/chain-parameters.test.ts b/ts/src/domain/governance/chain-parameters.test.ts new file mode 100644 index 000000000..41305b7a6 --- /dev/null +++ b/ts/src/domain/governance/chain-parameters.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { parseChainParameterAssignments, proposalParameters } from "./chain-parameters.js"; + +const current = [ + { key: "getCreateAccountFee", value: 100_000 }, + { key: "getTransactionFee", value: 10 }, + { key: "getAllowMultiSign", value: 1 }, +]; + +describe("chain parameter proposal mapping", () => { + it("accepts names and ids, applies last duplicate, and sorts by protocol id", () => { + expect( + parseChainParameterAssignments( + ["getTransactionFee=12", "2=200000", "GETTRANSACTIONFEE=15"], + current, + ), + ).toEqual([ + { + id: 2, + name: "getCreateAccountFee", + currentValue: 100_000, + proposedValue: 200_000, + unit: "sun", + }, + { id: 3, name: "getTransactionFee", currentValue: 10, proposedValue: 15, unit: "sun/byte" }, + ]); + }); + + it("rejects unknown parameters and invalid boolean values before building", () => { + expect(() => parseChainParameterAssignments(["getMissing=1"], current)).toThrowError( + expect.objectContaining({ code: "unknown_parameter" }), + ); + expect(() => parseChainParameterAssignments(["getAllowMultiSign=2"], current)).toThrowError( + expect.objectContaining({ code: "invalid_value" }), + ); + }); + + it("keeps lossless proposal values as strings when they exceed JS safe integers", () => { + expect(proposalParameters({ "999": "9223372036854775807" })).toEqual([ + { id: 999, name: "parameter-999", value: "9223372036854775807", unit: "" }, + ]); + }); + + it("never carries a current value — a proposal records only what it would set", () => { + expect(proposalParameters({ "3": "15" })).toEqual([ + { id: 3, name: "getTransactionFee", value: 15, unit: "sun/byte" }, + ]); + }); + + it("accepts the full positive Java long range without precision loss", () => { + expect( + parseChainParameterAssignments(["getTotalEnergyLimit=9223372036854775807"], current), + ).toMatchObject([{ id: 17, proposedValue: "9223372036854775807" }]); + }); +}); diff --git a/ts/src/domain/governance/chain-parameters.ts b/ts/src/domain/governance/chain-parameters.ts new file mode 100644 index 000000000..eb1d21792 --- /dev/null +++ b/ts/src/domain/governance/chain-parameters.ts @@ -0,0 +1,242 @@ +import { UsageError } from "../errors/index.js"; + +export interface ChainParameterDefinition { + id: number; + name: string; + unit: string; + min: bigint; + max: bigint; + allowed?: readonly bigint[]; +} + +export interface ChainParameterChange { + id: number; + name: string; + currentValue: number | string | null; + proposedValue: number | string; + unit: string; +} + +export interface ChainParameterValue { + id: number; + name: string; + value: number | string; + unit: string; +} + +const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); +const INT64_MAX = (1n << 63n) - 1n; +const BOOL = [0n, 1n] as const; + +const names: ReadonlyArray = [ + [0, "getMaintenanceTimeInterval"], + [1, "getAccountUpgradeCost"], + [2, "getCreateAccountFee"], + [3, "getTransactionFee"], + [4, "getAssetIssueFee"], + [5, "getWitnessPayPerBlock"], + [6, "getWitnessStandbyAllowance"], + [7, "getCreateNewAccountFeeInSystemContract"], + [8, "getCreateNewAccountBandwidthRate"], + [9, "getAllowCreationOfContracts"], + [10, "getRemoveThePowerOfTheGr"], + [11, "getEnergyFee"], + [12, "getExchangeCreateFee"], + [13, "getMaxCpuTimeOfOneTx"], + [14, "getAllowUpdateAccountName"], + [15, "getAllowSameTokenName"], + [16, "getAllowDelegateResource"], + [17, "getTotalEnergyLimit"], + [18, "getAllowTvmTransferTrc10"], + [19, "getTotalEnergyCurrentLimit"], + [20, "getAllowMultiSign"], + [21, "getAllowAdaptiveEnergy"], + [22, "getUpdateAccountPermissionFee"], + [23, "getMultiSignFee"], + [24, "getAllowProtoFilterNum"], + [25, "getAllowAccountStateRoot"], + [26, "getAllowTvmConstantinople"], + [29, "getAdaptiveResourceLimitMultiplier"], + [30, "getAllowChangeDelegation"], + [31, "getWitness127PayPerBlock"], + [32, "getAllowTvmSolidity059"], + [33, "getAdaptiveResourceLimitTargetRatio"], + [35, "getForbidTransferToContract"], + [39, "getAllowShieldedTRC20Transaction"], + [40, "getAllowPBFT"], + [41, "getAllowTvmIstanbul"], + [44, "getAllowMarketTransaction"], + [45, "getMarketSellFee"], + [46, "getMarketCancelFee"], + [47, "getMaxFeeLimit"], + [48, "getAllowTransactionFeePool"], + [49, "getAllowBlackHoleOptimization"], + [51, "getAllowNewResourceModel"], + [52, "getAllowTvmFreeze"], + [53, "getAllowAccountAssetOptimization"], + [59, "getAllowTvmVote"], + [60, "getAllowTvmCompatibleEvm"], + [61, "getFreeNetLimit"], + [62, "getTotalNetLimit"], + [63, "getAllowTvmLondon"], + [65, "getAllowHigherLimitForMaxCpuTimeOfOneTx"], + [66, "getAllowAssetOptimization"], + [67, "getAllowNewReward"], + [68, "getMemoFee"], + [69, "getAllowDelegateOptimization"], + [70, "getUnfreezeDelayDays"], + [71, "getAllowOptimizedReturnValueOfChainId"], + [72, "getAllowDynamicEnergy"], + [73, "getDynamicEnergyThreshold"], + [74, "getDynamicEnergyIncreaseFactor"], + [75, "getDynamicEnergyMaxFactor"], + [76, "getAllowTvmShanghai"], + [77, "getAllowCancelAllUnfreezeV2"], + [78, "getMaxDelegateLockPeriod"], + [79, "getAllowOldRewardOpt"], + [81, "getAllowEnergyAdjustment"], + [82, "getMaxCreateAccountTxSize"], + [83, "getAllowTvmCancun"], + [87, "getAllowStrictMath"], + [88, "getConsensusLogicOptimization"], + [89, "getAllowTvmBlob"], + [92, "getProposalExpireTime"], + [94, "getAllowTvmSelfdestructRestriction"], + [95, "getAllowTvmPrague"], + [96, "getAllowTvmOsaka"], + [97, "getAllowHardenResourceCalculation"], + [98, "getAllowHardenExchangeCalculation"], +]; + +const booleanIds = new Set([ + 9, 10, 14, 15, 16, 18, 20, 21, 24, 25, 26, 30, 32, 35, 39, 40, 41, 44, 48, 49, 51, 52, 53, 59, 60, + 63, 65, 66, 67, 69, 71, 72, 76, 77, 79, 81, 83, 87, 88, 89, 94, 95, 96, 97, 98, +]); + +const ranges = new Map([ + [0, [81_000n, 86_400_000n]], + [13, [0n, 1_000n]], + [29, [1n, 10_000n]], + [33, [1n, 1_000n]], + [61, [0n, 100_000n]], + [62, [0n, 1_000_000_000_000n]], + [68, [0n, 1_000_000_000n]], + [70, [1n, 365n]], + [74, [0n, 10_000n]], + [75, [0n, 100_000n]], + [78, [86_401n, 10_512_000n]], + [82, [500n, 10_000n]], + [92, [1n, 31_536_003_000n]], +]); + +const units: Readonly> = { + getMaintenanceTimeInterval: "ms", + getAccountUpgradeCost: "sun", + getCreateAccountFee: "sun", + getTransactionFee: "sun/byte", + getAssetIssueFee: "sun", + getWitnessPayPerBlock: "sun", + getWitnessStandbyAllowance: "sun", + getCreateNewAccountFeeInSystemContract: "sun", + getEnergyFee: "sun", + getExchangeCreateFee: "sun", + getMaxCpuTimeOfOneTx: "ms", + getUpdateAccountPermissionFee: "sun", + getMultiSignFee: "sun", + getWitness127PayPerBlock: "sun", + getMarketSellFee: "sun", + getMarketCancelFee: "sun", + getMaxFeeLimit: "sun", + getMemoFee: "sun", + getProposalExpireTime: "ms", +}; + +export const CHAIN_PARAMETER_CATALOG: readonly ChainParameterDefinition[] = names.map( + ([id, name]) => { + const [min, max] = ranges.get(id) ?? [0n, INT64_MAX]; + return { + id, + name, + unit: units[name] ?? "", + min, + max, + ...(booleanIds.has(id) ? { allowed: BOOL } : {}), + }; + }, +); + +const byId = new Map(CHAIN_PARAMETER_CATALOG.map((entry) => [entry.id, entry])); +const byName = new Map(CHAIN_PARAMETER_CATALOG.map((entry) => [entry.name.toLowerCase(), entry])); + +export function chainParameterById(id: number): ChainParameterDefinition | undefined { + return byId.get(id); +} + +export function chainParameterByName(name: string): ChainParameterDefinition | undefined { + return byName.get(name.toLowerCase()); +} + +export function parseChainParameterAssignments( + assignments: readonly string[], + current: ReadonlyArray<{ key: string; value?: number | string }>, +): ChainParameterChange[] { + const currentByName = new Map(current.map((entry) => [entry.key.toLowerCase(), entry.value])); + const selected = new Map(); + for (const assignment of assignments) { + const separator = assignment.indexOf("="); + if (separator <= 0 || separator === assignment.length - 1) { + throw new UsageError( + "invalid_value", + `invalid --set '${assignment}'; expected =`, + ); + } + const key = assignment.slice(0, separator).trim(); + const rawValue = assignment.slice(separator + 1).trim(); + const definition = /^\d+$/.test(key) ? byId.get(Number(key)) : byName.get(key.toLowerCase()); + if (!definition) throw new UsageError("unknown_parameter", `unknown chain parameter: ${key}`); + if (!/^-?\d+$/.test(rawValue)) { + throw new UsageError("invalid_value", `${definition.name} value must be an integer`); + } + const value = BigInt(rawValue); + if (definition.allowed && !definition.allowed.includes(value)) { + throw new UsageError( + "invalid_value", + `${definition.name} must be ${definition.allowed.join(" or ")}`, + ); + } + if (!definition.allowed && (value < definition.min || value > definition.max)) { + throw new UsageError( + "invalid_value", + `${definition.name} must be between ${definition.min} and ${definition.max}`, + ); + } + const exactValue = value <= MAX_SAFE ? Number(value) : value.toString(); + selected.set(definition.id, { + id: definition.id, + name: definition.name, + currentValue: currentByName.get(definition.name.toLowerCase()) ?? null, + proposedValue: exactValue, + unit: definition.unit, + }); + } + return [...selected.values()].sort((left, right) => left.id - right.id); +} + +export function proposalParameters( + parameters: Readonly>, +): ChainParameterValue[] { + return Object.entries(parameters) + .map(([rawId, rawValue]) => { + const id = Number(rawId); + const definition = byId.get(id); + const value = + /^-?\d+$/.test(rawValue) && BigInt(rawValue) <= MAX_SAFE ? Number(rawValue) : rawValue; + return { + id, + name: definition?.name ?? `parameter-${id}`, + value, + unit: definition?.unit ?? "", + }; + }) + .sort((left, right) => left.id - right.id); +} diff --git a/ts/src/domain/governance/create2.test.ts b/ts/src/domain/governance/create2.test.ts new file mode 100644 index 000000000..e0dfb44b2 --- /dev/null +++ b/ts/src/domain/governance/create2.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { computeTronCreate2Address } from "./create2.js"; + +const DEPLOYER = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + +describe("computeTronCreate2Address", () => { + it("matches the Java wallet-cli TVM CREATE2 vector", () => { + expect(computeTronCreate2Address(DEPLOYER, "60006000", "1")).toEqual({ + deployerAddress: DEPLOYER, + salt: 1, + saltHex: "0x0000000000000000000000000000000000000000000000000000000000000001", + codeHash: "5e3ce470a8506d55e59815db7232a08774174ae0c7fdb2fbc81a49e4e242b0d6", + address: "TFVMEWMJCq5fCmADjNzuhKnUFHJkJBBFAW", + }); + }); + + it("encodes a negative Java long with two's-complement in the low 8 bytes", () => { + const result = computeTronCreate2Address(DEPLOYER, "0x60 00", "-1"); + expect(result.saltHex).toBe( + "0x000000000000000000000000000000000000000000000000ffffffffffffffff", + ); + }); + + it("rejects Ethereum-style hex salts and an invalid deployer", () => { + expect(() => computeTronCreate2Address(DEPLOYER, "6000", "0x01")).toThrowError( + expect.objectContaining({ code: "invalid_value" }), + ); + expect(() => + computeTronCreate2Address("0x0000000000000000000000000000000000000000", "6000", "1"), + ).toThrowError(expect.objectContaining({ code: "invalid_address" })); + }); +}); diff --git a/ts/src/domain/governance/create2.ts b/ts/src/domain/governance/create2.ts new file mode 100644 index 000000000..2aba3d282 --- /dev/null +++ b/ts/src/domain/governance/create2.ts @@ -0,0 +1,63 @@ +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { bytesToHex, concatBytes, hexToBytes } from "@noble/hashes/utils.js"; +import { UsageError } from "../errors/index.js"; +import { tronBase58ToBytes, tronBytesToBase58 } from "../address/index.js"; + +const MIN_INT64 = -(1n << 63n); +const MAX_INT64 = (1n << 63n) - 1n; + +export interface TronCreate2Result { + deployerAddress: string; + salt: number | string; + saltHex: string; + codeHash: string; + address: string; +} + +/** Compute the TVM CREATE2 address with the exact formula used by Java wallet-cli. */ +export function computeTronCreate2Address( + deployerAddress: string, + creationCode: string, + decimalSalt: string, +): TronCreate2Result { + let deployer: Uint8Array; + try { + deployer = tronBase58ToBytes(deployerAddress); + } catch { + throw new UsageError("invalid_address", `invalid TRON deployer address: ${deployerAddress}`); + } + + const codeHex = creationCode.replace(/^\s*0x/i, "").replace(/\s+/g, ""); + if (!codeHex || codeHex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(codeHex)) { + throw new UsageError("invalid_value", "creation bytecode must be non-empty, even-length hex"); + } + if (!/^-?\d+$/.test(decimalSalt)) { + throw new UsageError("invalid_value", "salt must be a decimal signed 64-bit integer"); + } + const salt = BigInt(decimalSalt); + if (salt < MIN_INT64 || salt > MAX_INT64) { + throw new UsageError("invalid_value", "salt is outside the signed 64-bit range"); + } + + // Java wallet-cli writes Longs.toByteArray(salt) into bytes 24..31 of a zeroed 32-byte salt. + const saltBytes = new Uint8Array(32); + new DataView(saltBytes.buffer).setBigInt64(24, salt, false); + const codeHashBytes = keccak_256(hexToBytes(codeHex)); + const digest = keccak_256(concatBytes(deployer, saltBytes, codeHashBytes)); + // Hash.sha3omit12 on TRON returns 0x41 || digest[12..31]. + const payload = new Uint8Array(21); + payload[0] = 0x41; + payload.set(digest.slice(12), 1); + + const safeSalt = + salt >= BigInt(Number.MIN_SAFE_INTEGER) && salt <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(salt) + : salt.toString(); + return { + deployerAddress, + salt: safeSalt, + saltHex: `0x${bytesToHex(saltBytes)}`, + codeHash: bytesToHex(codeHashBytes), + address: tronBytesToBase58(payload), + }; +} diff --git a/ts/src/domain/keystore/index.ts b/ts/src/domain/keystore/index.ts new file mode 100644 index 000000000..0c5f7440c --- /dev/null +++ b/ts/src/domain/keystore/index.ts @@ -0,0 +1,202 @@ +/** + * Web3 keystore crypto — the shared scrypt/AES/MAC construction, plus the standard **V3 file** + * codec used to interoperate with other wallets (TronLink, the Java wallet-cli). + * + * Two audiences, deliberately separated: + * - `Web3Crypto` is the primitive construction (scrypt|pbkdf2 → aes-128-ctr → keccak MAC). Our + * private at-rest vault (`CryptoEnvelope`, `version: 1`) and the V3 interop file both use it, + * so the MAC is defined exactly once. + * - `KeystoreV3` is the on-the-wire *file format*: `{version: 3, id, address, crypto}` wrapping a + * single raw 32-byte private key. It never sees our vault's wrapper (`type`, `version: 1`) or + * its seed payload — a V3 keystore holds one key and nothing derivable. + * + * Asymmetric by design: we WRITE scrypt only, but READ scrypt or pbkdf2, matching the accept set of + * the Java implementation (`Wallet.java`) so anything it or TronLink can open, we can open — with + * one deliberate exception: a derived key shorter than the private key it must authenticate is + * refused (see `deriveKey`), because accepting it would mean accepting any password. + */ +import { randomUUID } from "node:crypto"; +import { scrypt } from "@noble/hashes/scrypt.js"; +import { pbkdf2 } from "@noble/hashes/pbkdf2.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { ctr } from "@noble/ciphers/aes.js"; +import { + randomBytes, + bytesToHex, + hexToBytes, + utf8ToBytes, + concatBytes, +} from "@noble/hashes/utils.js"; +import type { Bytes } from "../types/index.js"; +import { ExecutionError, UsageError } from "../errors/index.js"; + +/** scrypt work factor we WRITE with. Matches the Java implementation's N_STANDARD (1 << 18). */ +export const SCRYPT_STANDARD = { n: 262144, r: 8, p: 1, dklen: 32 } as const; + +const PRIVATE_KEY_BYTES = 32; + +export const Web3Crypto = { + scryptKey( + password: string, + salt: Bytes, + p: { n: number; r: number; p: number; dklen: number }, + ): Bytes { + return scrypt(utf8ToBytes(password), salt, { N: p.n, r: p.r, p: p.p, dkLen: p.dklen }); + }, + + /** keccak256(dk[16:32] || ciphertext) — the Web3 keystore MAC over the *derived* key's second half. */ + mac(dk: Bytes, ciphertext: Bytes): Bytes { + return keccak_256(concatBytes(dk.slice(16, 32), ciphertext)); + }, + + /** aes-128-ctr is its own inverse here; one function serves both directions. */ + crypt(dk: Bytes, iv: Bytes, data: Bytes): Bytes { + return ctr(dk.slice(0, 16), iv).encrypt(data); + }, +}; + +/** A standard Web3 V3 keystore file. `address` is TRON's 21-byte hex form (`41…`), as written by + * the Java implementation's `exportKeystore`, so TronLink round-trips it. */ +export interface KeystoreV3File { + version: 3; + id: string; + address: string; + crypto: { + cipher: "aes-128-ctr"; + ciphertext: string; + cipherparams: { iv: string }; + kdf: "scrypt"; + kdfparams: { n: number; r: number; p: number; dklen: number; salt: string }; + mac: string; + }; +} + +const invalid = (why: string) => + new UsageError("invalid_keystore", `not a valid V3 keystore: ${why}`); + +export const KeystoreV3 = { + /** Wrap ONE raw private key as a V3 file. `hexAddress` is recorded for other wallets to display; + * it is never trusted on the way back in (the key is the truth — see `decrypt`). */ + encrypt(privateKey: Bytes, password: string, hexAddress: string): KeystoreV3File { + if (privateKey.length !== PRIVATE_KEY_BYTES) { + throw new ExecutionError( + "encoding_error", + `a keystore holds a ${PRIVATE_KEY_BYTES}-byte private key, got ${privateKey.length}`, + ); + } + const salt = randomBytes(32); + const iv = randomBytes(16); + const dk = Web3Crypto.scryptKey(password, salt, SCRYPT_STANDARD); + const ciphertext = Web3Crypto.crypt(dk, iv, privateKey); + return { + version: 3, + id: randomUUID(), + address: hexAddress, + crypto: { + cipher: "aes-128-ctr", + ciphertext: bytesToHex(ciphertext), + cipherparams: { iv: bytesToHex(iv) }, + kdf: "scrypt", + kdfparams: { ...SCRYPT_STANDARD, salt: bytesToHex(salt) }, + mac: bytesToHex(Web3Crypto.mac(dk, ciphertext)), + }, + }; + }, + + /** + * Recover the private key from a parsed V3 keystore of ANY origin. Structure is validated before + * the password is used, so a malformed file is reported as such instead of as a wrong password. + * The file's own `address` is ignored: only the key it actually decrypts to can be trusted. + */ + decrypt(file: unknown, password: string): Bytes { + const f = asRecord(file, "not a JSON object"); + // Every reader in the wild (incl. Java's, which hard-rejects other versions) speaks v3 only. + if (f.version !== 3) throw invalid(`version must be 3, got ${JSON.stringify(f.version)}`); + const c = asRecord(f.crypto, "missing crypto section"); + if (c.cipher !== "aes-128-ctr") throw invalid(`unsupported cipher ${JSON.stringify(c.cipher)}`); + + const ciphertext = hexField(c.ciphertext, "crypto.ciphertext"); + const iv = hexField( + asRecord(c.cipherparams, "missing crypto.cipherparams").iv, + "crypto.cipherparams.iv", + ); + const dk = deriveKey(c, password); + + // Through hexField like every other hex field, then compared as BYTES: `A1B2` and `a1b2` are the + // same MAC, and comparing our lowercase rendering against the file's own spelling reported a + // valid keystore as a wrong password. It also keeps a missing or non-hex mac reported as the + // malformed file it is, rather than as a password the reader would then go and "fix". + const mac = hexField(c.mac, "crypto.mac"); + if (!equalBytes(Web3Crypto.mac(dk, ciphertext), mac)) { + throw new ExecutionError("wrong_keystore_password", "incorrect keystore file password"); + } + const plaintext = Web3Crypto.crypt(dk, iv, ciphertext); + // A V3 keystore carries exactly one private key. Anything else (a re-wrapped seed vault, a + // truncated file) decrypts and MACs fine yet is not a key — reject it rather than import junk. + if (plaintext.length !== PRIVATE_KEY_BYTES) { + throw invalid( + `decrypted payload is ${plaintext.length} bytes, expected a ${PRIVATE_KEY_BYTES}-byte private key`, + ); + } + return plaintext; + }, +}; + +/** scrypt or pbkdf2 (hmac-sha256) — the two KDFs Java's importer accepts. */ +function deriveKey(c: Record, password: string): Bytes { + const p = asRecord(c.kdfparams, "missing crypto.kdfparams"); + const salt = hexField(p.salt, "crypto.kdfparams.salt"); + const dklen = intField(p.dklen, "crypto.kdfparams.dklen"); + // The MAC authenticates the password only through dk[16:32]. Below 32 bytes that slice is empty + // and the MAC collapses to keccak(ciphertext) — a constant the file's author chooses — so every + // password passes and decrypts to a different, unknowable key. Java is not fooled here (its + // pbkdf2 path ignores dklen and derives 32; its scrypt path throws), so this is parity, not + // divergence. + if (dklen < PRIVATE_KEY_BYTES) { + throw invalid( + `crypto.kdfparams.dklen must be at least ${PRIVATE_KEY_BYTES}, got ${dklen}: a shorter derived key cannot bind the MAC to the password`, + ); + } + if (c.kdf === "scrypt") { + return Web3Crypto.scryptKey(password, salt, { + n: intField(p.n, "crypto.kdfparams.n"), + r: intField(p.r, "crypto.kdfparams.r"), + p: intField(p.p, "crypto.kdfparams.p"), + dklen, + }); + } + if (c.kdf === "pbkdf2") { + if (p.prf !== undefined && p.prf !== "hmac-sha256") + throw invalid(`unsupported pbkdf2 prf ${JSON.stringify(p.prf)}`); + return pbkdf2(sha256, utf8ToBytes(password), salt, { + c: intField(p.c, "crypto.kdfparams.c"), + dkLen: dklen, + }); + } + throw invalid(`unsupported kdf ${JSON.stringify(c.kdf)}`); +} + +function asRecord(value: unknown, why: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid(why); + return value as Record; +} + +/** Same bytes, same MAC — no timing claim: whoever can time this already holds the file and can + * try passwords offline, so the comparison is plain. */ +function equalBytes(a: Bytes, b: Bytes): boolean { + return a.length === b.length && a.every((byte, i) => byte === b[i]); +} + +function hexField(value: unknown, field: string): Bytes { + if (typeof value !== "string" || !/^[0-9a-fA-F]*$/.test(value) || value.length % 2 !== 0) { + throw invalid(`${field} is not a hex string`); + } + return hexToBytes(value); +} + +function intField(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) + throw invalid(`${field} is not a positive integer`); + return value; +} diff --git a/ts/src/domain/keystore/keystore-v3.test.ts b/ts/src/domain/keystore/keystore-v3.test.ts new file mode 100644 index 000000000..7f02957a1 --- /dev/null +++ b/ts/src/domain/keystore/keystore-v3.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; +import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js"; +import { pbkdf2 } from "@noble/hashes/pbkdf2.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { ctr } from "@noble/ciphers/aes.js"; +import { KeystoreV3, Web3Crypto } from "./index.js"; + +const KEY = hexToBytes("4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d"); +const ADDRESS = "41f0cc5a2b8d4e7f9c1a3b5d7e9f0a2c4b6d8e0f12"; +const PW = "Str0ng!pass"; + +// A light scrypt (n=2^10) keeps the round-trip test fast; the codec reads whatever n the file +// declares. Export always writes n=2^18, asserted separately below. +function lightV3(privateKey = KEY, password = PW) { + const salt = new Uint8Array(32).fill(7); + const iv = new Uint8Array(16).fill(3); + const kdfparams = { n: 1024, r: 8, p: 1, dklen: 32 }; + const dk = Web3Crypto.scryptKey(password, salt, kdfparams); + const ciphertext = Web3Crypto.crypt(dk, iv, privateKey); + return { + version: 3, + id: "aa0f2c1e-0000-4000-8000-000000000001", + address: ADDRESS, + crypto: { + cipher: "aes-128-ctr", + ciphertext: bytesToHex(ciphertext), + cipherparams: { iv: bytesToHex(iv) }, + kdf: "scrypt", + kdfparams: { ...kdfparams, salt: bytesToHex(salt) }, + mac: bytesToHex(Web3Crypto.mac(dk, ciphertext)), + }, + }; +} + +/** A pbkdf2 keystore — the other KDF Java's importer accepts, which we must read but never write. */ +function pbkdf2V3() { + const salt = new Uint8Array(32).fill(9); + const iv = new Uint8Array(16).fill(5); + const dk = pbkdf2(sha256, utf8ToBytes(PW), salt, { c: 4096, dkLen: 32 }); + const ciphertext = ctr(dk.slice(0, 16), iv).encrypt(KEY); + return { + version: 3, + id: "aa0f2c1e-0000-4000-8000-000000000002", + address: ADDRESS, + crypto: { + cipher: "aes-128-ctr", + ciphertext: bytesToHex(ciphertext), + cipherparams: { iv: bytesToHex(iv) }, + kdf: "pbkdf2", + kdfparams: { c: 4096, dklen: 32, prf: "hmac-sha256", salt: bytesToHex(salt) }, + mac: bytesToHex(Web3Crypto.mac(dk, ciphertext)), + }, + }; +} + +describe("KeystoreV3.encrypt", () => { + it("writes the standard V3 wrapper — version 3, uuid id, address, no internal type tag", () => { + const file = KeystoreV3.encrypt(KEY, PW, ADDRESS); + expect(file.version).toBe(3); + expect(file.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(file.address).toBe(ADDRESS); + expect(Object.keys(file).sort()).toEqual(["address", "crypto", "id", "version"]); + expect(file).not.toHaveProperty("type"); + }); + + it("writes scrypt at the Java N_STANDARD work factor with aes-128-ctr", () => { + const { crypto } = KeystoreV3.encrypt(KEY, PW, ADDRESS); + expect(crypto.cipher).toBe("aes-128-ctr"); + expect(crypto.kdf).toBe("scrypt"); + expect(crypto.kdfparams).toMatchObject({ n: 262144, r: 8, p: 1, dklen: 32 }); + expect(crypto.ciphertext).toHaveLength(64); // 32-byte key, ctr is length-preserving + }); + + it("round-trips its own output", () => { + const file = KeystoreV3.encrypt(KEY, PW, ADDRESS); + expect(bytesToHex(KeystoreV3.decrypt(file, PW))).toBe(bytesToHex(KEY)); + }); + + it("uses a fresh salt and iv per call, so the same key never yields the same ciphertext", () => { + const a = KeystoreV3.encrypt(KEY, PW, ADDRESS); + const b = KeystoreV3.encrypt(KEY, PW, ADDRESS); + expect(a.crypto.ciphertext).not.toBe(b.crypto.ciphertext); + expect(a.crypto.kdfparams.salt).not.toBe(b.crypto.kdfparams.salt); + expect(a.crypto.cipherparams.iv).not.toBe(b.crypto.cipherparams.iv); + }); + + it("refuses a payload that is not a 32-byte private key", () => { + expect(() => KeystoreV3.encrypt(KEY.slice(0, 16), PW, ADDRESS)).toThrowError( + /32-byte private key/, + ); + }); +}); + +describe("KeystoreV3.decrypt", () => { + it("reads a scrypt keystore", () => { + expect(bytesToHex(KeystoreV3.decrypt(lightV3(), PW))).toBe(bytesToHex(KEY)); + }); + + it("reads a pbkdf2 keystore — the KDF we accept but never emit", () => { + expect(bytesToHex(KeystoreV3.decrypt(pbkdf2V3(), PW))).toBe(bytesToHex(KEY)); + }); + + it("reports a wrong file password distinctly from a malformed file", () => { + expect(() => KeystoreV3.decrypt(lightV3(), "not-the-password")).toThrowError( + /incorrect keystore file password/, + ); + try { + KeystoreV3.decrypt(lightV3(), "not-the-password"); + } catch (e: any) { + expect(e.code).toBe("wrong_keystore_password"); + } + }); + + it.each([ + ["a non-object", 42, /not a JSON object/], + [ + "our own version-1 vault blob", + { version: 1, type: "raw-privkey", id: "key_x", crypto: lightV3().crypto }, + /version must be 3/, + ], + [ + "an unsupported cipher", + { ...lightV3(), crypto: { ...lightV3().crypto, cipher: "aes-256-gcm" } }, + /unsupported cipher/, + ], + [ + "an unknown kdf", + { ...lightV3(), crypto: { ...lightV3().crypto, kdf: "argon2" } }, + /unsupported kdf/, + ], + [ + "a non-hex ciphertext", + { ...lightV3(), crypto: { ...lightV3().crypto, ciphertext: "zz" } }, + /ciphertext is not a hex string/, + ], + [ + "a missing crypto section", + { version: 3, id: "x", address: ADDRESS }, + /missing crypto section/, + ], + ])("rejects %s before the password is used", (_label, file, message) => { + expect(() => KeystoreV3.decrypt(file, PW)).toThrowError(message as RegExp); + try { + KeystoreV3.decrypt(file, PW); + } catch (e: any) { + expect(e.code).toBe("invalid_keystore"); + } + }); + + it("rejects a MAC-valid file whose payload is not a 32-byte key", () => { + // e.g. someone re-wrapped a seed vault's JSON plaintext in a V3 envelope: it decrypts cleanly, + // so only the length check catches it. + const file = lightV3(utf8ToBytes(JSON.stringify({ v: 1, entropy: "00".repeat(16) }))); + expect(() => KeystoreV3.decrypt(file, PW)).toThrowError(/expected a 32-byte private key/); + }); + + it("ignores the file's address field — the decrypted key is the only source of identity", () => { + const file = { ...lightV3(), address: "41deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }; + expect(bytesToHex(KeystoreV3.decrypt(file, PW))).toBe(bytesToHex(KEY)); + }); +}); + +/** + * The Web3 MAC is keccak(dk[16:32] || ciphertext) — it authenticates the password only because + * dk[16:32] is derived from it. A file declaring `dklen: 16` makes that slice EMPTY, so the MAC + * degenerates to keccak(ciphertext): a value the file's author fixes, independent of any password. + * Every password then passes the check and decrypts the ciphertext to different 32 random bytes, + * which is a well-formed private key. The import reports success and the user holds an address + * nobody knows the key to — funds sent there are burned. + * + * Both KDFs in the accept set are affected, and both are safe in the Java implementation this codec + * claims parity with: its pbkdf2 path ignores `dklen` and always derives 32 bytes, and its scrypt + * path would throw copying a 16-byte slice. Refusing dklen < 32 is therefore not a divergence from + * Java — it is catching up with it. + */ +describe("V3 import rejects a derived key too short to authenticate the password", () => { + const shortDklen = (kdf: "pbkdf2" | "scrypt", password: string) => { + const salt = new Uint8Array(32).fill(11); + const iv = new Uint8Array(16).fill(13); + const kdfparams = + kdf === "pbkdf2" + ? { c: 1, prf: "hmac-sha256", dklen: 16, salt: bytesToHex(salt) } + : { n: 1024, r: 8, p: 1, dklen: 16, salt: bytesToHex(salt) }; + const dk = + kdf === "pbkdf2" + ? pbkdf2(sha256, utf8ToBytes(password), salt, { c: 1, dkLen: 16 }) + : Web3Crypto.scryptKey(password, salt, { n: 1024, r: 8, p: 1, dklen: 16 }); + const ciphertext = ctr(dk.slice(0, 16), iv).encrypt(KEY); + return { + version: 3, + id: "aa0f2c1e-0000-4000-8000-000000000002", + address: ADDRESS, + crypto: { + cipher: "aes-128-ctr", + ciphertext: bytesToHex(ciphertext), + cipherparams: { iv: bytesToHex(iv) }, + kdf, + kdfparams, + // dk[16:32] is empty, so this is keccak(ciphertext) — no password involved. + mac: bytesToHex(Web3Crypto.mac(dk, ciphertext)), + }, + }; + }; + + it.each(["pbkdf2", "scrypt"] as const)("refuses a %s file declaring dklen 16", (kdf) => { + expect(() => KeystoreV3.decrypt(shortDklen(kdf, PW), PW)).toThrowError(/dklen/i); + }); + + // The decisive property: without the guard BOTH of these succeed, each yielding a different key. + it.each(["pbkdf2", "scrypt"] as const)( + "refuses a %s file under any password, not just the wrong one", + (kdf) => { + const file = shortDklen(kdf, PW); + for (const attempt of [PW, "completely-different-password", ""]) { + expect(() => KeystoreV3.decrypt(file, attempt)).toThrowError(/dklen/i); + } + }, + ); + + it("still accepts the standard dklen 32", () => { + expect(KeystoreV3.decrypt(lightV3(), PW)).toEqual(KEY); + }); +}); + +/** + * The MAC is a hex string, and `A1B2` and `a1b2` are the same bytes. It was the one hex field that + * skipped `hexField` and was compared as a STRING against our lowercase rendering, so a file written + * with uppercase hex — legal, and what `Arrays.equals` in the Java implementation accepts without + * noticing — came back as `wrong_keystore_password`. Two things wrong with that: a valid file is + * refused, and the refusal sends the reader to fix a password that was never wrong. + * + * The same misreport covered a malformed file: a missing or non-string `mac` compared unequal and + * was also reported as a bad password, which the codec's own contract says it should not be + * ("a malformed file is reported as such instead of as a wrong password"). + */ +describe("V3 import compares the MAC by value, not by how it was written", () => { + const withMac = (mac: unknown) => { + const file = lightV3() as unknown as { crypto: Record }; + file.crypto.mac = mac; + return file; + }; + const macOf = () => (lightV3() as unknown as { crypto: { mac: string } }).crypto.mac; + + it.each([ + ["uppercase", (m: string) => m.toUpperCase()], + ["mixed case", (m: string) => m.slice(0, 8).toUpperCase() + m.slice(8)], + ])("accepts a correct MAC written in %s", (_label, rewrite) => { + expect(KeystoreV3.decrypt(withMac(rewrite(macOf())), PW)).toEqual(KEY); + }); + + it("still rejects a wrong password as a wrong password", () => { + expect(() => KeystoreV3.decrypt(lightV3(), "not-the-password")).toThrowError( + /incorrect keystore file password/, + ); + }); + + it.each([ + ["missing", undefined], + ["not a string", 123], + ["not hex", "zzzz"], + ["an odd number of digits", "abc"], + ])("reports a MAC that is %s as a malformed file, not a bad password", (_label, mac) => { + expect(() => KeystoreV3.decrypt(withMac(mac), PW)).toThrowError(/not a valid V3 keystore/); + }); +}); diff --git a/ts/src/domain/permission/index.ts b/ts/src/domain/permission/index.ts index 0a80aa3ca..237ce3892 100644 --- a/ts/src/domain/permission/index.ts +++ b/ts/src/domain/permission/index.ts @@ -28,10 +28,18 @@ export const TRON_OPERATIONS: readonly TronOperation[] = Object.freeze([ { contractTypeId: 2, contractType: "TransferAssetContract", label: "Transfer TRC10" }, { contractTypeId: 3, contractType: "VoteAssetContract", label: "Vote for TRC10 [unused]" }, { contractTypeId: 4, contractType: "VoteWitnessContract", label: "Vote" }, - { contractTypeId: 5, contractType: "WitnessCreateContract", label: "Apply to Become a SR Candidate" }, + { + contractTypeId: 5, + contractType: "WitnessCreateContract", + label: "Apply to Become a SR Candidate", + }, { contractTypeId: 6, contractType: "AssetIssueContract", label: "Issue TRC10" }, { contractTypeId: 8, contractType: "WitnessUpdateContract", label: "Update SR Info" }, - { contractTypeId: 9, contractType: "ParticipateAssetIssueContract", label: "Participate in TRC10 Issuance" }, + { + contractTypeId: 9, + contractType: "ParticipateAssetIssueContract", + label: "Participate in TRC10 Issuance", + }, { contractTypeId: 10, contractType: "AccountUpdateContract", label: "Update Account Name" }, { contractTypeId: 11, contractType: "FreezeBalanceContract", label: "TRX Stake (1.0)" }, { contractTypeId: 12, contractType: "UnfreezeBalanceContract", label: "TRX Unstake (1.0)" }, @@ -46,28 +54,68 @@ export const TRON_OPERATIONS: readonly TronOperation[] = Object.freeze([ { contractTypeId: 30, contractType: "CreateSmartContract", label: "Create Smart Contract" }, { contractTypeId: 31, contractType: "TriggerSmartContract", label: "Trigger Smart Contract" }, { contractTypeId: 32, contractType: "GetContract", label: "Get Contract" }, - { contractTypeId: 33, contractType: "UpdateSettingContract", label: "Update Contract Parameters" }, - { contractTypeId: 41, contractType: "ExchangeCreateContract", label: "Create Bancor Transaction" }, - { contractTypeId: 42, contractType: "ExchangeInjectContract", label: "Inject Assets into Bancor Transaction" }, - { contractTypeId: 43, contractType: "ExchangeWithdrawContract", label: "Withdraw Assets from Bancor Transaction" }, - { contractTypeId: 44, contractType: "ExchangeTransactionContract", label: "Execute Bancor Transaction" }, - { contractTypeId: 45, contractType: "UpdateEnergyLimitContract", label: "Update Contract Energy Limit" }, - { contractTypeId: 46, contractType: "AccountPermissionUpdateContract", label: "Update Account Permissions" }, + { + contractTypeId: 33, + contractType: "UpdateSettingContract", + label: "Update Contract Parameters", + }, + { + contractTypeId: 41, + contractType: "ExchangeCreateContract", + label: "Create Bancor Transaction", + }, + { + contractTypeId: 42, + contractType: "ExchangeInjectContract", + label: "Inject Assets into Bancor Transaction", + }, + { + contractTypeId: 43, + contractType: "ExchangeWithdrawContract", + label: "Withdraw Assets from Bancor Transaction", + }, + { + contractTypeId: 44, + contractType: "ExchangeTransactionContract", + label: "Execute Bancor Transaction", + }, + { + contractTypeId: 45, + contractType: "UpdateEnergyLimitContract", + label: "Update Contract Energy Limit", + }, + { + contractTypeId: 46, + contractType: "AccountPermissionUpdateContract", + label: "Update Account Permissions", + }, { contractTypeId: 48, contractType: "ClearABIContract", label: "Clear Contract ABI" }, - { contractTypeId: 49, contractType: "UpdateBrokerageContract", label: "Update SR Commission Ratio" }, + { + contractTypeId: 49, + contractType: "UpdateBrokerageContract", + label: "Update SR Commission Ratio", + }, { contractTypeId: 51, contractType: "ShieldedTransferContract", label: "Shielded Transfer" }, { contractTypeId: 52, contractType: "MarketSellAssetContract", label: "Market Sell Asset" }, { contractTypeId: 53, contractType: "MarketCancelOrderContract", label: "Market Cancel Order" }, { contractTypeId: 54, contractType: "FreezeBalanceV2Contract", label: "TRX Stake (2.0)" }, { contractTypeId: 55, contractType: "UnfreezeBalanceV2Contract", label: "TRX Unstake (2.0)" }, - { contractTypeId: 56, contractType: "WithdrawExpireUnfreezeContract", label: "Withdraw Unstaked TRX" }, + { + contractTypeId: 56, + contractType: "WithdrawExpireUnfreezeContract", + label: "Withdraw Unstaked TRX", + }, { contractTypeId: 57, contractType: "DelegateResourceContract", label: "Delegate Resources" }, { contractTypeId: 58, contractType: "UnDelegateResourceContract", label: "Reclaim Resources" }, { contractTypeId: 59, contractType: "CancelAllUnfreezeV2Contract", label: "Cancel Unstake" }, ]); -const operationByType = new Map(TRON_OPERATIONS.map((operation) => [operation.contractType, operation])); -const operationById = new Map(TRON_OPERATIONS.map((operation) => [operation.contractTypeId, operation])); +const operationByType = new Map( + TRON_OPERATIONS.map((operation) => [operation.contractType, operation]), +); +const operationById = new Map( + TRON_OPERATIONS.map((operation) => [operation.contractTypeId, operation]), +); function invalidPermission(message: string, details?: object): never { throw new UsageError("invalid_permission", message, details); @@ -89,7 +137,11 @@ function safePositiveInteger(value: unknown, field: string): number { text = value.toString(); } else if (typeof value === "string" && /^(?:0|[1-9][0-9]*)$/.test(value)) { text = value; - } else if (value && typeof value === "object" && typeof (value as { toString?: unknown }).toString === "function") { + } else if ( + value && + typeof value === "object" && + typeof (value as { toString?: unknown }).toString === "function" + ) { text = String(value); } else { return invalidPermission(`${field} must be an integer`); @@ -111,19 +163,23 @@ function exactInteger(value: unknown, expected: number, field: string): number { function permissionName(value: unknown, fallback: string, field: string): string { const name = value === undefined ? fallback : value; - if (typeof name !== "string" || name.length === 0) return invalidPermission(`${field} must not be empty`); + if (typeof name !== "string" || name.length === 0) + return invalidPermission(`${field} must not be empty`); if (Buffer.byteLength(name, "utf8") > MAX_PERMISSION_NAME_BYTES) { return invalidPermission(`${field} must be at most ${MAX_PERMISSION_NAME_BYTES} UTF-8 bytes`); } - if (/\p{Cc}/u.test(name)) return invalidPermission(`${field} must not contain control characters`); + if (/\p{Cc}/u.test(name)) + return invalidPermission(`${field} must not contain control characters`); return name; } function keys(value: unknown, field: string, exactCount?: number): PermissionKeyView[] { - if (!Array.isArray(value) - || value.length < 1 - || value.length > MAX_KEYS - || (exactCount !== undefined && value.length !== exactCount)) { + if ( + !Array.isArray(value) || + value.length < 1 || + value.length > MAX_KEYS || + (exactCount !== undefined && value.length !== exactCount) + ) { return invalidPermission( exactCount === undefined ? `${field} must contain 1 to ${MAX_KEYS} keys` @@ -154,7 +210,8 @@ function group(value: unknown, kind: "owner" | "witness", expectedId: 0 | 1): Pe const parsedKeys = keys(input.keys, `${kind}.keys`, kind === "witness" ? 1 : undefined); const threshold = safePositiveInteger(input.threshold, `${kind}.threshold`); const total = parsedKeys.reduce((sum, key) => sum + BigInt(key.weight), 0n); - if (BigInt(threshold) > total) return invalidPermission(`${kind}.threshold exceeds the total key weight`); + if (BigInt(threshold) > total) + return invalidPermission(`${kind}.threshold exceeds the total key weight`); return { id: exactInteger(input.id, expectedId, `${kind}.id`), name: permissionName(input.name, kind, `${kind}.name`), @@ -199,7 +256,8 @@ export function encodeOperations(contractTypes: readonly string[]): string { const bytes = Buffer.alloc(OPERATIONS_BYTES); const seen = new Set(); for (const contractType of contractTypes) { - if (typeof contractType !== "string") return invalidPermission("active.operations entries must be strings"); + if (typeof contractType !== "string") + return invalidPermission("active.operations entries must be strings"); if (seen.has(contractType)) continue; seen.add(contractType); const operation = operationByType.get(contractType); @@ -214,7 +272,11 @@ function isEmptyUnknownList(value: unknown): boolean { } /** Require unnamed bitmap bits to be declared verbatim, so no set bit escapes human review. */ -function assertDeclaredUnknownOperations(value: unknown, actual: readonly number[], index: number): void { +function assertDeclaredUnknownOperations( + value: unknown, + actual: readonly number[], + index: number, +): void { const field = `actives[${index}].unknownOperationIds`; if (value === undefined) { if (actual.length === 0) return; @@ -222,7 +284,10 @@ function assertDeclaredUnknownOperations(value: unknown, actual: readonly number `${field} must declare the unnamed contract types set in operationsHex: ${actual.join(", ")}`, ); } - if (!Array.isArray(value) || value.some((id) => typeof id !== "number" || !Number.isSafeInteger(id))) { + if ( + !Array.isArray(value) || + value.some((id) => typeof id !== "number" || !Number.isSafeInteger(id)) + ) { return invalidPermission(`${field} must be an array of integers`); } const declaredIds = value as number[]; @@ -230,7 +295,10 @@ function assertDeclaredUnknownOperations(value: unknown, actual: readonly number return invalidPermission(`${field} must not contain duplicate contract type ids`); } const declared = [...declaredIds].sort((a, b) => a - b); - if (declared.length !== actual.length || declared.some((id, position) => id !== actual[position])) { + if ( + declared.length !== actual.length || + declared.some((id, position) => id !== actual[position]) + ) { return invalidPermission( `${field} does not match operationsHex: declared ${declared.join(", ") || "none"}, bitmap sets ${actual.join(", ") || "none"}`, ); @@ -239,7 +307,12 @@ function assertDeclaredUnknownOperations(value: unknown, actual: readonly number function activeGroup(value: unknown, index: number): ActivePermissionView { const input = ownRecord(value, `actives[${index}]`); - if (typeof input.id !== "number" || !Number.isSafeInteger(input.id) || input.id < 2 || input.id > 9) { + if ( + typeof input.id !== "number" || + !Number.isSafeInteger(input.id) || + input.id < 2 || + input.id > 9 + ) { return invalidPermission(`actives[${index}].id must be an integer from 2 to 9`); } if (!Array.isArray(input.operations)) { @@ -248,9 +321,10 @@ function activeGroup(value: unknown, index: number): ActivePermissionView { const declaredOperations = input.operations as string[]; // A node may set bits for contract types this build has no name for. Those bits are real // permission scope, so `operations` alone cannot describe the bitmap — hence the empty-list case. - const encodedKnownOperations = declaredOperations.length === 0 - ? "00".repeat(OPERATIONS_BYTES) - : encodeOperations(declaredOperations); + const encodedKnownOperations = + declaredOperations.length === 0 + ? "00".repeat(OPERATIONS_BYTES) + : encodeOperations(declaredOperations); let operationsHex = encodedKnownOperations; if (input.operationsHex !== undefined) { if (typeof input.operationsHex !== "string") { @@ -259,12 +333,14 @@ function activeGroup(value: unknown, index: number): ActivePermissionView { const supplied = decodeOperations(input.operationsHex); const known = decodeOperations(encodedKnownOperations); if ( - supplied.operations.length !== known.operations.length - || supplied.operations.some((operation, operationIndex) => operation !== known.operations[operationIndex]) + supplied.operations.length !== known.operations.length || + supplied.operations.some( + (operation, operationIndex) => operation !== known.operations[operationIndex], + ) ) { return invalidPermission( - `actives[${index}].operationsHex does not match operations; remove operationsHex to regenerate ` - + "it from operations, or edit both together", + `actives[${index}].operationsHex does not match operations; remove operationsHex to regenerate ` + + "it from operations, or edit both together", ); } // Every set bit must be declared somewhere a reviewer can read: named types in `operations`, @@ -276,7 +352,10 @@ function activeGroup(value: unknown, index: number): ActivePermissionView { // produces — so it needs no bitmap to justify it. `permission show` always emits the field, and // demanding operationsHex alongside an empty list blocked the documented show/edit/update round // trip. Only a non-empty list still requires the bitmap it was read from. - } else if (input.unknownOperationIds !== undefined && !isEmptyUnknownList(input.unknownOperationIds)) { + } else if ( + input.unknownOperationIds !== undefined && + !isEmptyUnknownList(input.unknownOperationIds) + ) { return invalidPermission( `actives[${index}].unknownOperationIds requires operationsHex — operations cannot express unnamed contract types`, ); @@ -304,9 +383,16 @@ function activeGroup(value: unknown, index: number): ActivePermissionView { } /** Strictly validate and canonicalize the complete replacement structure. */ -export function validatePermissionStructure(value: unknown, expectedAddress?: string): AccountPermissionsView { +export function validatePermissionStructure( + value: unknown, + expectedAddress?: string, +): AccountPermissionsView { const input = ownRecord(value, "permission structure"); - if (expectedAddress !== undefined && input.address !== undefined && input.address !== expectedAddress) { + if ( + expectedAddress !== undefined && + input.address !== undefined && + input.address !== expectedAddress + ) { return invalidPermission("permission structure address does not match the selected account"); } const address = expectedAddress ?? input.address; @@ -314,9 +400,10 @@ export function validatePermissionStructure(value: unknown, expectedAddress?: st return invalidPermission("permission structure address is not a valid TRON address"); } const owner = group(input.owner, "owner", 0); - const witness = input.witness === undefined || input.witness === null - ? null - : group(input.witness, "witness", 1); + const witness = + input.witness === undefined || input.witness === null + ? null + : group(input.witness, "witness", 1); const activeInput = input.actives ?? []; if (!Array.isArray(activeInput) || activeInput.length > MAX_ACTIVES) { return invalidPermission(`actives must contain at most ${MAX_ACTIVES} permission groups`); @@ -333,7 +420,9 @@ export function validatePermissionStructure(value: unknown, expectedAddress?: st export type LocalPermissionInventory = ReadonlyMap; /** Watch-only accounts are excluded because they cannot contribute a signature. */ -export function buildLocalPermissionInventory(accounts: readonly AccountDescriptor[]): LocalPermissionInventory { +export function buildLocalPermissionInventory( + accounts: readonly AccountDescriptor[], +): LocalPermissionInventory { const inventory = new Map(); for (const account of accounts) { if (account.type === "watch") continue; @@ -365,14 +454,15 @@ export function permissionSafetyWarnings( inventory: LocalPermissionInventory, ): WarningView[] { const localOwnerWeight = permissions.owner.keys.reduce( - (sum, key) => inventory.has(key.address) ? sum + key.weight : sum, + (sum, key) => (inventory.has(key.address) ? sum + key.weight : sum), 0, ); const warnings: WarningView[] = []; if (localOwnerWeight === 0) { warnings.push({ code: "owner_lockout", - message: "local signing keys hold no owner weight; applying this structure may permanently lock out this wallet", + message: + "local signing keys hold no owner weight; applying this structure may permanently lock out this wallet", }); } else if (localOwnerWeight < permissions.owner.threshold) { warnings.push({ diff --git a/ts/src/domain/permission/permission.test.ts b/ts/src/domain/permission/permission.test.ts index e811ae6ee..2fadf2f15 100644 --- a/ts/src/domain/permission/permission.test.ts +++ b/ts/src/domain/permission/permission.test.ts @@ -19,24 +19,33 @@ function structure() { owner: { id: 0, threshold: 2, - keys: [{ address: A, weight: 1, local: "forged" }, { address: B, weight: 1 }], + keys: [ + { address: A, weight: 1, local: "forged" }, + { address: B, weight: 1 }, + ], }, witness: null, - actives: [{ - id: 2, - name: "finance", - threshold: 1, - operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], - operationsHex: "0600008000000000000000000000000000000000000000000000000000000000", - unknownOperationIds: [] as number[], - keys: [{ address: A, weight: 1 }], - }], + actives: [ + { + id: 2, + name: "finance", + threshold: 1, + operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], + operationsHex: "0600008000000000000000000000000000000000000000000000000000000000", + unknownOperationIds: [] as number[], + keys: [{ address: A, weight: 1 }], + }, + ], }; } describe("TRON permission operations", () => { it("uses contract ids as little-endian bits within a fixed 32-byte bitmap", () => { - const encoded = encodeOperations(["TransferContract", "TransferAssetContract", "TriggerSmartContract"]); + const encoded = encodeOperations([ + "TransferContract", + "TransferAssetContract", + "TriggerSmartContract", + ]); expect(encoded).toBe("0600008000000000000000000000000000000000000000000000000000000000"); expect(decodeOperations(encoded)).toMatchObject({ operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], @@ -98,7 +107,9 @@ describe("permission replacement validation", () => { const omitted = structure(); omitted.actives[0]!.operationsHex = "86000080" + "00".repeat(28); delete (omitted.actives[0] as Record).unknownOperationIds; - expect(() => validatePermissionStructure(omitted)).toThrowError(/must declare the unnamed contract types.*7/); + expect(() => validatePermissionStructure(omitted)).toThrowError( + /must declare the unnamed contract types.*7/, + ); const emptied = structure(); emptied.actives[0]!.operationsHex = "86000080" + "00".repeat(28); @@ -109,11 +120,15 @@ describe("permission replacement validation", () => { const understated = structure(); understated.actives[0]!.operationsHex = "86000088" + "00".repeat(28); understated.actives[0]!.unknownOperationIds = [7]; - expect(() => validatePermissionStructure(understated)).toThrowError(/does not match operationsHex/); + expect(() => validatePermissionStructure(understated)).toThrowError( + /does not match operationsHex/, + ); const overstated = structure(); overstated.actives[0]!.unknownOperationIds = [7]; - expect(() => validatePermissionStructure(overstated)).toThrowError(/does not match operationsHex/); + expect(() => validatePermissionStructure(overstated)).toThrowError( + /does not match operationsHex/, + ); }); it("rejects duplicate unknownOperationIds", () => { @@ -134,7 +149,11 @@ describe("permission replacement validation", () => { const result = validatePermissionStructure(input); expect(result.actives[0]).toMatchObject({ operations: ["TransferContract", "TransferAssetContract", "TriggerSmartContract"], - operationsHex: encodeOperations(["TransferContract", "TransferAssetContract", "TriggerSmartContract"]), + operationsHex: encodeOperations([ + "TransferContract", + "TransferAssetContract", + "TriggerSmartContract", + ]), unknownOperationIds: [], }); }); @@ -226,7 +245,10 @@ describe("permission replacement validation", () => { input.witness = { id: 1, threshold: 1, - keys: [{ address: A, weight: 1 }, { address: B, weight: 1 }], + keys: [ + { address: A, weight: 1 }, + { address: B, weight: 1 }, + ], } as never; expect(() => validatePermissionStructure(input)).toThrowError(/exactly 1 key/); }); @@ -240,7 +262,11 @@ describe("permission replacement validation", () => { }); describe("local key inventory and lockout warnings", () => { - const account = (type: AccountDescriptor["type"], address: string, label: string): AccountDescriptor => ({ + const account = ( + type: AccountDescriptor["type"], + address: string, + label: string, + ): AccountDescriptor => ({ accountId: `wlt_${label}` as never, label, type, @@ -256,7 +282,10 @@ describe("local key inventory and lockout warnings", () => { account("watch", B, "watch"), ]); expect([...inventory.entries()]).toEqual([[A, "main"]]); - const annotated = annotateLocalPermissionKeys(validatePermissionStructure(structure()), inventory); + const annotated = annotateLocalPermissionKeys( + validatePermissionStructure(structure()), + inventory, + ); expect(annotated.owner.keys.map((key) => key.local)).toEqual(["main", null]); expect(permissionSafetyWarnings(annotated, inventory)).toEqual([ expect.objectContaining({ code: "owner_lockout_partial" }), @@ -267,7 +296,10 @@ describe("local key inventory and lockout warnings", () => { const input = structure(); input.actives[0]!.operations.push("AccountPermissionUpdateContract"); input.actives[0]!.operationsHex = encodeOperations(input.actives[0]!.operations); - const warnings = permissionSafetyWarnings(validatePermissionStructure(input), new Map([[C, "other"]])); + const warnings = permissionSafetyWarnings( + validatePermissionStructure(input), + new Map([[C, "other"]]), + ); expect(warnings.map((warning) => warning.code)).toEqual([ "owner_lockout", "active_can_update_permission", @@ -282,9 +314,11 @@ describe("local key inventory and lockout warnings", () => { input.actives[0]!.unknownOperationIds = [7]; const inventory = new Map([[A, "main"]]); const warnings = permissionSafetyWarnings(validatePermissionStructure(input), inventory); - expect(warnings).toContainEqual(expect.objectContaining({ - code: "active_unknown_operations", - message: expect.stringContaining("7"), - })); + expect(warnings).toContainEqual( + expect.objectContaining({ + code: "active_unknown_operations", + message: expect.stringContaining("7"), + }), + ); }); }); diff --git a/ts/src/domain/sources/sources.test.ts b/ts/src/domain/sources/sources.test.ts index d35e586db..7dd51c6ff 100644 --- a/ts/src/domain/sources/sources.test.ts +++ b/ts/src/domain/sources/sources.test.ts @@ -31,7 +31,12 @@ describe("source registry", () => { }); it("pins family for single-family sources only", () => { - const ledger: Source = { type: "ledger", family: "tron", path: "m/44'/195'/0'/0/0", address: "T..." }; + const ledger: Source = { + type: "ledger", + family: "tron", + path: "m/44'/195'/0'/0/0", + address: "T...", + }; const watch: Source = { type: "watch", family: "tron", address: "T..." }; const seed: Source = { type: "seed", vaultId: "vlt_x", addresses: {} }; const priv: Source = { type: "privateKey", keyId: "key_x", addresses: { tron: "T..." } }; diff --git a/ts/src/domain/typed-data/index.test.ts b/ts/src/domain/typed-data/index.test.ts index e842ba48a..71499ab0b 100644 --- a/ts/src/domain/typed-data/index.test.ts +++ b/ts/src/domain/typed-data/index.test.ts @@ -2,11 +2,20 @@ import { describe, it, expect } from "vitest"; import { normalizeTypedData } from "./index.js"; const DOMAIN = { name: "SunPerp", version: "1", chainId: 728126428 }; -const TYPES = { Order: [{ name: "trader", type: "address" }, { name: "size", type: "uint256" }] }; +const TYPES = { + Order: [ + { name: "trader", type: "address" }, + { name: "size", type: "uint256" }, + ], +}; describe("normalizeTypedData", () => { it("keeps a well-formed payload intact", () => { - const p = normalizeTypedData({ domain: DOMAIN, types: TYPES, message: { trader: "T1", size: "1" } }); + const p = normalizeTypedData({ + domain: DOMAIN, + types: TYPES, + message: { trader: "T1", size: "1" }, + }); expect(p).toEqual({ domain: DOMAIN, types: TYPES, message: { trader: "T1", size: "1" } }); }); @@ -23,13 +32,18 @@ describe("normalizeTypedData", () => { }); it("accepts `value` as an alias for `message`", () => { - const p = normalizeTypedData({ domain: DOMAIN, types: TYPES, value: { trader: "T1", size: "1" } }); + const p = normalizeTypedData({ + domain: DOMAIN, + types: TYPES, + value: { trader: "T1", size: "1" }, + }); expect(p.message).toEqual({ trader: "T1", size: "1" }); }); it("rejects a payload whose types contain only EIP712Domain", () => { - expect(() => normalizeTypedData({ domain: DOMAIN, types: { EIP712Domain: [] }, message: {} })) - .toThrow(/at least one struct type/); + expect(() => + normalizeTypedData({ domain: DOMAIN, types: { EIP712Domain: [] }, message: {} }), + ).toThrow(/at least one struct type/); }); it("rejects a missing message", () => { @@ -41,20 +55,25 @@ describe("normalizeTypedData", () => { }); it("rejects a primaryType that is not declared", () => { - expect(() => normalizeTypedData({ domain: DOMAIN, types: TYPES, primaryType: "Nope", message: {} })) - .toThrow(/not declared in types/); + expect(() => + normalizeTypedData({ domain: DOMAIN, types: TYPES, primaryType: "Nope", message: {} }), + ).toThrow(/not declared in types/); }); // A nested (non-root) type is referenced by another struct, so it can never be what actually gets // signed — ethers signs the root regardless. Reject it rather than sign one thing and report another. const NESTED = { - Outer: [{ name: "who", type: "address" }, { name: "detail", type: "Inner" }], + Outer: [ + { name: "who", type: "address" }, + { name: "detail", type: "Inner" }, + ], Inner: [{ name: "amount", type: "uint256" }], }; it("rejects a primaryType that is nested inside another type (not a root)", () => { - expect(() => normalizeTypedData({ domain: DOMAIN, types: NESTED, primaryType: "Inner", message: {} })) - .toThrow(/not a root type/); + expect(() => + normalizeTypedData({ domain: DOMAIN, types: NESTED, primaryType: "Inner", message: {} }), + ).toThrow(/not a root type/); }); it("rejects a nested primaryType referenced through an array field", () => { @@ -62,22 +81,33 @@ describe("normalizeTypedData", () => { Outer: [{ name: "items", type: "Inner[]" }], Inner: [{ name: "amount", type: "uint256" }], }; - expect(() => normalizeTypedData({ domain: DOMAIN, types: arrayNested, primaryType: "Inner", message: {} })) - .toThrow(/not a root type/); + expect(() => + normalizeTypedData({ domain: DOMAIN, types: arrayNested, primaryType: "Inner", message: {} }), + ).toThrow(/not a root type/); }); it("accepts a primaryType that is the root type", () => { - const p = normalizeTypedData({ domain: DOMAIN, types: NESTED, primaryType: "Outer", message: { who: "T1", detail: { amount: "1" } } }); + const p = normalizeTypedData({ + domain: DOMAIN, + types: NESTED, + primaryType: "Outer", + message: { who: "T1", detail: { amount: "1" } }, + }); expect(p.primaryType).toBe("Outer"); }); it("accepts an omitted primaryType even with nested types", () => { - const p = normalizeTypedData({ domain: DOMAIN, types: NESTED, message: { who: "T1", detail: { amount: "1" } } }); + const p = normalizeTypedData({ + domain: DOMAIN, + types: NESTED, + message: { who: "T1", detail: { amount: "1" } }, + }); expect(p.primaryType).toBeUndefined(); }); it("rejects a malformed field list", () => { - expect(() => normalizeTypedData({ domain: DOMAIN, types: { Order: [{ name: "x" }] }, message: {} })) - .toThrow(/without a name\/type/); + expect(() => + normalizeTypedData({ domain: DOMAIN, types: { Order: [{ name: "x" }] }, message: {} }), + ).toThrow(/without a name\/type/); }); }); diff --git a/ts/src/domain/typed-data/index.ts b/ts/src/domain/typed-data/index.ts index dc13a9144..72c57bf38 100644 --- a/ts/src/domain/typed-data/index.ts +++ b/ts/src/domain/typed-data/index.ts @@ -41,39 +41,57 @@ function isReferencedType(types: Record, name: string) export function normalizeTypedData(raw: unknown): TypedDataPayload { if (!isObject(raw)) throw new UsageError("invalid_value", "typed data must be a JSON object"); const { domain, types, primaryType } = raw; - if (!isObject(domain)) throw new UsageError("invalid_value", "typed data `domain` must be an object"); - if (!isObject(types)) throw new UsageError("invalid_value", "typed data `types` must be an object"); + if (!isObject(domain)) + throw new UsageError("invalid_value", "typed data `domain` must be an object"); + if (!isObject(types)) + throw new UsageError("invalid_value", "typed data `types` must be an object"); const message = raw.message ?? raw.value; - if (!isObject(message)) throw new UsageError("invalid_value", "typed data `message` must be an object"); + if (!isObject(message)) + throw new UsageError("invalid_value", "typed data `message` must be an object"); const structs: Record = {}; for (const [name, fields] of Object.entries(types)) { if (name === "EIP712Domain") continue; if (!Array.isArray(fields)) { - throw new UsageError("invalid_value", `typed data type \`${name}\` must be an array of fields`); + throw new UsageError( + "invalid_value", + `typed data type \`${name}\` must be an array of fields`, + ); } for (const f of fields) { if (!isObject(f) || typeof f.name !== "string" || typeof f.type !== "string") { - throw new UsageError("invalid_value", `typed data type \`${name}\` has a field without a name/type`); + throw new UsageError( + "invalid_value", + `typed data type \`${name}\` has a field without a name/type`, + ); } } structs[name] = fields as TypedDataField[]; } if (Object.keys(structs).length === 0) { - throw new UsageError("invalid_value", "typed data `types` must declare at least one struct type besides EIP712Domain"); + throw new UsageError( + "invalid_value", + "typed data `types` must declare at least one struct type besides EIP712Domain", + ); } if (primaryType !== undefined && typeof primaryType !== "string") { throw new UsageError("invalid_value", "typed data `primaryType` must be a string"); } if (typeof primaryType === "string" && !(primaryType in structs)) { - throw new UsageError("invalid_value", `typed data \`primaryType\` "${primaryType}" is not declared in types`); + throw new UsageError( + "invalid_value", + `typed data \`primaryType\` "${primaryType}" is not declared in types`, + ); } // The signed message is always the root struct — the one no other struct references. A supplied // primaryType that IS referenced (directly or through an array field) is nested, so it can never be // what gets signed; reject it rather than sign the root while echoing the caller's nested type. if (typeof primaryType === "string" && isReferencedType(structs, primaryType)) { - throw new UsageError("invalid_value", `typed data \`primaryType\` "${primaryType}" is not a root type; it is referenced as a field type, so it cannot be the message root`); + throw new UsageError( + "invalid_value", + `typed data \`primaryType\` "${primaryType}" is not a root type; it is referenced as a field type, so it cannot be the message root`, + ); } return { domain, types: structs, ...(primaryType === undefined ? {} : { primaryType }), message }; diff --git a/ts/src/domain/types/primitives.ts b/ts/src/domain/types/primitives.ts index 8c3940b9d..d91537002 100644 --- a/ts/src/domain/types/primitives.ts +++ b/ts/src/domain/types/primitives.ts @@ -1,3 +1,2 @@ export type OutputMode = "text" | "json"; export type ExitCode = 0 | 1 | 2; - diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index 6950900fe..fa35fb6a1 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -54,7 +54,14 @@ export type TxOutcome = | { stage: "plan"; tx: UnsignedTx; fee: FeeReport } | { stage: "built"; tx: UnsignedTx; hex: string; fee: FeeReport } // `fee` is absent when the caller supplied the transaction (tx sign): nothing was estimated. - | { stage: "signed"; signed: SignedTx; hex?: string; fee?: FeeReport; address?: string; txId?: string } + | { + stage: "signed"; + signed: SignedTx; + hex?: string; + fee?: FeeReport; + address?: string; + txId?: string; + } | ({ stage: BroadcastStage } & BroadcastResult); // ════════════════════ per-command typed text outputs ══════════════════════ @@ -80,16 +87,50 @@ export interface TxStatusView { } /** decoded transfer parties of a tx (best-effort from the raw tx). */ -export interface TxParties { from?: string; to?: string; amount?: string; symbol?: string; contract?: string } +export interface TxParties { + from?: string; + to?: string; + amount?: string; + symbol?: string; + contract?: string; +} /** which action a broadcast receipt describes — drives the summary verb + extra rows. * A typed discriminant replaces matching on the stringly command id. */ export type TxReceiptKind = - | "send" | "broadcast" | "sign" - | "stake-freeze" | "stake-unfreeze" | "stake-delegate" | "stake-undelegate" | "stake-withdraw" | "stake-cancel" - | "contract-send" | "contract-deploy" - | "vote-cast" | "reward-withdraw" | "permission-update" - | "account-activate" | "account-set"; + | "send" + | "broadcast" + | "sign" + | "stake-freeze" + | "stake-unfreeze" + | "stake-delegate" + | "stake-undelegate" + | "stake-withdraw" + | "stake-cancel" + | "contract-send" + | "contract-deploy" + | "proposal-create" + | "proposal-approve" + | "proposal-delete" + | "witness-create" + | "witness-update" + | "witness-set-brokerage" + | "contract-clear-abi" + | "contract-set-origin-energy-limit" + | "contract-set-user-resource-percent" + | "vote-cast" + | "reward-withdraw" + | "permission-update" + | "account-activate" + | "account-set" + | "asset-issue" + | "asset-update" + | "asset-participate" + | "asset-unfreeze" + | "exchange-create" + | "exchange-inject" + | "exchange-withdraw" + | "exchange-trade"; /** * Canonical tx receipt the signing commands return (dry-run / sign-only / broadcast stages). @@ -132,6 +173,60 @@ export interface TxReceiptView { // contract method?: string; contractAddress?: string; + // TRC10 assets — quantities in the asset's minimal units, rendered with `precision` + name?: string; + abbr?: string; + issuerAddress?: string; + participantAddress?: string; + precision?: number; + totalSupply?: string; + price?: string; + trxNum?: number; + num?: number; + startTime?: number; + endTime?: number; + url?: string; + description?: string; + freeAssetNetLimit?: number; + publicFreeAssetNetLimit?: number; + frozenSupply?: Array<{ amount: string; days: number }>; + paidSun?: string; + receivedAmount?: string; + // Bancor exchange — quantities in each token's minimal units, rendered with its own decimals + exchangeId?: number; + pair?: string; + creatorAddress?: string; + traderAddress?: string; + firstTokenId?: string; + firstTokenQuant?: string; + firstTokenLabel?: string; + firstTokenDecimals?: number; + secondTokenId?: string; + secondTokenQuant?: string; + secondTokenLabel?: string; + secondTokenDecimals?: number; + tokenId?: string; + tokenQuant?: string; + tokenLabel?: string; + tokenDecimals?: number; + otherTokenId?: string; + otherTokenQuant?: string; + otherTokenLabel?: string; + otherTokenDecimals?: number; + reserveAfter?: string; + otherReserveAfter?: string; + soldTokenId?: string; + soldQuant?: string; + soldLabel?: string; + soldDecimals?: number; + receivedTokenId?: string; + receivedQuant?: string; + receivedLabel?: string; + receivedDecimals?: number; + estimatedReceivedQuant?: string; + minReceivedQuant?: string; + releasedAmount?: string; + stillFrozenAmount?: string; // confirmed / failed on-chain numbers blockNumber?: number; energyUsed?: number; diff --git a/ts/src/domain/wallet/index.ts b/ts/src/domain/wallet/index.ts index 812a6389e..72c0f9046 100644 --- a/ts/src/domain/wallet/index.ts +++ b/ts/src/domain/wallet/index.ts @@ -38,7 +38,9 @@ export function accountRef(walletId: string, index: number | null): AccountRef { /** known account indices of a source — seed only (privateKey/ledger have none). */ export function accountIndices(source: Source): number[] { if (source.type !== "seed") return []; - return Object.keys(source.addresses).map(Number).sort((a, b) => a - b); + return Object.keys(source.addresses) + .map(Number) + .sort((a, b) => a - b); } /** @@ -84,7 +86,9 @@ export function decodeVault(plaintext: Bytes): { entropy: Bytes; passphrase?: st export function deriveSeedAddresses(seed: Bytes, index: number): ChainAddresses { const out = {} as Record; for (const f of CHAIN_FAMILIES) { - out[f] = addressCodec(f).fromPublicKey(Derivation.derive(seed, Derivation.path(f, index)).publicKey); + out[f] = addressCodec(f).fromPublicKey( + Derivation.derive(seed, Derivation.path(f, index)).publicKey, + ); } return out; } @@ -96,7 +100,9 @@ export function derivePrivAddresses(pk: Bytes): ChainAddresses { } /** (index, cached addresses) pairs of a wallet — the one shape both dedup and views walk. */ -export function enumerateAddresses(w: Wallet): Array<{ index: number | null; addr: Partial }> { +export function enumerateAddresses( + w: Wallet, +): Array<{ index: number | null; addr: Partial }> { const s = w.source; if (s.type === "seed") { return accountIndices(s).map((i) => ({ index: i, addr: s.addresses[String(i)]! })); diff --git a/ts/test/contract-deploy.test.ts b/ts/test/contract-deploy.test.ts index cc385e6d4..ab5a79eb7 100644 --- a/ts/test/contract-deploy.test.ts +++ b/ts/test/contract-deploy.test.ts @@ -11,7 +11,9 @@ import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.j // Regression coverage for issue #2: `contract deploy` constructor params. // • --constructor-sig was a dead flag (types come from the ABI); it was removed. // • --params must be RAW positional values ([100, "T..."]) — the {type,value} form that -// contract call/send use is rejected by TronWeb's createSmartContract ABI encoder. +// contract call/send use is rejected by TronWeb's createSmartContract ABI encoder, and is now +// named as a format error at the command boundary before it gets there (see +// commands/contract.deploy.test.ts for that guard's own alignment coverage). // // The negative case fails at client-side ABI encoding *before* any node call, so it runs // hermetically (random key, no network, no funds). The positive/broadcast cases hit real Nile @@ -20,17 +22,21 @@ import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.j // RUN_LIVE_BROADCAST=1 → actually deploy + confirm on Nile (spends testnet TRX) const HERE = dirname(fileURLToPath(import.meta.url)); -const TSX = join(process.cwd(), "node_modules", ".bin", "tsx"); const ENTRY = join(process.cwd(), "src", "index.ts"); const PW = "testpw123A"; // Minimal init code whose runtime ignores appended constructor args (a known-good Nile deploy // payload). Declaring a constructor in the ABI is what drives client-side arg encoding. -const ABI = JSON.stringify([{ - type: "constructor", - stateMutability: "nonpayable", - inputs: [{ name: "cap", type: "uint256" }, { name: "owner", type: "address" }], -}]); +const ABI = JSON.stringify([ + { + type: "constructor", + stateMutability: "nonpayable", + inputs: [ + { name: "cap", type: "uint256" }, + { name: "owner", type: "address" }, + ], + }, +]); const BYTECODE = "6080604052348015600f57600080fd5b50603f80601d6000396000f3fe6080604052600080fdfea2646970667358fe"; const ARG_ADDR = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; @@ -39,14 +45,16 @@ const ENC_CAP = "0".repeat(62) + "64"; // uint256(100) const ENC_OWNER = "74472e7d35395a6b5add427eecb7f4b62ad2b071"; // ARG_ADDR as a 20-byte EVM address const RAW_PARAMS = `[100, "${ARG_ADDR}"]`; -const TYPED_PARAMS = - `[{"type":"uint256","value":"100"},{"type":"address","value":"${ARG_ADDR}"}]`; +const TYPED_PARAMS = `[{"type":"uint256","value":"100"},{"type":"address","value":"${ARG_ADDR}"}]`; let HOME: string; function seed(secret: string) { - new Keystore(HOME, new AtomicFileStore(), () => PW) - .import({ secret, type: "privateKey", label: "deployer" }); + new Keystore(HOME, new AtomicFileStore(), () => PW).import({ + secret, + type: "privateKey", + label: "deployer", + }); } function deploy( @@ -56,13 +64,20 @@ function deploy( const globals = ["--output", "json", "--network", "tron:nile"]; if (opts.wait) globals.push("--wait"); // --wait is a global flag (before the subcommand) const local = [ - "contract", "deploy", - "--abi", ABI, "--bytecode", BYTECODE, "--fee-limit", "1000000000", - "--params", params, + "contract", + "deploy", + "--abi", + ABI, + "--bytecode", + BYTECODE, + "--fee-limit", + "1000000000", + "--params", + params, ]; if (opts.dryRun) local.push("--dry-run"); local.push("--password-stdin"); - const r = spawnSync(TSX, [ENTRY, ...globals, ...local], { + const r = spawnSync(process.execPath, ["--import", "tsx", ENTRY, ...globals, ...local], { input: PW + "\n", encoding: "utf8", env: { ...process.env, WALLET_CLI_HOME: HOME, NO_COLOR: "1" }, @@ -77,10 +92,14 @@ describe("contract deploy — constructor params (issue #2)", () => { }); it("rejects the {type,value} param form (raw positional values are required)", () => { - seed(randomBytes(32).toString("hex")); // encoding fails before any node call → hermetic + seed(randomBytes(32).toString("hex")); // rejected at the command boundary → hermetic const out = deploy(TYPED_PARAMS, { dryRun: true }); expect(out.success).toBe(false); - expect(out.error.message).toMatch(/BigNumberish/i); + // A malformed call, not a failed execution: deterministic on retry, so exit 2 / invalid_value. + // (Before the guard this reached TronWeb and came back as rpc_error / `invalid BigNumberish + // value (argument="value")` — same refusal, worded in ethers' internals.) + expect(out.error.code).toBe("invalid_value"); + expect(out.error.message).toMatch(/raw positional values/i); }); const PK = loadTestPrivateKey(); diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 03870d284..129ade369 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -1,203 +1,248 @@ -import { describe, it, expect, beforeEach } from "vitest" -import { spawnSync } from "node:child_process" -import { mkdtempSync, readFileSync, statSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { Keystore } from "../src/adapters/outbound/keystore/index.js" -import { TokenBook } from "../src/adapters/outbound/tokenbook/index.js" -import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js" -import type { TokenEntry } from "../src/domain/types/index.js" - -const TSX = join(process.cwd(), "node_modules", ".bin", "tsx") -const ENTRY = join(process.cwd(), "src", "index.ts") -const MNEMONIC = "test test test test test test test test test test test junk" -const TRON1 = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7" -const DEFAULT_PW = "testpw123A" - -let HOME: string +import { describe, it, expect, beforeEach } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Keystore } from "../src/adapters/outbound/keystore/index.js"; +import { TokenBook } from "../src/adapters/outbound/tokenbook/index.js"; +import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js"; +import type { TokenEntry } from "../src/domain/types/index.js"; + +const ENTRY = join(process.cwd(), "src", "index.ts"); +const MNEMONIC = "test test test test test test test test test test test junk"; +const TRON1 = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const DEFAULT_PW = "testpw123A"; + +let HOME: string; beforeEach(() => { - HOME = mkdtempSync(join(tmpdir(), "wcli-")) -}) + HOME = mkdtempSync(join(tmpdir(), "wcli-")); +}); // Secret model (§7.13.1): master password via stdin (--password-stdin); two-secret import is // interactive so it can't run as a black-box subprocess — wallet setup uses seedWallet() to write // the keystore in-process instead. No MASTER_PASSWORD env. password:null → no source (auth_required). function run(args: string[], opts: { input?: string; password?: string | null } = {}) { - const env: Record = { ...process.env, WALLET_CLI_HOME: HOME } as Record - delete env.MASTER_PASSWORD - const finalArgs = [...args] - let stdin = opts.input + const env: Record = { ...process.env, WALLET_CLI_HOME: HOME } as Record< + string, + string + >; + delete env.MASTER_PASSWORD; + const finalArgs = [...args]; + let stdin = opts.input; if (opts.password !== null) { - finalArgs.push("--password-stdin") - stdin = (opts.password ?? DEFAULT_PW) + "\n" + finalArgs.push("--password-stdin"); + stdin = (opts.password ?? DEFAULT_PW) + "\n"; } // 25s < the suite's 30s testTimeout: a genuinely hung subprocess errors here with a clear // signal instead of silently eating the whole test budget. - const r = spawnSync(TSX, [ENTRY, ...finalArgs], { input: stdin, encoding: "utf8", env, timeout: 25_000 }) - let json: any + // `node --import tsx` executes the same TypeScript entry without the tsx CLI's IPC control + // socket, so black-box tests also run in restricted CI/sandbox environments. + const r = spawnSync(process.execPath, ["--import", "tsx", ENTRY, ...finalArgs], { + input: stdin, + encoding: "utf8", + env, + timeout: 25_000, + }); + let json: any; try { - json = JSON.parse(r.stdout) + json = JSON.parse(r.stdout); } catch { /* not json */ } - return { stdout: r.stdout, stderr: r.stderr, status: r.status, json } + return { stdout: r.stdout, stderr: r.stderr, status: r.status, json }; } // Write the keystore directly (bypassing the now-interactive CLI import) so wallet-dependent // tests have a funded identity; the seed is encrypted with DEFAULT_PW, matching run()'s default. function seedWallet(label = "main") { - const ks = new Keystore(HOME, new AtomicFileStore(), () => DEFAULT_PW) - return ks.import({ secret: MNEMONIC, type: "seed", label }).accountId + const ks = new Keystore(HOME, new AtomicFileStore(), () => DEFAULT_PW); + return ks.import({ secret: MNEMONIC, type: "seed", label }).accountId; } // Write a user-layer token directly (bypassing the live-RPC `token add` path) so list/remove // can be exercised deterministically — mirrors seedWallet()'s in-process keystore approach. function seedToken(networkId: string, ref: string, entry: TokenEntry) { - new TokenBook(HOME, new AtomicFileStore()).add(networkId, ref, entry) + new TokenBook(HOME, new AtomicFileStore()).add(networkId, ref, entry); } describe("golden CLI — meta & introspection", () => { it("--version prints the version, exit 0", () => { - const r = run(["--version"]) - expect(r.status).toBe(0) - expect(r.stdout.trim()).toBe("4.11.0") - }) + const r = run(["--version"]); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe("4.12.0"); + }); it("root --help shows the TRON first-release command surface", () => { - const r = run(["--help"], { password: null }) - expect(r.status).toBe(0) - expect(r.stdout).toContain("wallet-cli — CLI wallet for TRON.") - expect(r.stdout).toContain("Usage: wallet-cli [OPTIONS] COMMAND") - expect(r.stdout).toContain("Common Commands:") - expect(r.stdout).toContain("Management Commands:") - expect(r.stdout).toContain("Global Options:") - expect(r.stdout).toContain(" current") - expect(r.stdout).toContain(" use") - expect(r.stdout).toContain(" import") - expect(r.stdout).toContain(" account") - expect(r.stdout).toContain(" tx") - expect(r.stdout).toMatch(/^ permission\s/m) - expect(r.stdout).toMatch(/^ gasfree\s/m) - expect(r.stdout).toMatch(/^ encoding\s/m) - expect(r.stdout).toMatch(/^ address\s/m) - expect(r.stdout).toMatch(/^ contact\s/m) - expect(r.stdout).toContain("current account (--qr for a receive QR code)") - expect(r.stdout).not.toContain("Learn more:") - expect(r.stdout).not.toMatch(/^ import watch\s/m) - expect(r.stdout).not.toMatch(/^ account balance\s/m) - expect(r.stdout).not.toMatch(/^ tx send\s/m) - expect(r.stdout).not.toMatch(/^ send\s/m) - expect(r.stdout).not.toMatch(/^ balance\s/m) - expect(r.stdout).not.toMatch(/^ portfolio\s/m) - expect(r.stdout).not.toContain("wallet-cli ") - expect(r.stdout).toContain("Run 'wallet-cli COMMAND --help' for more information on a command.") - }) + const r = run(["--help"], { password: null }); + expect(r.status).toBe(0); + expect(r.stdout).toContain("wallet-cli — CLI wallet for TRON."); + expect(r.stdout).toContain("Usage: wallet-cli [OPTIONS] COMMAND"); + expect(r.stdout).toContain("Common Commands:"); + expect(r.stdout).toContain("Management Commands:"); + expect(r.stdout).toContain("Global Options:"); + expect(r.stdout).toContain(" current"); + expect(r.stdout).toContain(" use"); + expect(r.stdout).toContain(" import"); + expect(r.stdout).toContain(" account"); + expect(r.stdout).toContain(" tx"); + expect(r.stdout).toMatch(/^ permission\s/m); + expect(r.stdout).toMatch(/^ gasfree\s/m); + expect(r.stdout).toMatch(/^ encoding\s/m); + expect(r.stdout).toMatch(/^ address\s/m); + expect(r.stdout).toMatch(/^ contact\s/m); + expect(r.stdout).toContain("current account (--qr for a receive QR code)"); + expect(r.stdout).not.toContain("Learn more:"); + expect(r.stdout).not.toMatch(/^ import watch\s/m); + expect(r.stdout).not.toMatch(/^ account balance\s/m); + expect(r.stdout).not.toMatch(/^ tx send\s/m); + expect(r.stdout).not.toMatch(/^ send\s/m); + expect(r.stdout).not.toMatch(/^ balance\s/m); + expect(r.stdout).not.toMatch(/^ portfolio\s/m); + expect(r.stdout).not.toContain("wallet-cli "); + expect(r.stdout).toContain( + "Run 'wallet-cli COMMAND --help' for more information on a command.", + ); + }); it("networks omits chain (neutral), exit 0", () => { - const r = run(["--output", "json", "networks"]) - expect(r.status).toBe(0) - expect(r.json.success).toBe(true) - expect(r.json.chain).toBeUndefined() - const ids = r.json.data.map((n: { id: string }) => n.id) + const r = run(["--output", "json", "networks"]); + expect(r.status).toBe(0); + expect(r.json.success).toBe(true); + expect(r.json.chain).toBeUndefined(); + const ids = r.json.data.map((n: { id: string }) => n.id); // only the 3 TRON networks ship - expect(ids).toEqual(expect.arrayContaining(["tron:mainnet", "tron:nile", "tron:shasta"])) - expect(ids).toHaveLength(3) - expect(ids.some((id: string) => id.startsWith("evm:"))).toBe(false) - }) + expect(ids).toEqual(expect.arrayContaining(["tron:mainnet", "tron:nile", "tron:shasta"])); + expect(ids).toHaveLength(3); + expect(ids.some((id: string) => id.startsWith("evm:"))).toBe(false); + }); it("--json-schema emits an agent schema for a command", () => { - const r = run(["import", "watch", "--json-schema"], { password: null }) - expect(r.status).toBe(0) - expect(r.json.properties.address).toBeDefined() - expect(r.json.required).toContain("address") - }) + const r = run(["import", "watch", "--json-schema"], { password: null }); + expect(r.status).toBe(0); + expect(r.json.properties.address).toBeDefined(); + expect(r.json.required).toContain("address"); + }); it("root --json-schema emits a full command catalog with global flags", () => { - const r = run(["--json-schema"], { password: null }) - expect(r.status).toBe(0) - expect(r.json.tool).toBe("wallet-cli") - expect(r.json.globalFlags.length).toBeGreaterThan(0) - const globalFlags = r.json.globalFlags.map((g: { flag: string }) => g.flag) - expect(globalFlags).not.toContain("--quiet") - expect(globalFlags).not.toContain("--rpc-url") - expect(globalFlags).not.toContain("--grpc-endpoint") - 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 === "tx.send") - expect(cmd.usage).toBe("wallet-cli tx send [options]") - expect(cmd.requires).toMatchObject({ network: "optional", auth: "conditional", wallet: "optional" }) - expect(cmd.inputSchema.properties.to).toBeDefined() - const importMnemonic = r.json.commands.find((c: { id: string }) => c.id === "import.mnemonic") + const r = run(["--json-schema"], { password: null }); + expect(r.status).toBe(0); + expect(r.json.tool).toBe("wallet-cli"); + expect(r.json.globalFlags.length).toBeGreaterThan(0); + const globalFlags = r.json.globalFlags.map((g: { flag: string }) => g.flag); + expect(globalFlags).not.toContain("--quiet"); + expect(globalFlags).not.toContain("--rpc-url"); + expect(globalFlags).not.toContain("--grpc-endpoint"); + 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 === "tx.send"); + expect(cmd.usage).toBe("wallet-cli tx send [options]"); + expect(cmd.requires).toMatchObject({ + network: "optional", + auth: "conditional", + wallet: "optional", + }); + expect(cmd.inputSchema.properties.to).toBeDefined(); + const importMnemonic = r.json.commands.find((c: { id: string }) => c.id === "import.mnemonic"); // 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]") - }) + 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]"); + }); it("family --json-schema scopes the catalog to that chain family", () => { - const r = run(["tron", "--json-schema"], { password: null }) - expect(r.status).toBe(0) - expect(r.json.commands.length).toBeGreaterThan(0) + 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; families?: string[] }) => c.kind === "chain" && (c.family === "tron" || (c.families?.includes("tron") ?? false))), - ).toBe(true) - }) + 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", () => { - const all = run(["--output", "json", "config"], { password: null }) - expect(all.status).toBe(0) - expect(all.json.data.defaultNetwork).toBe("tron:mainnet") - - const get = run(["--output", "json", "config", "defaultNetwork"], { password: null }) - expect(get.status).toBe(0) - expect(get.json.data).toMatchObject({ key: "defaultNetwork", value: "tron:mainnet" }) - - const set = run(["--output", "json", "config", "defaultNetwork", "tron:nile"], { password: null }) - expect(set.status).toBe(0) - expect(set.json.data).toMatchObject({ key: "defaultNetwork", value: "tron:nile", input: "tron:nile" }) - - const getAgain = run(["--output", "json", "config", "defaultNetwork"], { password: null }) - expect(getAgain.json.data.value).toBe("tron:nile") - }) -}) + const all = run(["--output", "json", "config"], { password: null }); + expect(all.status).toBe(0); + expect(all.json.data.defaultNetwork).toBe("tron:mainnet"); + + const get = run(["--output", "json", "config", "defaultNetwork"], { password: null }); + expect(get.status).toBe(0); + expect(get.json.data).toMatchObject({ key: "defaultNetwork", value: "tron:mainnet" }); + + const set = run(["--output", "json", "config", "defaultNetwork", "tron:nile"], { + password: null, + }); + expect(set.status).toBe(0); + expect(set.json.data).toMatchObject({ + key: "defaultNetwork", + value: "tron:nile", + input: "tron:nile", + }); + + const getAgain = run(["--output", "json", "config", "defaultNetwork"], { password: null }); + expect(getAgain.json.data.value).toBe("tron:nile"); + }); +}); describe("golden CLI — wallet lifecycle (shared identity)", () => { it("lists, sets active, renames", () => { - seedWallet() - const list = run(["--output", "json", "list"]) - expect(list.json.data[0].label).toBe("main") - expect(list.json.data[0].active).toBe(true) + seedWallet(); + const list = run(["--output", "json", "list"]); + expect(list.json.data[0].label).toBe("main"); + expect(list.json.data[0].active).toBe(true); - const rename = run(["--output", "json", "rename", "main", "--label", "primary"]) - expect(rename.status).toBe(0) + const rename = run(["--output", "json", "rename", "main", "--label", "primary"]); + expect(rename.status).toBe(0); - const current = run(["--output", "json", "current"]) - expect(current.status).toBe(0) - expect(current.json.command).toBe("current") - expect(current.json.data.label).toBe("primary") - }) + const current = run(["--output", "json", "current"]); + expect(current.status).toBe(0); + expect(current.json.command).toBe("current"); + expect(current.json.data.label).toBe("primary"); + }); it("routes root-level message sign through the selected network family", () => { - seedWallet() - const r = run(["--output", "json", "message", "sign", "--network", "tron:nile", "--message", "hello world"]) - expect(r.status).toBe(0) - expect(r.json.command).toBe("message.sign") - expect(r.json.chain.network).toBe("tron:nile") - }) + seedWallet(); + const r = run([ + "--output", + "json", + "message", + "sign", + "--network", + "tron:nile", + "--message", + "hello world", + ]); + expect(r.status).toBe(0); + expect(r.json.command).toBe("message.sign"); + expect(r.json.chain.network).toBe("tron:nile"); + }); it("routes root-level send and validates human amount decimals before RPC", () => { - 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("tx.send") - expect(r.json.error.code).toBe("invalid_amount") - }) + 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("tx.send"); + expect(r.json.error.code).toBe("invalid_amount"); + }); it("resolves TRON send --token from the address book before amount conversion", () => { - seedWallet() + seedWallet(); const r = run([ "--output", "json", @@ -212,153 +257,186 @@ describe("golden CLI — wallet lifecycle (shared identity)", () => { "--amount", "0.0000001", "--dry-run", - ]) - expect(r.status).toBe(2) - expect(r.json.command).toBe("tx.send") - expect(r.json.error.code).toBe("invalid_amount") - }) + ]); + expect(r.status).toBe(2); + expect(r.json.command).toBe("tx.send"); + expect(r.json.error.code).toBe("invalid_amount"); + }); it("backup writes the secret to a 0600 file, never to stdout", () => { - seedWallet() - const out = join(HOME, "bak.json") - const r = run(["--output", "json", "backup", "main", "--out", out]) - expect(r.status).toBe(0) + seedWallet(); + const out = join(HOME, "bak.json"); + const r = run(["--output", "json", "backup", "main", "--out", out]); + expect(r.status).toBe(0); // stdout carries metadata + path only — no secret in the envelope - expect(r.json.data.out).toBe(out) - expect(JSON.stringify(r.json)).not.toContain("junk") // mnemonic word must not leak to stdout + expect(r.json.data.out).toBe(out); + expect(JSON.stringify(r.json)).not.toContain("junk"); // mnemonic word must not leak to stdout // the file holds the plaintext secret, at 0600 - const file = JSON.parse(readFileSync(out, "utf8")) - expect(file.secretType).toBe("mnemonic") - expect(file.mnemonic).toBe(MNEMONIC) - expect(statSync(out).mode & 0o777).toBe(0o600) + const file = JSON.parse(readFileSync(out, "utf8")); + expect(file.secretType).toBe("mnemonic"); + expect(file.mnemonic).toBe(MNEMONIC); + expect(statSync(out).mode & 0o777).toBe(0o600); // refuses to clobber an existing file → exit 2 - const again = run(["--output", "json", "backup", "main", "--out", out]) - expect(again.status).toBe(2) - expect(again.json.error.code).toBe("output_exists") - }, 15000) // seed encrypt + two backup decrypts run scrypt 3× → exceeds vitest's 5s default + const again = run(["--output", "json", "backup", "main", "--out", out]); + expect(again.status).toBe(2); + expect(again.json.error.code).toBe("output_exists"); + }, 15000); // seed encrypt + two backup decrypts run scrypt 3× → exceeds vitest's 5s default it("supports root-level use and backup account commands", () => { - seedWallet() - const use = run(["--output", "json", "use", "main"], { password: null }) - expect(use.status).toBe(0) - expect(use.json.command).toBe("use") - - const out = join(HOME, "root-bak.json") - const backup = run(["--output", "json", "backup", "main", "--out", out]) - expect(backup.status).toBe(0) - expect(backup.json.command).toBe("backup") - expect(backup.json.data.out).toBe(out) - }) + seedWallet(); + const use = run(["--output", "json", "use", "main"], { password: null }); + expect(use.status).toBe(0); + expect(use.json.command).toBe("use"); + + const out = join(HOME, "root-bak.json"); + const backup = run(["--output", "json", "backup", "main", "--out", out]); + expect(backup.status).toBe(0); + expect(backup.json.command).toBe("backup"); + expect(backup.json.data.out).toBe(out); + }); it("derive makes the newly derived HD account the active one (§1.7)", () => { - const seedId = seedWallet().split(".")[0]! // "main" at index 0, active; seed id = wlt_x - const r = run(["--output", "json", "derive", "--seed-id", seedId, "--label", "child"]) - expect(r.status).toBe(0) - expect(r.json.command).toBe("derive") - expect(r.json.data.index).toBe(1) - expect(r.json.data.active).toBe(true) // derive auto-activates, not active:false + const seedId = seedWallet().split(".")[0]!; // "main" at index 0, active; seed id = wlt_x + const r = run(["--output", "json", "derive", "--seed-id", seedId, "--label", "child"]); + expect(r.status).toBe(0); + expect(r.json.command).toBe("derive"); + expect(r.json.data.index).toBe(1); + expect(r.json.data.active).toBe(true); // derive auto-activates, not active:false // and `current` now resolves to the derived child, confirming the switch persisted - const current = run(["--output", "json", "current"], { password: null }) - expect(current.json.data.label).toBe("child") - }) -}) + const current = run(["--output", "json", "current"], { password: null }); + expect(current.json.data.label).toBe("child"); + }); +}); describe("golden CLI — command help contracts", () => { it("import ledger --help documents the device precondition (B5)", () => { - const r = run(["import", "ledger", "--help"], { password: null }) - expect(r.status).toBe(0) - expect(r.stdout).toContain("Requires:") - expect(r.stdout).toMatch(/connected, unlocked Ledger/) - }) + const r = run(["import", "ledger", "--help"], { password: null }); + expect(r.status).toBe(0); + expect(r.stdout).toContain("Requires:"); + expect(r.stdout).toMatch(/connected, unlocked Ledger/); + }); it("tx send --help summary leads with 'Send' and human --amount (E2)", () => { - const r = run(["tx", "send", "--help"], { password: null }) - expect(r.status).toBe(0) - expect(r.stdout).toContain("Send native TRX or TRC20/TRC10 tokens with human --amount") - }) + const r = run(["tx", "send", "--help"], { password: null }); + expect(r.status).toBe(0); + expect(r.stdout).toContain("Send native TRX or TRC20/TRC10 tokens with human --amount"); + }); it("block --help documents the height as a positional arg, not a --number flag (H4)", () => { - const r = run(["block", "--help"], { password: null }) - expect(r.status).toBe(0) - expect(r.stdout).toContain("wallet-cli block []") - expect(r.stdout).toMatch(/Args:\s*\n\s*number\s/) - expect(r.stdout).not.toContain("--number") // positional-only surface, flag dropped from help - }) + const r = run(["block", "--help"], { password: null }); + expect(r.status).toBe(0); + expect(r.stdout).toContain("wallet-cli block []"); + expect(r.stdout).toMatch(/Args:\s*\n\s*number\s/); + expect(r.stdout).not.toContain("--number"); // positional-only surface, flag dropped from help + }); it("account-positional commands document under Args, not as a --account flag", () => { - const r = run(["rename", "--help"], { password: null }) - expect(r.status).toBe(0) - expect(r.stdout).toContain("wallet-cli rename ") - expect(r.stdout).toMatch(/Args:\s*\n\s*account\s/) - expect(r.stdout).not.toContain("--account") // unified positional mechanism hides the flag - expect(r.stdout).toContain("--label") // sibling flags still listed - }) -}) + const r = run(["rename", "--help"], { password: null }); + expect(r.status).toBe(0); + expect(r.stdout).toContain("wallet-cli rename "); + expect(r.stdout).toMatch(/Args:\s*\n\s*account\s/); + expect(r.stdout).not.toContain("--account"); // unified positional mechanism hides the flag + expect(r.stdout).toContain("--label"); // sibling flags still listed + }); +}); describe("golden CLI — watch wallet (import, no signer)", () => { it("imports a watch account, auto-detecting the family from the address, exit 0", () => { - const r = run(["--output", "json", "import", "watch", "--address", TRON1, "--label", "obs"]) - expect(r.status).toBe(0) - expect(r.json.data.addresses.tron).toBe(TRON1) - const list = run(["--output", "json", "list"]) - expect(list.json.data[0].type).toBe("watch") - expect(list.json.data[0].active).toBe(true) - }) + const r = run(["--output", "json", "import", "watch", "--address", TRON1, "--label", "obs"]); + expect(r.status).toBe(0); + expect(r.json.data.addresses.tron).toBe(TRON1); + const list = run(["--output", "json", "list"]); + expect(list.json.data[0].type).toBe("watch"); + expect(list.json.data[0].active).toBe(true); + }); it("imports a watch account through the import source command", () => { - const r = run(["--output", "json", "import", "watch", "--address", TRON1, "--label", "obs"]) - expect(r.status).toBe(0) - expect(r.json.command).toBe("import.watch") - expect(r.json.data.addresses.tron).toBe(TRON1) - }) + const r = run(["--output", "json", "import", "watch", "--address", TRON1, "--label", "obs"]); + expect(r.status).toBe(0); + expect(r.json.command).toBe("import.watch"); + expect(r.json.data.addresses.tron).toBe(TRON1); + }); it("rejects an unrecognised watch address → invalid_value, exit 2 (§7.14.2)", () => { - const r = run(["--output", "json", "import", "watch", "--address", "not-an-address"]) - expect(r.status).toBe(2) - expect(r.json.error.code).toBe("invalid_value") - }) + const r = run(["--output", "json", "import", "watch", "--address", "not-an-address"]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_value"); + }); it("deletes an account through the root positional delete command", () => { - seedWallet() - const r = run(["--output", "json", "delete", "main", "--yes"], { password: null }) - expect(r.status).toBe(0) - expect(r.json.command).toBe("delete") - expect(r.json.data.newActive).toBeNull() - }) + seedWallet(); + const r = run(["--output", "json", "delete", "main", "--yes"], { password: null }); + expect(r.status).toBe(0); + expect(r.json.command).toBe("delete"); + expect(r.json.data.newActive).toBeNull(); + }); it("refuses to sign with a watch-only active account → watch_only_no_signer, exit 1", () => { - run(["--output", "json", "import", "watch", "--address", TRON1, "--label", "obs"]) - const r = run(["--output", "json", "message", "sign", "--network", "tron:nile", "--message", "hi"]) - expect(r.status).toBe(1) - expect(r.json.error.code).toBe("watch_only_no_signer") - }) -}) + run(["--output", "json", "import", "watch", "--address", TRON1, "--label", "obs"]); + const r = run([ + "--output", + "json", + "message", + "sign", + "--network", + "tron:nile", + "--message", + "hi", + ]); + expect(r.status).toBe(1); + expect(r.json.error.code).toBe("watch_only_no_signer"); + }); +}); describe("golden CLI — error contract (exit codes)", () => { it("unknown command → exit 2", () => { - const r = run(["--output", "json", "tron", "bogus", "action", "--network", "tron:nile"]) - expect(r.status).toBe(2) - expect(r.json.error.code).toBe("unknown_command") - }) + const r = run(["--output", "json", "tron", "bogus", "action", "--network", "tron:nile"]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("unknown_command"); + }); it("unknown top-level namespace → unknown_command, exit 2 (no silent exit 0)", () => { - const r = run(["--output", "json", "foobar", "list"]) - expect(r.status).toBe(2) - expect(r.json.error.code).toBe("unknown_command") - }) + const r = run(["--output", "json", "foobar", "list"]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("unknown_command"); + }); it("omitted network on a chain-mutating command uses defaultNetwork before input validation", () => { - const r = run(["--output", "json", "tx", "send"]) - expect(r.status).toBe(2) - expect(r.json.error.code).toBe("missing_option") - expect(r.json.chain.network).toBe("tron:mainnet") - }) + const r = run(["--output", "json", "tx", "send"]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("missing_option"); + expect(r.json.chain.network).toBe("tron:mainnet"); + }); it("invalid address value → exit 2", () => { - const r = run(["--output", "json", "token", "balance", "--network", "tron:nile", "--contract", "0xnope"]) - expect(r.status).toBe(2) - expect(r.json.error.code).toBe("invalid_value") - }) + const r = run([ + "--output", + "json", + "token", + "balance", + "--network", + "tron:nile", + "--contract", + "0xnope", + ]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_value"); + }); + + // Every address-shaped flag is validated locally, so a typo is exit 2 everywhere rather than a + // node round-trip that reports the same typo as an execution failure (exit 1, rpc_error). + it.each([ + ["asset info --issuer", ["asset", "info", "--issuer", "notanaddress"]], + [ + "account activate --address", + ["account", "activate", "--address", "notanaddress", "--dry-run"], + ], + ])("%s with a malformed address → invalid_value, exit 2", (_label, args) => { + const r = run(["--output", "json", ...args, "--network", "tron:nile"]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_value"); + expect(r.json.error.message).toMatch(/invalid tron address/); + }); it("stake delegate --lock-period without --lock → invalid_value, exit 2", () => { const r = run([ @@ -374,22 +452,44 @@ describe("golden CLI — error contract (exit codes)", () => { "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7", "--lock-period", "100", - ]) - expect(r.status).toBe(2) - expect(r.json.error.code).toBe("invalid_value") - }) + ]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_value"); + }); it("tx send --amount 0 → invalid_value, exit 2 (a zero transfer is meaningless)", () => { - const r = run(["--output", "json", "tx", "send", "--network", "tron:nile", "--to", "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7", "--amount", "0"]) - expect(r.status).toBe(2) - expect(r.json.error.code).toBe("invalid_value") - }) + const r = run([ + "--output", + "json", + "tx", + "send", + "--network", + "tron:nile", + "--to", + "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7", + "--amount", + "0", + ]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_value"); + }); it("stake freeze --amount-sun 0 → invalid_value, exit 2", () => { - const r = run(["--output", "json", "stake", "freeze", "--network", "tron:nile", "--amount-sun", "0", "--resource", "ENERGY"]) - expect(r.status).toBe(2) - expect(r.json.error.code).toBe("invalid_value") - }) + const r = run([ + "--output", + "json", + "stake", + "freeze", + "--network", + "tron:nile", + "--amount-sun", + "0", + "--resource", + "ENERGY", + ]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_value"); + }); it("contract call --params with non-{type,value} entries → invalid_value, exit 2", () => { const r = run([ @@ -405,187 +505,340 @@ describe("golden CLI — error contract (exit codes)", () => { "balanceOf(address)", "--params", '["0x1"]', - ]) - expect(r.status).toBe(2) - expect(r.json.error.code).toBe("invalid_value") - }) + ]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_value"); + }); it("wrong master password → auth_failed, exit 1", () => { - seedWallet() - const r = run(["--output", "json", "message", "sign", "--network", "tron:nile", "--message", "hi"], { password: "WRONGpw999" }) - expect(r.status).toBe(1) - expect(r.json.error.code).toBe("auth_failed") - }) + seedWallet(); + const r = run( + ["--output", "json", "message", "sign", "--network", "tron:nile", "--message", "hi"], + { password: "WRONGpw999" }, + ); + expect(r.status).toBe(1); + expect(r.json.error.code).toBe("auth_failed"); + }); it("auth-required command with no password source → auth_required up front, exit 1", () => { - seedWallet() - const r = run(["--output", "json", "message", "sign", "--network", "tron:nile", "--message", "hi"], { password: null }) - expect(r.status).toBe(1) - expect(r.json.error.code).toBe("auth_required") - }) + seedWallet(); + const r = run( + ["--output", "json", "message", "sign", "--network", "tron:nile", "--message", "hi"], + { password: null }, + ); + 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() + 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) - }) + 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() + 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'") - }) + 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'"); + }); // Leaf/group help must carry the doc's user-value semantics, not a compressed one-liner: overwrite // semantics + TP math (vote cast), the 30-entry cap on --for, the 24h withdraw cap (reward // withdraw), the 0% reward-ratio warning (vote status), and the reward pointer (vote group). it("vote --help keeps the reward pointer (group 2nd line)", () => { - const r = run(["vote", "--help"], { password: null }) - expect(r.stdout).toContain("Vote for super representatives (SR).") - expect(r.stdout).toContain("Voting accrues rewards — query and claim them with 'wallet-cli reward'.") - }) + const r = run(["vote", "--help"], { password: null }); + expect(r.stdout).toContain("Vote for super representatives (SR)."); + expect(r.stdout).toContain( + "Voting accrues rewards — query and claim them with 'wallet-cli reward'.", + ); + }); it("vote cast --help spells out overwrite semantics, the 30-entry cap, and TP math", () => { - const r = run(["vote", "cast", "--help"], { password: null }) - expect(r.stdout).toContain("any previous SR not listed is set to zero") - expect(r.stdout).toContain("1 vote = 1 Tron Power (TP) = 1 staked TRX") - expect(r.stdout).toContain("at least 1, at most 30 entries") - }) + const r = run(["vote", "cast", "--help"], { password: null }); + expect(r.stdout).toContain("any previous SR not listed is set to zero"); + expect(r.stdout).toContain("1 vote = 1 Tron Power (TP) = 1 staked TRX"); + expect(r.stdout).toContain("at least 1, at most 30 entries"); + }); it("vote status --help warns about the 0% reward-ratio case", () => { - const r = run(["vote", "status", "--help"], { password: null }) - expect(r.stdout).toContain("0% reward ratio") - }) + const r = run(["vote", "status", "--help"], { password: null }); + expect(r.stdout).toContain("0% reward ratio"); + }); it("reward withdraw --help states the 24h withdrawal cap", () => { - const r = run(["reward", "withdraw", "--help"], { password: null }) - expect(r.stdout).toContain("at most once every 24 hours") - }) + const r = run(["reward", "withdraw", "--help"], { password: null }); + expect(r.stdout).toContain("at most once every 24 hours"); + }); // stake-query / chain / interactive-import leaf help must also carry the doc's fuller description, // not the compressed one-line summary (same fix as vote/reward above). it("stake info --help lists the overview fields", () => { - const r = run(["stake", "info", "--help"], { password: null }) - expect(r.stdout).toContain("pending unstakes, currently withdrawable TRX, and available unfreeze slots") - }) + const r = run(["stake", "info", "--help"], { password: null }); + expect(r.stdout).toContain( + "pending unstakes, currently withdrawable TRX, and available unfreeze slots", + ); + }); it("stake delegated --help explains outbound/inbound lock semantics", () => { - const r = run(["stake", "delegated", "--help"], { password: null }) - expect(r.stdout).toContain('Outbound shows "Locked until"') - expect(r.stdout).toContain("inbound shows") - expect(r.stdout).toContain('"Guaranteed until"') - }) + const r = run(["stake", "delegated", "--help"], { password: null }); + expect(r.stdout).toContain('Outbound shows "Locked until"'); + expect(r.stdout).toContain("inbound shows"); + expect(r.stdout).toContain('"Guaranteed until"'); + }); it("chain params --help mentions --key for a single value", () => { - const r = run(["chain", "params", "--help"], { password: null }) - expect(r.stdout).toContain("Use --key for one value") - }) + const r = run(["chain", "params", "--help"], { password: null }); + expect(r.stdout).toContain("Use --key for one value"); + }); it("chain prices --help states the SUN unit basis", () => { - const r = run(["chain", "prices", "--help"], { password: null }) - expect(r.stdout).toContain("in SUN; 1 TRX = 1,000,000 SUN") - }) + const r = run(["chain", "prices", "--help"], { password: null }); + expect(r.stdout).toContain("in SUN; 1 TRX = 1,000,000 SUN"); + }); it("chain node --help explains the sync-vs-tx diagnostic use", () => { - const r = run(["chain", "node", "--help"], { password: null }) - expect(r.stdout).toContain("node out of sync") - }) + const r = run(["chain", "node", "--help"], { password: null }); + expect(r.stdout).toContain("node out of sync"); + }); it("change-password --help notes Ledger/watch are unaffected and TTY-only secrets", () => { - const r = run(["change-password", "--help"], { password: null }) - expect(r.stdout).toContain("Ledger / watch-only accounts are unaffected") - expect(r.stdout).toContain("they never touch argv or stdin") - }) + const r = run(["change-password", "--help"], { password: null }); + expect(r.stdout).toContain("Ledger / watch-only accounts are unaffected"); + expect(r.stdout).toContain("they never touch argv or stdin"); + }); it("import mnemonic --help documents hidden TTY-only entry", () => { - const r = run(["import", "mnemonic", "--help"], { password: null }) - expect(r.stdout).toContain("The recovery phrase and master password are read") - expect(r.stdout).toContain("they never touch argv or stdin") - }) + const r = run(["import", "mnemonic", "--help"], { password: null }); + expect(r.stdout).toContain("The recovery phrase and master password are read"); + expect(r.stdout).toContain("they never touch argv or stdin"); + }); it("import private-key --help documents hidden TTY-only entry", () => { - const r = run(["import", "private-key", "--help"], { password: null }) - expect(r.stdout).toContain("The private key and master password are read") - expect(r.stdout).toContain("they never touch argv or stdin") - }) -}) + const r = run(["import", "private-key", "--help"], { password: null }); + expect(r.stdout).toContain("The private key and master password are read"); + expect(r.stdout).toContain("they never touch argv or stdin"); + }); +}); describe("golden CLI — token address-book (local, no RPC)", () => { // a real valid base58check address (the CLI validates --contract); not actually a token contract. - const CUSTOM: TokenEntry = { kind: "trc20", id: "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb", symbol: "CUS", decimals: 8, name: "Custom" } + const CUSTOM: TokenEntry = { + kind: "trc20", + id: "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb", + symbol: "CUS", + decimals: 8, + name: "Custom", + }; it("token list shows the official layer on mainnet (default network), tagged official", () => { - seedWallet() - const r = run(["--output", "json", "token", "list"]) - expect(r.status).toBe(0) - expect(r.json.data.network).toBe("tron:mainnet") - expect(r.json.data.tokens.map((t: { symbol: string }) => t.symbol)).toEqual(["USDT", "USDC", "USDD"]) - expect(r.json.data.tokens.every((t: { source: string }) => t.source === "official")).toBe(true) - }) + seedWallet(); + const r = run(["--output", "json", "token", "list"]); + expect(r.status).toBe(0); + expect(r.json.data.network).toBe("tron:mainnet"); + expect(r.json.data.tokens.map((t: { symbol: string }) => t.symbol)).toEqual([ + "USDT", + "USDC", + "USDD", + ]); + expect(r.json.data.tokens.every((t: { source: string }) => t.source === "official")).toBe(true); + }); it("routes root-level token commands through defaultNetwork", () => { - seedWallet() - const r = run(["--output", "json", "token", "list"]) - expect(r.status).toBe(0) - expect(r.json.command).toBe("token.list") - expect(r.json.chain.network).toBe("tron:mainnet") - }) + seedWallet(); + const r = run(["--output", "json", "token", "list"]); + expect(r.status).toBe(0); + expect(r.json.command).toBe("token.list"); + expect(r.json.chain.network).toBe("tron:mainnet"); + }); it("token list lists nile's official tokens first, then a user-added token tagged user", () => { - const ref = seedWallet() - seedToken("tron:nile", ref, CUSTOM) - const r = run(["--output", "json", "token", "list", "--network", "tron:nile"]) - expect(r.status).toBe(0) - expect(r.json.data.tokens.map((t: { source: string; symbol: string }) => `${t.source}:${t.symbol}`)).toEqual(["official:USDT", "official:USDD", "user:CUS"]) - }) + const ref = seedWallet(); + seedToken("tron:nile", ref, CUSTOM); + const r = run(["--output", "json", "token", "list", "--network", "tron:nile"]); + expect(r.status).toBe(0); + expect( + r.json.data.tokens.map((t: { source: string; symbol: string }) => `${t.source}:${t.symbol}`), + ).toEqual(["official:USDT", "official:USDD", "user:CUS"]); + }); it("token remove of an official token → token_is_official, exit 2", () => { - seedWallet() - const r = run(["--output", "json", "token", "remove", "--contract", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"]) - expect(r.status).toBe(2) - expect(r.json.error.code).toBe("token_is_official") - }) + seedWallet(); + const r = run([ + "--output", + "json", + "token", + "remove", + "--contract", + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + ]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("token_is_official"); + }); it("token remove of a user token succeeds; removing an absent one → token_not_in_book, exit 2", () => { - const ref = seedWallet() - seedToken("tron:nile", ref, CUSTOM) - const ok = run(["--output", "json", "token", "remove", "--network", "tron:nile", "--contract", CUSTOM.id]) - expect(ok.status).toBe(0) - expect(ok.json.data.removed.symbol).toBe("CUS") - const again = run(["--output", "json", "token", "remove", "--network", "tron:nile", "--contract", CUSTOM.id]) - expect(again.status).toBe(2) - expect(again.json.error.code).toBe("token_not_in_book") - }) + const ref = seedWallet(); + seedToken("tron:nile", ref, CUSTOM); + const ok = run([ + "--output", + "json", + "token", + "remove", + "--network", + "tron:nile", + "--contract", + CUSTOM.id, + ]); + expect(ok.status).toBe(0); + expect(ok.json.data.removed.symbol).toBe("CUS"); + const again = run([ + "--output", + "json", + "token", + "remove", + "--network", + "tron:nile", + "--contract", + CUSTOM.id, + ]); + expect(again.status).toBe(2); + expect(again.json.error.code).toBe("token_not_in_book"); + }); it("token add/remove require exactly one of --contract / --asset-id → exit 2", () => { - seedWallet() - const r = run(["--output", "json", "token", "remove", "--network", "tron:nile"]) - expect(r.status).toBe(2) - }) -}) + seedWallet(); + const r = run(["--output", "json", "token", "remove", "--network", "tron:nile"]); + expect(r.status).toBe(2); + }); +}); describe("golden CLI — fixes regression", () => { it("-o json short alias selects JSON output", () => { - const r = run(["-o", "json", "networks"]) - expect(r.status).toBe(0) - expect(r.json.success).toBe(true) - }) + const r = run(["-o", "json", "networks"]); + expect(r.status).toBe(0); + expect(r.json.success).toBe(true); + }); it("a value flag resolves via zod arity (raw-amount reaches the schema)", () => { - seedWallet() + seedWallet(); // invalid (non-numeric) amount must be a zod invalid_value, proving the value reached the schema - const r = run(["--output", "json", "tx", "send", "--network", "tron:nile", "--to", TRON1, "--raw-amount", "notanumber", "--dry-run"]) - expect(r.status).toBe(2) - expect(r.json.error.code).toBe("invalid_value") - }) -}) + const r = run([ + "--output", + "json", + "tx", + "send", + "--network", + "tron:nile", + "--to", + TRON1, + "--raw-amount", + "notanumber", + "--dry-run", + ]); + expect(r.status).toBe(2); + expect(r.json.error.code).toBe("invalid_value"); + }); +}); + +describe("golden CLI — v4.12 governance surface", () => { + it("registers proposal, witness, and contract-governance command groups", () => { + const proposal = run(["proposal", "--help"], { password: null }); + expect(proposal.status).toBe(0); + expect(proposal.stdout).toContain("create"); + expect(proposal.stdout).toContain("approve"); + expect(proposal.stdout).toContain("delete"); + + const witness = run(["witness", "--help"], { password: null }); + expect(witness.status).toBe(0); + expect(witness.stdout).toContain("set-brokerage"); + + const contract = run(["contract", "--help"], { password: null }); + expect(contract.status).toBe(0); + expect(contract.stdout).toContain("set-origin-energy-limit"); + expect(contract.stdout).toContain("set-user-resource-percent"); + expect(contract.stdout).toContain("create2"); + }); + + it("computes the Java-compatible TVM CREATE2 vector without RPC or wallet", () => { + const r = run( + [ + "-o", + "json", + "contract", + "create2", + "--deployer", + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "--code", + "60006000", + "--salt", + "1", + ], + { password: null }, + ); + expect(r.status).toBe(0); + expect(r.json.data).toMatchObject({ + saltHex: "0x0000000000000000000000000000000000000000000000000000000000000001", + address: "TFVMEWMJCq5fCmADjNzuhKnUFHJkJBBFAW", + }); + }); + + it("publishes build-only, expiration, and permission-id in governance schemas", () => { + const r = run(["proposal", "create", "--json-schema"], { password: null }); + expect(r.status).toBe(0); + expect(r.json.properties.buildOnly).toBeDefined(); + expect(r.json.properties.expiration).toBeDefined(); + expect(r.json.properties.permissionId).toBeDefined(); + expect(r.json.required).toContain("set"); + }); + + it("rejects brokerage and origin-energy int64 overflow before wallet or RPC access", () => { + const brokerage = run(["-o", "json", "witness", "set-brokerage", "101"], { password: null }); + expect(brokerage.status).toBe(2); + expect(brokerage.json.error.code).toBe("invalid_value"); + + const energy = run( + [ + "-o", + "json", + "contract", + "set-origin-energy-limit", + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "9223372036854775808", + ], + { password: null }, + ); + expect(energy.status).toBe(2); + expect(energy.json.error.code).toBe("invalid_value"); + }); +});