diff --git a/ts/README.md b/ts/README.md index e9a3dbc6a..ac876274a 100644 --- a/ts/README.md +++ b/ts/README.md @@ -148,6 +148,7 @@ Token and contract operations, resource staking, voting rewards, message signing | [`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 | +| [`typed-data`](docs/commands/typed-data/index.md) | Sign EIP-712 / TIP-712 structured data ([sign](docs/commands/typed-data/sign.md)) | ### Local tools and configuration diff --git a/ts/docs/commands/account/activate.md b/ts/docs/commands/account/activate.md index 311e8fe3d..1aec9905a 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 | diff --git a/ts/docs/commands/account/set.md b/ts/docs/commands/account/set.md index 5f9f6789c..8f0e046b6 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 | diff --git a/ts/docs/commands/asset/index.md b/ts/docs/commands/asset/index.md new file mode 100644 index 000000000..974136c44 --- /dev/null +++ b/ts/docs/commands/asset/index.md @@ -0,0 +1,37 @@ +# wallet-cli asset + +Issue and operate TRC10 tokens. + +TRC10 is TRON's **chain-native** token type: the protocol itself tracks issuance, an ICO window and frozen supply, with no smart contract involved. That is why it is a group of its own — [`token`](../token/index.md) handles TRC20 contract tokens, and the two share almost no mechanics. + +Two things shape everything in this group: + +- **An account may issue exactly one TRC10, ever.** `asset issue` burns a fee that is not refunded, and once it lands the account can never issue again. Only the description, URL and the two free-bandwidth limits stay changeable; supply, price, ICO dates, precision and the frozen tranches are fixed permanently. +- **Transfer is not here.** Sending TRC10 is [`tx send`](../tx/send.md) with an asset id — the same command you use for everything else. + +**Ledger cannot sign any of the write commands in this group.** The Ledger TRON app does not implement the TRC10 issuance contract types, so `issue`, `update`, `participate` and `unfreeze` require a software account and fail fast with `ledger_unsupported`. (TRC10 *transfer* via `tx send` does work on Ledger.) + +## 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) | Update the four mutable fields of your TRC10 | +| `asset participate` | [participate.md](participate.md) | Buy into a TRC10's ICO at its fixed rate | +| `asset unfreeze` | [unfreeze.md](unfreeze.md) | Release matured frozen supply | +| `asset info` | [info.md](info.md) | Show one TRC10 in full | +| `asset list` | [list.md](list.md) | List TRC10 tokens, one page at a time | + +## Units + +Command input and text output use **whole tokens**. JSON and the chain use **minimal units** — whole tokens scaled by the asset's `precision`. A token with `precision: 6` and a supply of 1,000,000,000 has an on-chain `total_supply` of `1000000000000000`. + +## See also + +[`token`](../token/index.md) (TRC20) · [`tx send`](../tx/send.md) (TRC10 transfer) · [`exchange`](../exchange/index.md) (trading TRC10 against TRX) diff --git a/ts/docs/commands/asset/info.md b/ts/docs/commands/asset/info.md new file mode 100644 index 000000000..23d5548a0 --- /dev/null +++ b/ts/docs/commands/asset/info.md @@ -0,0 +1,73 @@ +# wallet-cli asset info + +Show one TRC10 in full. + +## Synopsis + +``` +wallet-cli asset info [] [--issuer
] [options] +``` + +## Description + +Shows a single TRC10's complete record: issuer, total supply, precision, ICO rate and window, project URL, description, both free-bandwidth limits, and every frozen tranche with its unlock time. + +Give **exactly one** of the `` argument or `--issuer`. A purely numeric `` is read as an id; anything else is read as a name. `--issuer` looks up the token issued by an address — unique by construction, since an account can only issue one. + +**Token names are not unique.** Duplicate names have been permitted since `AllowSameTokenName` was enabled, and there really are duplicates on both mainnet and Nile. A name matching more than one token is an **error** (`ambiguous_asset_name`) carrying the matching ids, not a differently-shaped success — the JSON `data` shape for this command never varies, so an agent can rely on it. + +Quantities are whole tokens in text and minimal units in JSON; the record carries its own `precision`, so no extra lookup is involved either way. + +**Related but different:** [`token info`](../token/info.md) is the cross-type metadata lookup (name / symbol / decimals / total supply, TRC20 and TRC10 alike). This command gives the TRC10-only issuance record. + +## Arguments + +| Argument | Description | +|---|---| +| `` | Token id or name; a numeric value is read as the id. Exactly one of this or `--issuer` | + +## Options + +| Option | Description | +|---|---| +| `--issuer ` | Look up the token issued by this address. Exactly one of this or `` | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +By id: + +```bash +wallet-cli asset info 1000123 --network tron:nile +``` + +By name — fails with the candidate ids if the name is not unique: + +```bash +wallet-cli asset info MyToken --network tron:nile +``` + +By issuer: + +```bash +wallet-cli asset info --issuer TQkXm4vN...5Zt7Uw --network tron:nile +``` + +Machine-readable: + +```bash +wallet-cli asset info 1000123 --network tron:nile -o json +``` + +## Errors + +| Code | Meaning | +|---|---| +| `asset_not_found` | No TRC10 matches that id, name or issuer | +| `ambiguous_asset_name` | The name matches several tokens; `details.assetIds` lists them | +| `invalid_value` | Neither or both of `` and `--issuer` were given | + +## See also + +[`asset list`](list.md) · [`token info`](../token/info.md) · [`asset` group](index.md) diff --git a/ts/docs/commands/asset/issue.md b/ts/docs/commands/asset/issue.md new file mode 100644 index 000000000..7a2bcc917 --- /dev/null +++ b/ts/docs/commands/asset/issue.md @@ -0,0 +1,95 @@ +# 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 + +Issues a TRC10 token and fixes its ICO terms in the same transaction. + +**This is irreversible in two ways.** The issuance fee is burned and never refunded, and an account can only ever issue **one** TRC10 — get it wrong and your only option is a different account. There is no confirmation prompt (it would break scripted use); preview with `--dry-run` instead. + +Only `--description`, `--url`, `--free-net-per-account` and `--public-free-net` can be changed afterwards, via [`asset update`](update.md). Supply, price, ICO dates, precision and the frozen tranches have no on-chain modification path at all. + +**`--price` is converted using `--precision`.** On chain the rate is a pair of int32s meaning "`trx_num` sun buys `num` minimal units", so the same `--price 1:100` stores as `trx_num=1, num=100` at `--precision 6` but `trx_num=10000, num=1` at `--precision 0`. The CLI reduces the fraction to lowest terms and refuses the issuance if either side no longer fits in an int32 — a silently truncated rate would misprice the token permanently. + +`--start` and `--end` are always read as **UTC**, so they mean the same thing on any machine. A bare date is midnight UTC, which means the earliest date-only `--start` is tomorrow; pass a time to open the sale today. + +Chain limits we cannot read are not pre-checked. The node exposes no RPC for the maximum tranche count, the tranche day bounds or the daily bandwidth limit, so those are left to the node to reject — which costs nothing, because a rejected transaction never enters a block and burns no fee. + +**By default the command returns at submission**; `--wait` blocks until confirmed. **The asset id is assigned by the chain**, so it only appears in the receipt once confirmed — without `--wait` the response carries the txid and no `assetId`. + +**Ledger accounts are refused** (`ledger_unsupported`): the Ledger TRON app cannot decode `AssetIssueContract`. + +## Options + +| Option | Description | +|---|---| +| `--name ` | **Required.** Token name, 1–32 visible ASCII characters — no spaces, no non-ASCII | +| `--supply ` | **Required.** Total supply, in whole tokens | +| `--price :` | **Required.** ICO rate in whole TRX to whole tokens, e.g. `1:100` | +| `--start ` | **Required.** ICO start, `YYYY-MM-DD` or `"YYYY-MM-DD HH:mm:ss"`, read as UTC; must be in the future | +| `--end ` | **Required.** ICO end, same format, must be after `--start` | +| `--url ` | **Required.** Project page; must not be empty, up to 256 bytes | +| `--abbr ` | Token abbreviation; same character rules as `--name` | +| `--precision <0-6>` | Decimal places (default `0`) | +| `--description ` | Short description, up to 200 bytes | +| `--free-net-per-account ` | Free bandwidth each holder may use | +| `--public-free-net ` | Shared free bandwidth pool for holders | +| `--freeze :` | Frozen tranche, amount in whole tokens; repeatable for multiple tranches | +| `--dry-run` | 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 | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +In the examples, `$PW` is your master password, fed on stdin via `--password-stdin`. + +Preview before spending anything — always do this first: + +```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 --dry-run --password-stdin --network tron:nile +``` + +Issue with two frozen tranches, waiting for the id: + +```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 \ + --wait --password-stdin --network tron:nile +``` + +## Errors + +| Code | Meaning | +|---|---| +| `already_issued_asset` | This account has already issued a TRC10 | +| `invalid_asset_name` | `--name` / `--abbr` is not 1–32 visible ASCII characters | +| `invalid_value` | Price, precision, dates, byte lengths or tranche syntax out of range | +| `ledger_unsupported` | The account is Ledger-backed; use a software account | +| `watch_only_no_signer` | The account cannot sign | +| `transaction_rejected` | The node refused it — the message carries its reason | + +## See also + +[`asset update`](update.md) · [`asset info`](info.md) · [`asset` group](index.md) diff --git a/ts/docs/commands/asset/list.md b/ts/docs/commands/asset/list.md new file mode 100644 index 000000000..9abdbd286 --- /dev/null +++ b/ts/docs/commands/asset/list.md @@ -0,0 +1,58 @@ +# wallet-cli asset list + +List TRC10 tokens, one page at a time. + +## Synopsis + +``` +wallet-cli asset list [--limit ] [--offset ] [options] +``` + +## Description + +Lists TRC10 tokens with id, name, total supply, precision and issuer. Use [`asset info`](info.md) for one token in full. + +**Paged server-side, and small by default.** There are thousands of TRC10s on chain — around 5,200 on mainnet and 7,300 on Nile, roughly 2.7 MB if fetched in one go — so `--limit` defaults to **10**. Raise it deliberately; a tool call that returns five thousand records will exhaust an agent's context long before anyone notices. + +**No total is reported.** The paginated node endpoint does not return a count, and the only way to compute one is to transfer every record. [`meta.pagination`](../../machine-interface.md#reading-metapagination) therefore carries `total: null` — the count does not exist, rather than having been omitted — alongside `offset` and `limit`; the text header reads `Assets (limit 10, offset 0)`. Page until you get a short page. + +Total supply is shown in whole tokens; each record carries its own precision, so this costs no extra lookups. + +## Options + +| Option | Description | +|---|---| +| `--limit ` | Max tokens to return, 1–1000 (default `10`) | +| `--offset ` | Pagination offset (default `0`) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +First page: + +```bash +wallet-cli asset list --network tron:nile +``` + +Walk further in: + +```bash +wallet-cli asset list --limit 50 --offset 50 --network tron:nile +``` + +Machine-readable: + +```bash +wallet-cli asset list --limit 50 --network tron:nile -o json +``` + +## Errors + +| Code | Meaning | +|---|---| +| `invalid_value` | `--limit` outside 1–1000, or a negative `--offset` | + +## See also + +[`asset info`](info.md) · [`asset` group](index.md) diff --git a/ts/docs/commands/asset/participate.md b/ts/docs/commands/asset/participate.md new file mode 100644 index 000000000..a9457b51e --- /dev/null +++ b/ts/docs/commands/asset/participate.md @@ -0,0 +1,78 @@ +# wallet-cli asset participate + +Buy into a TRC10's ICO at its fixed rate. + +## Synopsis + +``` +wallet-cli asset participate --pay + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] +``` + +## Description + +Buys tokens directly from an issuer during its ICO window, at the rate fixed when the token was issued. This is **participation in the issuance**, not a market trade — there is no counterparty, no order book and no price discovery. To trade a TRC10 against TRX at a market-ish price, see [`exchange trade`](../exchange/trade.md). + +**`--pay` is the TRX you spend, not the tokens you receive.** The chain computes `floor(pay × num ÷ trx_num)` — multiply first, then truncate — and transfers your TRX in full, so a truncated remainder is not refunded. Paying too little to buy even one minimal unit is rejected before broadcast rather than sent and wasted. + +The issuer's address is resolved from the token automatically; you never pass it. + +`` is a token id or a name. A purely numeric value is read as an id. Names are not unique on chain — a name matching more than one token is rejected with `ambiguous_asset_name` and the matching ids, so re-run with the id. + +**By default the command returns at submission**; `--wait` blocks until confirmed. The received amount is exact integer arithmetic from the token's fixed rate, so it is reported in both cases. + +**Ledger accounts are refused** (`ledger_unsupported`): the Ledger TRON app cannot decode `ParticipateAssetIssueContract`. + +## Arguments + +| Argument | Description | +|---|---| +| `` | **Required.** Token id or name; a numeric value is read as the id | + +## Options + +| Option | Description | +|---|---| +| `--pay ` | **Required.** TRX to spend — not the number of tokens | +| `--dry-run` | 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 | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Spend 100 TRX on token 1000124: + +```bash +echo "$PW" | wallet-cli asset participate 1000124 --pay 100 \ + --wait --password-stdin --network tron:nile +``` + +Check what you would get before committing: + +```bash +echo "$PW" | wallet-cli asset participate 1000124 --pay 100 \ + --dry-run --password-stdin --network tron:nile +``` + +## Errors + +| Code | Meaning | +|---|---| +| `asset_not_found` | No TRC10 matches that id or name | +| `ambiguous_asset_name` | The name matches several tokens; `details.assetIds` lists them | +| `not_in_ico_window` | The funding window has not opened, or has closed | +| `self_participation` | An issuer cannot buy into its own ICO | +| `invalid_value` | `--pay` is not positive, or too small to buy one unit | +| `ledger_unsupported` | The account is Ledger-backed; use a software account | +| `watch_only_no_signer` | The account cannot sign | +| `transaction_rejected` | The node refused it — e.g. the issuer has run out of sellable supply | + +## See also + +[`asset info`](info.md) · [`exchange trade`](../exchange/trade.md) · [`asset` group](index.md) diff --git a/ts/docs/commands/asset/unfreeze.md b/ts/docs/commands/asset/unfreeze.md new file mode 100644 index 000000000..585b122b2 --- /dev/null +++ b/ts/docs/commands/asset/unfreeze.md @@ -0,0 +1,67 @@ +# 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 + +Releases the part of your token's supply that you locked at issuance and whose lock period has now elapsed. Released tokens return to the issuing account's balance. + +**Not to be confused with [`stake unfreeze`](../stake/unfreeze.md)**, which releases staked *TRX* in exchange for resources. This one releases frozen *TRC10 supply*. Different mechanism, different asset — they share only a verb. + +There are **no arguments**. It always targets the token issued by the signing account, and the chain releases **every matured tranche at once** — you cannot choose a tranche or a partial amount. Tranches that have not matured are untouched; run the command again later for those. + +A tranche's unlock time is fixed at issuance as `start_time + days`, computed from the ICO start rather than from when the issuance actually landed. [`asset info`](info.md) shows each tranche with its unlock time. + +**By default the command returns at submission**; `--wait` blocks until confirmed. The released amount is read from the transaction receipt, so it is exact only once confirmed; without `--wait` the response reports the amount we projected from the tranche table. + +**Ledger accounts are refused** (`ledger_unsupported`): the Ledger TRON app cannot decode `UnfreezeAssetContract`. + +## Options + +| Option | Description | +|---|---| +| `--dry-run` | 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 | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Check what has matured before spending bandwidth: + +```bash +wallet-cli asset info --issuer TQkXm4vN...5Zt7Uw --network tron:nile +``` + +Release everything that has matured: + +```bash +echo "$PW" | wallet-cli asset unfreeze --wait --password-stdin --network tron:nile +``` + +## Errors + +| Code | Meaning | +|---|---| +| `not_an_issuer` | This account has not issued a TRC10 | +| `no_frozen_supply` | The token was issued without any frozen tranche | +| `not_yet_unfreezable` | No tranche has matured yet; the message names the earliest unlock | +| `ledger_unsupported` | The account is Ledger-backed; use a software account | +| `watch_only_no_signer` | The account cannot sign | +| `transaction_rejected` | The node refused it — the message carries its reason | + +## See also + +[`asset info`](info.md) · [`asset issue`](issue.md) · [`stake unfreeze`](../stake/unfreeze.md) (a different thing) · [`asset` group](index.md) diff --git a/ts/docs/commands/asset/update.md b/ts/docs/commands/asset/update.md new file mode 100644 index 000000000..a50e43ad4 --- /dev/null +++ b/ts/docs/commands/asset/update.md @@ -0,0 +1,71 @@ +# wallet-cli asset update + +Update 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 + +Updates the only four fields of a TRC10 that can ever change: its description, its URL, and the two free-bandwidth limits. + +There is **no token argument** — the command always targets the token issued by the signing account. Supply, ICO price, ICO dates, precision and the frozen tranches were fixed at issuance and have no modification path on chain; changing them means issuing a new token from a different account. + +**Pass only the fields you want to change.** The chain overwrites all four in one operation, so anything you omit is read back from the current on-chain record and rewritten unchanged — omitting `--description` will not blank it. At least one field is required, or there would be nothing to do. + +**By default the command returns at submission**; `--wait` blocks until confirmed. + +**Ledger accounts are refused** (`ledger_unsupported`): the Ledger TRON app cannot decode `UpdateAssetContract`. + +## Options + +| Option | Description | +|---|---| +| `--description ` | New description, up to 200 bytes | +| `--url ` | New project page; must not be empty, up to 256 bytes | +| `--free-net-per-account ` | Free bandwidth each holder may use | +| `--public-free-net ` | Shared free bandwidth pool for holders | +| `--dry-run` | 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 | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Change only the URL; the other three keep their current values: + +```bash +echo "$PW" | wallet-cli asset update --url https://mytoken.io/v2 \ + --wait --password-stdin --network tron:nile +``` + +Raise both bandwidth allowances at once: + +```bash +echo "$PW" | wallet-cli asset update --free-net-per-account 1000 --public-free-net 10000 \ + --wait --password-stdin --network tron:nile +``` + +## Errors + +| Code | Meaning | +|---|---| +| `not_an_issuer` | This account has not issued a TRC10 | +| `invalid_value` | No field given, or URL/description out of bounds | +| `ledger_unsupported` | The account is Ledger-backed; use a software account | +| `watch_only_no_signer` | The account cannot sign | +| `transaction_rejected` | The node refused it — the message carries its reason | + +## See also + +[`asset issue`](issue.md) · [`asset info`](info.md) · [`asset` group](index.md) diff --git a/ts/docs/commands/backup.md b/ts/docs/commands/backup.md index 69b4d08cd..8e2065347 100644 --- a/ts/docs/commands/backup.md +++ b/ts/docs/commands/backup.md @@ -1,30 +1,56 @@ # wallet-cli backup -Export an account's secret + metadata to a 0600 file. +Export an account's secret to a 0600 file — natively, or as a standard Web3 keystore. With `--records`, list past exports instead. ## Synopsis ``` -wallet-cli backup [--out ] [options] +wallet-cli backup [--keystore] [--out ] [options] +wallet-cli backup --records [options] ``` ## Arguments -- `account` — account or wallet to export, by accountId, label, or address +- `account` — account or wallet to export, by accountId, label, or address. Required unless `--records` is given; with `--records` it selects **whose** exports to list. ## Options | Option | Description | |---|---| -| `--out ` | output file path; omit to write /backups/-.json; mode 0600, never overwritten | +| `--keystore` | Export as a standard Web3 keystore JSON instead of the native format | +| `--out ` | Output file path; omit to write `./-.json` in the **current directory** (`.keystore.json` with `--keystore`); mode 0600, never overwritten | | `--password-stdin` | read the master password from stdin (fd 0) | +Records options (with `--records`, instead of exporting): + +| Option | Description | +|---|---| +| `--records` | List past exports instead of exporting anything | +| `--from ` | Only records at or after this instant — `YYYY-MM-DD` or `YYYY-MM-DD HH:mm:ss`, **UTC**, inclusive | +| `--to ` | Only records at or before this instant, same format, inclusive | +| `--limit ` | Max records to return; omit for all | +| `--offset ` | Pagination offset (default `0`) | +| `--account ` | Only exports of this account, by accountId / label / address | + Plus [global options](index.md). ## Notes The file contains recoverable secret material — move it to secure storage and treat it as the key itself. See [Security](../concepts/security.md). +> ⚠️ **Exports land in the current working directory** by default (changed in v4.12.0 — v4.11.0 wrote them under `/backups/`; the filename is unchanged, only the directory). Do **not** run `backup` in a shared directory or inside a git repository. wallet-cli guarantees only mode 0600 and never overwriting an existing file; it does not vet the directory or check whether it is version-controlled. + +### Native format vs `--keystore` + +| | native (default) | `--keystore` | +|---|---|---| +| Contents | The account's own secret — the **mnemonic** for an HD wallet, the private key for a private-key wallet | Exactly **one private key**; an HD account exports only the key at its current index | +| Can rebuild the whole wallet? | Yes — re-import with [`import mnemonic`](import/mnemonic.md) | No. Nothing is derivable from it; it is an isolated account elsewhere | +| Read by other wallets? | No — wallet-cli's own format | Yes — standard V3 (`aes-128-ctr`, scrypt), importable by TronLink and the Java wallet-cli | +| Encrypted with | Not encrypted; the file itself is the secret | Your **master password** — that is also the password that opens it elsewhere | + +Watch-only and Ledger accounts hold no exportable secret and fail with `not_exportable` — checked **before** any password is demanded. + ## Examples In the examples, `$PW` is your master password (from an environment variable, password manager, etc.), fed on stdin via `--password-stdin`. @@ -34,7 +60,7 @@ 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 +69,95 @@ printf '%s' "$PW" | wallet-cli backup main --password-stdin ⚠️ Secret material was written only to the backup file, never to stdout. ``` +```bash +printf '%s' "$PW" | wallet-cli backup main --keystore --password-stdin +``` + +```console +⚠️ Keystore written ./wlt_d1qbj2fb.0-1783751611076.keystore.json + Account ID wlt_d1qbj2fb.0 + Secret private key + File mode 0600 + Bytes 608 + +⚠️ 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 ``` ```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":"TJToBi4Ngr6JT3HqZHfCkKvuQTvqm73HHp"},"seedId":"wlt_d1qbj2fb","secretType":"mnemonic","format":"native","out":"./main-backup.json","fileMode":"0600","bytes":277},"meta":{"durationMs":1387,"warnings":[]}} +``` + +```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 | TJToBi4Ngr...vqm73HHp (main) | backup --keystore | ./wlt_d1qbj2fb.0-1785930000000.keystore.json | +| 2026-08-04 09:12 | TJToBi4Ngr...vqm73HHp (main) | backup | ./wlt_d1qbj2fb.0-1785834720000.json | +| 2026-07-30 22:03 | TBeta9mRk1...gW8pLxQ2 | backup | ./tbeta-seed.json | +``` + +```bash +wallet-cli backup --records --account main --from 2026-08-01 -o json +``` + +```json +{"schema":"wallet-cli.result.v1","success":true,"command":"backup.records","data":{"records":[{"operation":"backup --keystore","accountId":"wlt_d1qbj2fb.0","account":"TJToBi4Ngr6JT3HqZHfCkKvuQTvqm73HHp","label":"main","out":"./wlt_d1qbj2fb.0-1785930000000.keystore.json","timestamp":"2026-08-05T11:40:00Z"}]},"meta":{"durationMs":8,"warnings":[],"pagination":{"offset":0,"limit":null,"total":1}}} ``` ## 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. +The two modes return **different shapes** and therefore different `command` ids: exporting reports `"command":"backup"`, the audit log reports `"command":"backup.records"`. Branch on that rather than probing for fields. + +### Export (`backup [--keystore]`) + +`data` is the exported account plus the file details. The secret is written only to the file, never to stdout. Local command — no `chain` block. | 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` (always `privateKey` with `--keystore`) | +| `format` | string | `"native"` or `"keystore"` | +| `out` | string | Written file path | | `fileMode` | string | File permissions, always `0600` | | `bytes` | number | File size in bytes | +### Audit log (`backup --records`) + +`data.records` is newest-first. The window is envelope metadata — [`meta.pagination`](../machine-interface.md#reading-metapagination) — carrying `offset`, `limit` (`null` when unlimited) and the pre-window `total` (always a number here: the log is local, so the count is always knowable). + +| Field | Type | Meaning | +|---|---|---| +| `operation` | string | `"backup"` or `"backup --keystore"` | +| `accountId` | string | The account whose secret was exported, as identified **at export time** | +| `account` | string | That account's TRON address | +| `label` | string \| null | Its label at export time (`null` if it had none) | +| `out` | string | The file the secret was written to | +| `timestamp` | string | UTC ISO-8601, second precision | + +Every field is a **snapshot** taken when the export happened and is never re-resolved, so a later rename or deletion cannot rewrite history. `--account` still finds those records: it matches on either the recorded accountId or the recorded address. + +Only **exports** are logged — `import` commands are not, since the log exists to trace secret material *leaving* this machine. Retention is a fixed **1000** most-recent entries (not configurable); older ones are dropped. The log itself holds no secrets, so `--records` needs no master password. + ## Exit status `0` success · `1` execution failure · `2` usage error. See [machine-interface](../machine-interface.md). +Notable codes: `not_exportable` (watch-only / Ledger account), `auth_failed` (wrong master password), `output_exists` (target file already exists — never overwritten), `io_error` (target path unwritable), `invalid_value` (bad `--from`/`--to`/`--limit`, or an export flag combined with `--records`). + ## See also -[Security model](../concepts/security.md) · [`delete`](delete.md) +[Security model](../concepts/security.md) · [`import keystore`](import/keystore.md) · [`import mnemonic`](import/mnemonic.md) · [`delete`](delete.md) diff --git a/ts/docs/commands/contract/clear-abi.md b/ts/docs/commands/contract/clear-abi.md new file mode 100644 index 000000000..2ae192e32 --- /dev/null +++ b/ts/docs/commands/contract/clear-abi.md @@ -0,0 +1,35 @@ +# wallet-cli contract clear-abi + +Irreversibly remove a contract's on-chain ABI metadata. + +## Synopsis + +``` +wallet-cli contract clear-abi
[--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +Only `SmartContract.origin_address` may execute this operation. The CLI verifies that address before building. Clearing the ABI does not alter bytecode or storage, but explorers and SDKs can no longer discover the interface from chain metadata. It cannot be restored. + +## Options + +`
` is required. Transaction controls are `--dry-run`, `--sign-only`, `--build-only`, `--expiration` (build/sign-only, max 24 h), `--permission-id`, `--account`, `--wait`, and `--password-stdin`. + +## Example + +```bash +echo "$PW" | wallet-cli contract clear-abi TQ5nJ8mV... --network tron:nile --wait --password-stdin +``` + +## Output + +Returns `kind: "contract-clear-abi"`, contract/deployer addresses, transaction stage/id, and confirmed resource usage. + +## Exit status + +`0` built/signed/submitted · `1` `contract_not_found`, `not_contract_deployer`, signer/auth, RPC, or chain failure · `2` invalid input. + +## See also + +[`contract info`](info.md) · [`contract set-origin-energy-limit`](set-origin-energy-limit.md) diff --git a/ts/docs/commands/contract/create2.md b/ts/docs/commands/contract/create2.md new file mode 100644 index 000000000..cac613dd2 --- /dev/null +++ b/ts/docs/commands/contract/create2.md @@ -0,0 +1,48 @@ +# wallet-cli contract create2 + +Compute a TVM CREATE2 contract address locally. + +## Synopsis + +``` +wallet-cli contract create2 --deployer
(--code | --code-file ) --salt +``` + +## Description + +No RPC, wallet, signature, or broadcast is involved. The input must be creation bytecode with encoded constructor arguments appended. The formula matches Java wallet-cli: + +``` +keccak256(deployer_21_bytes || salt_32_bytes || keccak256(creation_code)) +``` + +The 21-byte result is obtained by replacing the first byte of hash slice `[11:32]` with `0x41`, then Base58Check encoding. Unlike Ethereum CREATE2 there is no `0xff`. Salt is a signed decimal Java `long`; its two's-complement 8 bytes occupy offsets 24–31 of a zeroed 32-byte value. + +## Options + +| Option | Description | +|---|---| +| `--deployer
` | Required TRON account or factory address | +| `--code ` | Creation bytecode; whitespace and optional `0x` are stripped | +| `--code-file ` | Read creation bytecode from a file; exclusive with `--code` | +| `--salt ` | Required signed 64-bit decimal integer | + +## Example + +```bash +wallet-cli contract create2 --deployer TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t --code 60006000 --salt 1 -o json +``` + +The example resolves to `TFVMEWMJCq5fCmADjNzuhKnUFHJkJBBFAW`. + +## Output + +Returns `deployerAddress`, decimal `salt`, zero-padded `saltHex`, `codeHash`, and Base58Check `address`. + +## Exit status + +`0` success · `2` `invalid_address`, `invalid_value`, or `file_not_found`. + +## See also + +[`contract deploy`](deploy.md) · [`contract info`](info.md) diff --git a/ts/docs/commands/contract/deploy.md b/ts/docs/commands/contract/deploy.md index 751d95e4d..12a88e644 100644 --- a/ts/docs/commands/contract/deploy.md +++ b/ts/docs/commands/contract/deploy.md @@ -14,7 +14,7 @@ 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. -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. +Same execution model as other broadcast commands: `--dry-run` previews, `--sign-only` outputs a signed transaction for [`tx broadcast`](../tx/broadcast.md), and `--build-only` emits the unsigned transaction without touching a signer. `--expiration` is restricted to build/sign-only; `--permission-id` selects the TRON permission group. Default returns at submission and `--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 +30,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 | @@ -68,6 +68,9 @@ echo "$PW" | wallet-cli contract deploy --abi "$(cat MyToken.abi.json)" --byteco |---|---| | default (submit) | `kind: "contract-deploy"`, `contractAddress` (deterministic new address), `stage: "submitted"`, `txId` | | `--wait` (confirmed) | above, plus `confirmed`, `blockNumber`, `feeSun`, `failed` | +| `--dry-run` | `kind`, `mode: "dry-run"`, unsigned `tx`, fee estimate, deterministic `contractAddress` | +| `--sign-only` | `kind`, `mode: "sign-only"`, `signed`, signer address, tx id, `contractAddress` | +| `--build-only` | `kind`, `mode: "build-only"`, `unsigned`, `unsignedHex`, `contractAddress` | ## Exit status diff --git a/ts/docs/commands/contract/index.md b/ts/docs/commands/contract/index.md index ccc7774a6..8fcb0e66d 100644 --- a/ts/docs/commands/contract/index.md +++ b/ts/docs/commands/contract/index.md @@ -1,6 +1,6 @@ # wallet-cli contract -Call, send, deploy, and inspect smart contracts. +Call, deploy, inspect, and govern smart contracts. ## Synopsis @@ -16,6 +16,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) | Irreversibly remove on-chain ABI metadata | +| `contract set-origin-energy-limit` | [set-origin-energy-limit.md](set-origin-energy-limit.md) | Set the deployer's per-call energy contribution cap | +| `contract set-user-resource-percent` | [set-user-resource-percent.md](set-user-resource-percent.md) | Set the caller-paid energy percentage | +| `contract create2` | [create2.md](create2.md) | Compute a TVM CREATE2 address locally | ## See also diff --git a/ts/docs/commands/contract/send.md b/ts/docs/commands/contract/send.md index e410d639a..c76ed27f3 100644 --- a/ts/docs/commands/contract/send.md +++ b/ts/docs/commands/contract/send.md @@ -14,7 +14,7 @@ wallet-cli contract send --contract
--method [--params ] Builds, signs, and broadcasts a state-changing contract call from the active account (or `--account`). Parameters follow the same `{type,value}` JSON-array convention as [`contract call`](call.md); `--call-value-sun` attaches native TRX to the call. -Two early exits: `--dry-run` previews the energy cost (estimateEnergy) without signing or broadcasting; `--sign-only` signs and prints the transaction for a later [`tx broadcast`](../tx/broadcast.md). +Three early exits are available: `--dry-run` previews energy, `--sign-only` emits a signed transaction, and `--build-only` emits an unsigned transaction without resolving a signer. `--expiration` is valid only with build/sign-only; `--permission-id` selects the TRON permission group. **By default the command returns at submission** (`stage: "submitted"`) — add `--wait` to block until confirmed/failed. With `--wait`, an on-chain execution failure (revert / `OUT_OF_ENERGY`) comes back as `stage: "failed"` with the `result` reason. @@ -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..b1eac02d0 --- /dev/null +++ b/ts/docs/commands/contract/set-origin-energy-limit.md @@ -0,0 +1,41 @@ +# wallet-cli contract set-origin-energy-limit + +Set the deployer's per-call energy contribution cap. + +## Synopsis + +``` +wallet-cli contract set-origin-energy-limit
+ [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +`origin_energy_limit` caps what the deployer can cover for one call; it is not a total contract or caller limit. The actual subsidy is also bounded by the deployer's staked energy and the caller/deployer split. The CLI requires `energy > 0`, verifies `origin_address`, and locally builds the protocol transaction without TronWeb's obsolete 10,000,000 policy cap. + +## Arguments + +| Argument | Description | +|---|---| +| `address` | Contract governed by the selected deployer account | +| `energy` | Positive signed-int64 energy cap | + +Transaction controls are `--dry-run`, `--sign-only`, `--build-only`, `--expiration` (build/sign-only, max 24 h), `--permission-id`, `--account`, `--wait`, and `--password-stdin`. + +## Example + +```bash +echo "$PW" | wallet-cli contract set-origin-energy-limit TQ5nJ8mV... 50000000 --network tron:nile --wait --password-stdin +``` + +## Output + +Returns contract/deployer addresses, `originEnergyLimit`, transaction stage/id, and confirmed resource usage. + +## Exit status + +`0` built/signed/submitted · `1` `contract_not_found`, `not_contract_deployer`, signer/auth, RPC, or chain failure · `2` non-positive/out-of-int64 integer or invalid mode. + +## See also + +[`contract set-user-resource-percent`](set-user-resource-percent.md) · [Energy & bandwidth](../../concepts/energy-bandwidth.md) 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..ee0b61827 --- /dev/null +++ b/ts/docs/commands/contract/set-user-resource-percent.md @@ -0,0 +1,41 @@ +# wallet-cli contract set-user-resource-percent + +Set the percentage of call energy paid by the caller. + +## Synopsis + +``` +wallet-cli contract set-user-resource-percent
+ [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +The value maps unchanged to `consume_user_resource_percent`: 100 means the caller pays all energy; 0 assigns the full nominal share to the deployer, still capped by `origin_energy_limit` and available staked energy. Only the contract's `origin_address` may change it. + +## Arguments + +| Argument | Description | +|---|---| +| `address` | Contract governed by the selected deployer account | +| `percent` | Integer 0–100 paid by the caller | + +Transaction controls are `--dry-run`, `--sign-only`, `--build-only`, `--expiration` (build/sign-only, max 24 h), `--permission-id`, `--account`, `--wait`, and `--password-stdin`. + +## Example + +```bash +echo "$PW" | wallet-cli contract set-user-resource-percent TQ5nJ8mV... 100 --network tron:nile --wait --password-stdin +``` + +## Output + +Returns contract/deployer addresses, `consumeUserResourcePercent`, transaction stage/id, and confirmed resource usage. + +## Exit status + +`0` built/signed/submitted · `1` `contract_not_found`, `not_contract_deployer`, signer/auth, RPC, or chain failure · `2` percentage or mode error. + +## See also + +[`contract set-origin-energy-limit`](set-origin-energy-limit.md) · [Energy & bandwidth](../../concepts/energy-bandwidth.md) diff --git a/ts/docs/commands/exchange/create.md b/ts/docs/commands/exchange/create.md new file mode 100644 index 000000000..811f6d095 --- /dev/null +++ b/ts/docs/commands/exchange/create.md @@ -0,0 +1,70 @@ +# wallet-cli exchange create + +Create a Bancor 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 Bancor exchange pair and seeds it with liquidity on both sides in one transaction. + +**Irreversible in one respect:** the creating account is the **only** account that can ever inject or withdraw this pair's liquidity, and the chain has no path to transfer that. Create with the wrong account and the liquidity is reachable only from that account. The creation fee is burned on top of both initial amounts leaving your balance. + +Either side may be TRX or a TRC10 id, and the two must differ. **Sides keep the order you type** — `--pair TRX:1000123` puts TRX first on chain, `--pair 1000123:TRX` puts it second. Both orders are valid; the pair reads the same either way. + +The **ratio of the two initial amounts is the pair's starting price**. `--pair TRX:1000123 --amounts 10000:500000` opens a pair quoting roughly 1 TRX ≈ 50 units of token 1000123. Every trade thereafter moves it. + +**By default the command returns at submission**; `--wait` blocks until confirmed. **The exchange id is assigned by the chain**, so it appears only once confirmed — without `--wait` you get the txid and no `exchangeId`. + +## Options + +| Option | Description | +|---|---| +| `--pair :` | **Required.** The two sides — `TRX` or a numeric TRC10 id; they must differ | +| `--amounts :` | Amount for each side, in whole tokens, in `--pair` order. Exactly one of this or `--raw-amounts` | +| `--raw-amounts :` | Amount for each side, in minimal units. Exactly one of this or `--amounts` | +| `--dry-run` | 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 | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Preview first — this one burns a fee: + +```bash +echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 \ + --dry-run --password-stdin --network tron:nile +``` + +Create and wait for the id: + +```bash +echo "$PW" | wallet-cli exchange create --pair TRX:1000123 --amounts 10000:500000 \ + --wait --password-stdin --network tron:nile +``` + +## Errors + +| Code | Meaning | +|---|---| +| `same_token` | Both sides name the same token | +| `invalid_value` | A side is not a token id, or an amount is not positive | +| `invalid_option` | Neither or both of `--amounts` / `--raw-amounts` | +| `asset_not_found` | A TRC10 id in the pair does not exist | +| `watch_only_no_signer` | The account cannot sign | +| `transaction_rejected` | The node refused it — e.g. not enough TRX for the fee, or a reserve limit | + +## See also + +[`exchange inject`](inject.md) · [`exchange show`](show.md) · [`exchange` group](index.md) diff --git a/ts/docs/commands/exchange/index.md b/ts/docs/commands/exchange/index.md new file mode 100644 index 000000000..9209a32b9 --- /dev/null +++ b/ts/docs/commands/exchange/index.md @@ -0,0 +1,43 @@ +# wallet-cli exchange + +Trade TRX and TRC10 on TRON's built-in Bancor market maker. + +TRON carries an automatic market maker at **protocol level**: no order book, no counterparty, no matching. A pair holds two reserves, and price follows a curve between them. It trades TRX and TRC10 only — TRC20 contract tokens are not eligible. + +## Four things that run against intuition + +- **Only the creator can inject or withdraw.** A pair is one account's private market-making position, not a pool anyone can join, and the binding cannot be transferred. Create with the wrong account and that liquidity is reachable only from that account, forever. +- **TRX's on-chain token id is `_`.** We accept `TRX` in any case, the literal `_`, or a numeric TRC10 id. +- **`--min-received` is a floor, not an expectation.** If the trade would return less, it reverts and you lose only bandwidth. +- **The protocol takes no fee.** `inject`, `withdraw` and `trade` cost bandwidth only; just `create` burns a fee. + +## Pricing + +The reserve ratio is a **quoted rate, not a fill price**. Every trade with size moves along the curve and gets less than the ratio suggests — that gap is price impact, and it grows with size relative to the reserves. `exchange show` tells you how deep a pair is; `exchange trade --dry-run` prices a specific amount. + +Our price prediction is an **estimate**. It reproduces java-tron's own arithmetic, but the chain evaluates it with Java's `StrictMath.pow`, which JavaScript does not guarantee to match bit-for-bit. So it derives the `--slippage` floor and the `--dry-run` preview, and is never a reason to refuse a transaction. + +## 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 to a pair you created | +| `exchange withdraw` | [withdraw.md](withdraw.md) | Take liquidity out of a pair you created | +| `exchange trade` | [trade.md](trade.md) | Swap one side for the other | +| `exchange show` | [show.md](show.md) | Show one pair | +| `exchange list` | [list.md](list.md) | List pairs, one page at a time | + +## Token ids, never names + +Every token argument here takes `TRX` or a numeric TRC10 id. Names are refused on purpose: a TRC10 name may legally contain `:`, which would make `--pair A:B:1000123` ambiguous. Look an id up with [`asset info `](../asset/info.md). + +## See also + +[`asset`](../asset/index.md) (TRC10 issuance) · [`tx send`](../tx/send.md) diff --git a/ts/docs/commands/exchange/inject.md b/ts/docs/commands/exchange/inject.md new file mode 100644 index 000000000..d881ccbab --- /dev/null +++ b/ts/docs/commands/exchange/inject.md @@ -0,0 +1,76 @@ +# wallet-cli exchange inject + +Add liquidity to a pair you created. + +## Synopsis + +``` +wallet-cli exchange inject --token (--amount | --raw-amount ) + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] +``` + +## Description + +Adds liquidity to an exchange pair in proportion to its current reserves. + +**Injection is two-sided.** You name one side and its amount; the chain computes the other side from the current ratio and debits that as well. You therefore need enough of **both** tokens — having plenty of one is not enough. The other side is `floor(otherReserve x amount / thisReserve)`, exact integer arithmetic, and the CLI refuses before broadcast when that works out to zero. + +**Only the account that created the pair may do this**, and the binding cannot be moved. + +Adding liquidity proportionally does not move the price; it deepens the pair, which reduces the price impact of later trades. + +**By default the command returns at submission**; `--wait` blocks until confirmed. + +## Arguments + +| Argument | Description | +|---|---| +| `` | **Required.** Exchange pair id | + +## Options + +| Option | Description | +|---|---| +| `--token ` | **Required.** The side you are specifying | +| `--amount ` | Amount for that side, in whole tokens. Exactly one of this or `--raw-amount` | +| `--raw-amount ` | Amount for that side, in minimal units. Exactly one of this or `--amount` | +| `--dry-run` | 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 | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Add 1,000 TRX and whatever the ratio requires of the other side: + +```bash +echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 \ + --wait --password-stdin --network tron:nile +``` + +See what the other side would cost, without sending anything: + +```bash +echo "$PW" | wallet-cli exchange inject 12 --token TRX --amount 1000 \ + --dry-run --password-stdin --network tron:nile +``` + +## Errors + +| Code | Meaning | +|---|---| +| `exchange_not_found` | No pair has that id | +| `not_exchange_creator` | Only the creating account can add liquidity | +| `token_not_in_exchange` | That token is not one of the pair's two sides | +| `exchange_closed` | One side holds nothing | +| `invalid_value` | The amount is not positive, or the other side works out to zero | +| `transaction_rejected` | The node refused it — e.g. not enough of either token | + +## See also + +[`exchange withdraw`](withdraw.md) · [`exchange show`](show.md) · [`exchange` group](index.md) diff --git a/ts/docs/commands/exchange/list.md b/ts/docs/commands/exchange/list.md new file mode 100644 index 000000000..e42ed08b0 --- /dev/null +++ b/ts/docs/commands/exchange/list.md @@ -0,0 +1,48 @@ +# wallet-cli exchange list + +List exchange pairs, one page at a time. + +## Synopsis + +``` +wallet-cli exchange list [--limit ] [--offset ] [options] +``` + +## Description + +Lists exchange pairs with their two token ids, reserves and creator. + +**This is exactly one RPC per call, and never looks a token up.** An exchange record carries only ids and balances — no name, no precision — so rendering whole tokens would mean one lookup per distinct token per row. Instead, tokens are shown **by id** and reserves in **minimal units**, with the column labelled to match. The label matters: `198100000` is either 198.1 tokens or 198,100,000 depending on a precision the record does not carry, and putting it under a bare "Reserves" heading beside TRX would mislead. + +Use [`exchange show`](show.md) for one pair with names and whole tokens. + +**No total is reported.** The chain does not return one without transferring every record. [`meta.pagination`](../../machine-interface.md#reading-metapagination) therefore carries `total: null` — the count does not exist, rather than having been omitted — alongside `offset` and `limit`. Page until you get a short page. + +## Options + +| Option | Description | +|---|---| +| `--limit ` | Max pairs to return, 1-1000 (default `10`) | +| `--offset ` | Pagination offset (default `0`) | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli exchange list --network tron:nile +``` + +```bash +wallet-cli exchange list --limit 50 --offset 50 --network tron:nile +``` + +## Errors + +| Code | Meaning | +|---|---| +| `invalid_value` | `--limit` outside 1-1000, or a negative `--offset` | + +## See also + +[`exchange show`](show.md) · [`exchange` group](index.md) diff --git a/ts/docs/commands/exchange/show.md b/ts/docs/commands/exchange/show.md new file mode 100644 index 000000000..8e893a80f --- /dev/null +++ b/ts/docs/commands/exchange/show.md @@ -0,0 +1,48 @@ +# wallet-cli exchange show + +Show one exchange pair. + +## Synopsis + +``` +wallet-cli exchange show [options] +``` + +## Description + +Shows a single pair: creator, creation time, and both tokens with their reserves in whole tokens. Names and precisions are resolved for the two sides, which costs at most two extra lookups — acceptable for one pair, and the reason [`exchange list`](list.md) does not do it per row. + +**No price is shown, on purpose.** The reserve ratio is a quoted rate, not what a trade returns: any trade with size moves along the curve and gets less. Showing the ratio as a price invites people to read it as executable. To price a specific amount at the current reserves, use [`exchange trade --dry-run`](trade.md). + +The reserves themselves are the useful signal — they tell you how deep the pair is, and therefore how much price impact a given trade will suffer. + +## Arguments + +| Argument | Description | +|---|---| +| `` | **Required.** Exchange pair id | + +## Options + +Only the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +wallet-cli exchange show 12 --network tron:nile +``` + +```bash +wallet-cli exchange show 12 --network tron:nile -o json +``` + +## Errors + +| Code | Meaning | +|---|---| +| `exchange_not_found` | No pair has that id | +| `asset_not_found` | A TRC10 side references an id that no longer resolves | + +## See also + +[`exchange list`](list.md) · [`exchange trade`](trade.md) · [`exchange` group](index.md) diff --git a/ts/docs/commands/exchange/trade.md b/ts/docs/commands/exchange/trade.md new file mode 100644 index 000000000..86b28fa67 --- /dev/null +++ b/ts/docs/commands/exchange/trade.md @@ -0,0 +1,95 @@ +# 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 an exchange pair for the other, priced along the Bancor curve. It settles immediately, needs no counterparty, and **anyone may trade** — unlike liquidity operations, this is not restricted to the creator. The protocol charges no fee; only bandwidth is spent. + +### Slippage protection + +`--min-received` is a **floor, not an expected return**. If the trade would return less than it, the whole trade reverts on chain and you lose only bandwidth. It is the only defence against the price moving between the moment you sign and the moment the transaction lands. + +`--slippage` is the convenient form: the CLI reads the current reserves, predicts the return, subtracts your percentage and sends the result as the floor. What goes on chain is always an absolute number. + +**With none of the three flags there is no slippage protection.** The protocol has no "unprotected" mode — `expected` must be positive — so this sends `expected = 1`, meaning "accept any non-zero return at any price". The response carries a `meta.warnings` entry saying so. That is a real risk on a thin pair; pass `--slippage` unless you mean it. + +A derived floor is anchored to the reserves **at build time**, on every execution path including `--sign-only` and `--build-only`. That is a deliberate commitment — "no worse than N% below what this was worth when I built it" — which is what signing anything in advance means. + +### Pricing is an estimate + +The predicted return reproduces java-tron's own arithmetic, but the chain evaluates it with Java's `StrictMath.pow`, which JavaScript does not guarantee to match to the last unit. It is therefore used to derive floors and previews, never to refuse a trade. Use `--dry-run` to price a specific amount at the current reserves. + +**By default the command returns at submission**; `--wait` blocks until confirmed. The realised return comes from the transaction receipt, so before confirmation the receipt shows an estimated return rather than a settled one. + +## Arguments + +| Argument | Description | +|---|---| +| `` | **Required.** Exchange pair id | + +## Options + +| Option | Description | +|---|---| +| `--sell ` | **Required.** The side you are selling; the other is what you buy | +| `--amount ` | How much to sell, in whole tokens. Exactly one of this or `--raw-amount` | +| `--raw-amount ` | How much to sell, in minimal units. Exactly one of this or `--amount` | +| `--min-received ` | Lowest acceptable return, in whole tokens; at most one of the three floor flags | +| `--raw-min-received ` | Lowest acceptable return, in minimal units; at most one of the three floor flags | +| `--slippage ` | Derive the floor from current reserves less this percentage, `0 < p < 100`; at most one of the three floor flags | +| `--dry-run` | 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 | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +Price it first: + +```bash +echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --slippage 1 \ + --dry-run --password-stdin --network tron:nile +``` + +Trade with a 1% floor: + +```bash +echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --slippage 1 \ + --wait --password-stdin --network tron:nile +``` + +Trade with an absolute floor you chose: + +```bash +echo "$PW" | wallet-cli exchange trade 12 --sell TRX --amount 100 --min-received 4900 \ + --wait --password-stdin --network tron:nile +``` + +## Errors + +| Code | Meaning | +|---|---| +| `exchange_not_found` | No pair has that id | +| `token_not_in_exchange` | That token is not one of the pair's two sides | +| `exchange_closed` | One side holds nothing | +| `invalid_value` | The amount is not positive, `--slippage` is outside `(0, 100)`, or the trade is too small to return anything | +| `invalid_option` | More than one floor flag, or neither/both amount flags | +| `transaction_rejected` | The node refused it — `token required must greater than expected` means the floor was not met | + +## See also + +[`exchange show`](show.md) · [`exchange` group](index.md) diff --git a/ts/docs/commands/exchange/withdraw.md b/ts/docs/commands/exchange/withdraw.md new file mode 100644 index 000000000..c98ffc523 --- /dev/null +++ b/ts/docs/commands/exchange/withdraw.md @@ -0,0 +1,66 @@ +# wallet-cli exchange withdraw + +Take liquidity out of a pair you created. + +## Synopsis + +``` +wallet-cli exchange withdraw --token (--amount | --raw-amount ) + [--dry-run | (--sign-only | --build-only) [--expiration ] | --wait [--wait-timeout ]] [--permission-id ] [options] +``` + +## Description + +Removes liquidity from an exchange pair in proportion to its current reserves. + +Like [`inject`](inject.md), this is **two-sided**: you name one side and its amount, the other side follows the ratio and is returned as well. **Only the account that created the pair may do this.** + +**Odd amounts get rejected on chain for lack of precision.** The chain requires the proportional quotient to be near-exact: rounded to four decimal places it may exceed the whole-number result by no more than 0.01% of it. In practice, awkward amounts fail with `Not precise enough` — round to a cleaner number and try again. This one is left to the node rather than pre-checked locally, because which of two hardfork variants of the rule is active cannot be read from any RPC, and refusing a withdrawal the chain would have accepted is worse than one wasted bandwidth charge. + +**By default the command returns at submission**; `--wait` blocks until confirmed. + +## Arguments + +| Argument | Description | +|---|---| +| `` | **Required.** Exchange pair id | + +## Options + +| Option | Description | +|---|---| +| `--token ` | **Required.** The side you are specifying | +| `--amount ` | Amount for that side, in whole tokens. Exactly one of this or `--raw-amount` | +| `--raw-amount ` | Amount for that side, in minimal units. Exactly one of this or `--amount` | +| `--dry-run` | 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 | + +Plus the [global options](../index.md#global-options-every-command). + +## Examples + +```bash +echo "$PW" | wallet-cli exchange withdraw 12 --token TRX --amount 1000 \ + --wait --password-stdin --network tron:nile +``` + +## Errors + +| Code | Meaning | +|---|---| +| `exchange_not_found` | No pair has that id | +| `not_exchange_creator` | Only the creating account can remove liquidity | +| `token_not_in_exchange` | That token is not one of the pair's two sides | +| `exchange_closed` | One side holds nothing | +| `insufficient_reserve` | The pair does not hold that much | +| `invalid_value` | The amount is not positive, or the other side works out to zero | +| `transaction_rejected` | The node refused it — `Not precise enough` means the amount does not divide the ratio cleanly | + +## See also + +[`exchange inject`](inject.md) · [`exchange show`](show.md) · [`exchange` group](index.md) diff --git a/ts/docs/commands/import/index.md b/ts/docs/commands/import/index.md index 50aa6aa52..cff6fbbe8 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 standard Web3 keystore JSON | | `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..1dbf1e3d0 --- /dev/null +++ b/ts/docs/commands/import/keystore.md @@ -0,0 +1,96 @@ +# wallet-cli import keystore + +Import a single account from a standard Web3 keystore JSON. **Interactive-only.** + +> **Note**: there are no stdin flags here. **Two** passwords are entered via hidden TTY prompts — your master password (to store the key locally) and the keystore file's own password (to decrypt it). They may differ. A keystore password is a raw secret, so it follows the same TTY-only rule as a mnemonic or private key. + +## Synopsis + +``` +wallet-cli import keystore [--label ] +``` + +## Description + +Reads a standard **V3** keystore (`version: 3`) as exported by TronLink, the Java wallet-cli, or [`backup --keystore`](../backup.md), and stores the private key it holds encrypted under your master password. The imported wallet becomes active. + +A keystore carries **one private key and no seed** — nothing can be derived from it, so the account is standalone (`type: "privateKey"`, `index: null`). To move a whole HD wallet, use the native [`backup`](../backup.md) (which exports the mnemonic) and [`import mnemonic`](mnemonic.md). + +The file is read and structurally validated **before** either password is requested, so a mistyped path costs no prompts. Accepted files use `aes-128-ctr` with either `scrypt` or `pbkdf2` (hmac-sha256) — the same set the Java implementation accepts. Anything else, including wallet-cli's own internal `version: 1` vault blobs, is rejected with `invalid_keystore`. + +**A same-address account is refused, not overwritten.** This is a deliberate deviation from the Java implementation, which silently replaces it: that account may be an HD account whose seed the overwrite would destroy in exchange for a single derived key. Delete it explicitly first if you mean to replace it. + +Without a TTY the command fails with `tty_required` — there is no non-interactive path. + +## Arguments + +- `path` — path to the keystore JSON file + +## Options + +| Option | Description | +|---|---| +| `--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 TZx9kP2mQ7hV3nD8sL5cR1tY6bWqA4eJfU + 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":"TZx9kP2mQ7hV3nD8sL5cR1tY6bWqA4eJfU"}},"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 (newly minted on this machine — ids never transfer) | +| `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, derived from the key itself | + +## Errors + +| Code | Meaning | +|---|---| +| `tty_required` | No TTY — both passwords are hidden-input only | +| `keystore_not_found` | No file at the given path | +| `invalid_keystore` | Not a valid V3 keystore (bad JSON, `version` ≠ 3, unsupported cipher/kdf, or a payload that is not a 32-byte key) | +| `wrong_keystore_password` | The keystore file's own password is wrong (its MAC did not match) | +| `auth_failed` | The master password is wrong | +| `account_exists` | An account with this address already exists locally — delete it first | + +## Exit status + +`0` imported · `1` execution failure (`wrong_keystore_password`, `auth_failed`, `account_exists`) · `2` usage error (`keystore_not_found`, `invalid_keystore`, `tty_required`, duplicate label). + +## See also + +[`backup --keystore`](../backup.md) · [`import private-key`](private-key.md) · [`import mnemonic`](mnemonic.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..73b0222e4 100644 --- a/ts/docs/commands/index.md +++ b/ts/docs/commands/index.md @@ -9,6 +9,7 @@ Every command — including every subcommand — has its own page, following a f | `create` | [create.md](create.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) | @@ -68,6 +69,20 @@ Every command — including every subcommand — has its own page, following a f | `token add` | [token/add.md](token/add.md) | | `token list` | [token/list.md](token/list.md) | | `token remove` | [token/remove.md](token/remove.md) | +| `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) | | `contact` (group) | [contact/index.md](contact/index.md) | | `contact add` | [contact/add.md](contact/add.md) | | `contact list` | [contact/list.md](contact/list.md) | @@ -77,6 +92,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) | @@ -86,6 +105,16 @@ Every command — including every subcommand — has its own page, following a f | 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) | | `stake` (group) | [stake/index.md](stake/index.md) | | `stake freeze` | [stake/freeze.md](stake/freeze.md) | | `stake unfreeze` | [stake/unfreeze.md](stake/unfreeze.md) | diff --git a/ts/docs/commands/permission/update.md b/ts/docs/commands/permission/update.md index 7a916905c..2e0a09041 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 | diff --git a/ts/docs/commands/proposal/approve.md b/ts/docs/commands/proposal/approve.md new file mode 100644 index 000000000..0af54cb43 --- /dev/null +++ b/ts/docs/commands/proposal/approve.md @@ -0,0 +1,42 @@ +# wallet-cli proposal approve + +Add or remove the selected witness's approval. + +## Synopsis + +``` +wallet-cli proposal approve [--cancel] + [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +TRON proposals have approval and un-approval, not an against vote. The default maps to Java `is_add_approval=true`; `--cancel` maps to `false`. Any registered witness may submit the transaction, but only active SR approvals count when the chain settles the proposal. + +## Options + +| Option | Description | +|---|---| +| `` | Positive proposal id | +| `--cancel` | Remove this witness's existing approval | +| `--dry-run`, `--sign-only`, `--build-only` | Mutually exclusive transaction modes | +| `--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` | + +## Example + +```bash +echo "$PW" | wallet-cli proposal approve 47 --cancel --network tron:nile --wait --password-stdin +``` + +## Output + +The receipt returns `addApproval`, the projected approval count, threshold, witness address, and transaction/resource fields. + +## Exit status + +`0` built/signed/submitted · `1` `not_a_witness`, `proposal_not_found`, `proposal_expired`, `already_approved`, `not_approved`, signer/auth, or chain failure · `2` invalid input. + +## See also + +[`proposal show`](show.md) · [`proposal delete`](delete.md) diff --git a/ts/docs/commands/proposal/create.md b/ts/docs/commands/proposal/create.md new file mode 100644 index 000000000..70bb4908b --- /dev/null +++ b/ts/docs/commands/proposal/create.md @@ -0,0 +1,45 @@ +# wallet-cli proposal create + +Create a proposal containing one or more chain-parameter changes. + +## Synopsis + +``` +wallet-cli proposal create --set = [--set ...] + [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +Only a registered witness can create a proposal. Parameter names match [`chain params`](../chain/params.md); numeric protocol ids are also accepted. Unknown parameters, non-integers, invalid boolean values, and known out-of-range values fail locally. Duplicate ids use the final assignment and the transaction is ordered by id. + +## Options + +| Option | Description | +|---|---| +| `--set =` | Required, repeatable parameter assignment | +| `--dry-run` | Build and estimate without signing | +| `--sign-only` | Sign without broadcasting | +| `--build-only` | Return the unsigned transaction without accessing a signer | +| `--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` | + +Plus `--account`, `--password-stdin`, and the [global options](../index.md#global-options-every-command). + +## Example + +```bash +echo "$PW" | wallet-cli proposal create --set getCreateAccountFee=200000 --set getTransactionFee=15 --network tron:nile --wait --password-stdin +``` + +## Output + +The receipt contains `kind: "proposal-create"`, proposer, sorted `changes[]`, transaction stage/id, and confirmed resource usage. The proposal id is resolved after confirmation when available. + +## Exit status + +`0` built/signed/submitted · `1` `not_a_witness`, signer/auth, RPC, or chain rejection · `2` invalid parameter or mode. + +## See also + +[`proposal approve`](approve.md) · [`chain params`](../chain/params.md) diff --git a/ts/docs/commands/proposal/delete.md b/ts/docs/commands/proposal/delete.md new file mode 100644 index 000000000..8aeffd1d3 --- /dev/null +++ b/ts/docs/commands/proposal/delete.md @@ -0,0 +1,35 @@ +# wallet-cli proposal delete + +Cancel a proposal created by the selected account during its voting window. + +## Synopsis + +``` +wallet-cli proposal delete [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +The account must be both a registered witness and the proposal's `proposer_address`. A successful delete produces the chain state `CANCELED`; it is distinct from `proposal approve --cancel`, which removes only one approval. + +## Options + +`` is required. Transaction controls are `--dry-run`, `--sign-only`, `--build-only`, `--expiration` (build/sign-only, max 24 h), `--permission-id`, `--account`, `--wait`, and `--password-stdin`. + +## Example + +```bash +echo "$PW" | wallet-cli proposal delete 48 --network tron:nile --wait --password-stdin +``` + +## Output + +Returns `kind: "proposal-delete"`, proposal/proposer identity, transaction stage/id, and confirmed resource usage. + +## Exit status + +`0` built/signed/submitted · `1` `proposal_not_found`, `not_proposal_owner`, `proposal_expired`, `already_canceled`, signer/auth, RPC, or chain failure · `2` invalid input. + +## See also + +[`proposal approve`](approve.md) · [`proposal show`](show.md) diff --git a/ts/docs/commands/proposal/index.md b/ts/docs/commands/proposal/index.md new file mode 100644 index 000000000..60c30d482 --- /dev/null +++ b/ts/docs/commands/proposal/index.md @@ -0,0 +1,23 @@ +# wallet-cli proposal + +Query and operate TRON chain-parameter proposals. Read commands are public; create, approve, and delete require a registered witness account. + +## Synopsis + +``` +wallet-cli proposal COMMAND +``` + +## Subcommands + +| Command | Page | Description | +|---|---|---| +| `proposal list` | [list.md](list.md) | List active or historical proposals | +| `proposal show` | [show.md](show.md) | Show one proposal and its approval progress | +| `proposal create` | [create.md](create.md) | Propose one or more chain-parameter changes | +| `proposal approve` | [approve.md](approve.md) | Add or remove this witness's approval | +| `proposal delete` | [delete.md](delete.md) | Cancel a proposal created by this account | + +## See also + +[`chain params`](../chain/params.md) · [`witness`](../witness/index.md) · [`vote`](../vote/index.md) diff --git a/ts/docs/commands/proposal/list.md b/ts/docs/commands/proposal/list.md new file mode 100644 index 000000000..dd1547eaa --- /dev/null +++ b/ts/docs/commands/proposal/list.md @@ -0,0 +1,41 @@ +# wallet-cli proposal list + +List chain-parameter proposals, newest first. + +## Synopsis + +``` +wallet-cli proposal list [--state active|all] [--offset ] [--limit ] [options] +``` + +## Description + +`active` selects `PENDING` proposals whose voting window has not expired. `all` includes approved, disapproved, and canceled history. Filtering happens before local pagination. Each proposal's parameter map is sorted by protocol parameter id; JSON pagination is emitted as `meta.pagination`. + +## Options + +| Option | Description | +|---|---| +| `--state ` | State filter; default `active` | +| `--offset ` | Zero-based offset; default 0 | +| `--limit ` | Positive page size; omitted means all remaining rows | + +Plus the [global options](../index.md#global-options-every-command). + +## Example + +```bash +wallet-cli proposal list --state all --offset 20 --limit 20 --network tron:nile -o json +``` + +## Output + +`data.approvalThreshold` is 18 for the normal 27-member active SR set. `data.proposals[]` contains `id`, `proposerAddress`, normalized `state`, approval count, expiry, and sorted `changes[]`. `meta.pagination` contains `offset`, `limit`, and the filtered total. + +## Exit status + +`0` success · `1` RPC failure · `2` invalid state or pagination value. + +## See also + +[`proposal show`](show.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..b5d1e8150 --- /dev/null +++ b/ts/docs/commands/proposal/show.md @@ -0,0 +1,37 @@ +# wallet-cli proposal show + +Show one proposal, its parameter changes, and approval progress. + +## Synopsis + +``` +wallet-cli proposal show [options] +``` + +## Description + +The state is normalized to `voting`, `approved`, `disapproved`, or `canceled`. A pending proposal remains `voting` until expiry even after reaching the threshold. JSON includes the full `approvedBy[]` address list; text output keeps only the count. + +## Arguments + +| Argument | Description | +|---|---| +| `id` | Positive proposal id | + +## Example + +```bash +wallet-cli proposal show 47 --network tron:nile +``` + +## Output + +Returns the proposer, create/expiry timestamps, threshold status, approving addresses, and parameter changes sorted by id. + +## Exit status + +`0` success · `1` `proposal_not_found` or RPC failure · `2` invalid id. + +## See also + +[`proposal list`](list.md) · [`proposal approve`](approve.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/send.md b/ts/docs/commands/tx/send.md index 7087e1697..c7338ae41 100644 --- a/ts/docs/commands/tx/send.md +++ b/ts/docs/commands/tx/send.md @@ -42,8 +42,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/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/witness/create.md b/ts/docs/commands/witness/create.md new file mode 100644 index 000000000..026dffa58 --- /dev/null +++ b/ts/docs/commands/witness/create.md @@ -0,0 +1,42 @@ +# wallet-cli witness create + +Register an activated account as an SR candidate. + +## Synopsis + +``` +wallet-cli witness create --url [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +Registration burns the current `getAccountUpgradeCost` chain parameter and cannot be undone. The command reads that value from the selected network, verifies account activation and exact SUN balance before building, and reports the burn as both `feeSun` and `registrationFeeSun`. + +## Options + +| Option | Description | +|---|---| +| `--url ` | Required candidate information URL, at most 256 UTF-8 bytes | +| `--dry-run`, `--sign-only`, `--build-only` | Mutually exclusive transaction modes | +| `--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` | + +Plus `--account`, `--wait`, `--password-stdin`, and the [global options](../index.md#global-options-every-command). + +## Example + +```bash +echo "$PW" | wallet-cli witness create --url https://sr.example --network tron:nile --wait --password-stdin +``` + +## Output + +Returns the witness address, URL, irreversible registration fee, transaction stage/id, and confirmed bandwidth/resource usage. + +## Exit status + +`0` built/signed/submitted · `1` `already_witness`, `account_not_active`, `insufficient_balance`, missing chain fee, signer/auth, RPC, or chain failure · `2` invalid input. + +## See also + +[`witness update`](update.md) · [`chain params`](../chain/params.md) diff --git a/ts/docs/commands/witness/index.md b/ts/docs/commands/witness/index.md new file mode 100644 index 000000000..5e90d863b --- /dev/null +++ b/ts/docs/commands/witness/index.md @@ -0,0 +1,21 @@ +# wallet-cli witness + +Register and operate a TRON super representative candidacy. + +## 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) | Update the candidate information URL | +| `witness set-brokerage` | [set-brokerage.md](set-brokerage.md) | Set the SR-retained reward percentage | + +## See also + +[`proposal`](../proposal/index.md) · [`vote`](../vote/index.md) · [`reward`](../reward/index.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..7c6b087b6 --- /dev/null +++ b/ts/docs/commands/witness/set-brokerage.md @@ -0,0 +1,39 @@ +# wallet-cli witness set-brokerage + +Set the percentage of rewards retained by the SR. + +## Synopsis + +``` +wallet-cli witness set-brokerage [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +`percent` is the SR-retained brokerage, exactly matching Java wallet-cli and `UpdateBrokerageContract`: 20 means the SR keeps 20% and voters share 80%. The value is never reversed by the client. The selected account must be a registered witness. + +## Arguments + +| Argument | Description | +|---|---| +| `percent` | Integer 0–100 retained by the SR | + +Transaction controls are `--dry-run`, `--sign-only`, `--build-only`, `--expiration` (build/sign-only, max 24 h), `--permission-id`, `--account`, `--wait`, and `--password-stdin`. + +## Example + +```bash +echo "$PW" | wallet-cli witness set-brokerage 20 --network tron:nile --wait --password-stdin +``` + +## Output + +Returns witness address, the unchanged brokerage value, transaction stage/id, and confirmed resource usage. + +## Exit status + +`0` built/signed/submitted · `1` `not_a_witness`, signer/auth, RPC, or chain failure · `2` percentage or mode error. + +## See also + +[`vote list`](../vote/list.md) · [`reward`](../reward/index.md) diff --git a/ts/docs/commands/witness/update.md b/ts/docs/commands/witness/update.md new file mode 100644 index 000000000..33532e614 --- /dev/null +++ b/ts/docs/commands/witness/update.md @@ -0,0 +1,35 @@ +# wallet-cli witness update + +Update an SR candidate's information URL. + +## Synopsis + +``` +wallet-cli witness update --url [--dry-run | --sign-only | --build-only] [options] +``` + +## Description + +The selected account must already be a registered witness. This operation has no registration burn and can be repeated. + +## Options + +`--url` is required and limited to 256 UTF-8 bytes. Transaction controls are `--dry-run`, `--sign-only`, `--build-only`, `--expiration` (build/sign-only, max 24 h), `--permission-id`, `--account`, `--wait`, and `--password-stdin`. + +## Example + +```bash +echo "$PW" | wallet-cli witness update --url https://sr.example/v2 --network tron:nile --wait --password-stdin +``` + +## Output + +Returns `kind: "witness-update"`, witness address, URL, transaction stage/id, and confirmed resource usage. + +## Exit status + +`0` built/signed/submitted · `1` `not_a_witness`, signer/auth, RPC, or chain failure · `2` invalid input. + +## See also + +[`witness create`](create.md) · [`witness set-brokerage`](set-brokerage.md) diff --git a/ts/docs/java-parity-v4.12-governance.md b/ts/docs/java-parity-v4.12-governance.md new file mode 100644 index 000000000..d7ffd0dee --- /dev/null +++ b/ts/docs/java-parity-v4.12-governance.md @@ -0,0 +1,37 @@ +# v4.12 治理功能 Java / TypeScript 一致性核对 + +结论:本次 12 条 TS 命令与 Java wallet-cli 使用相同的 TRON protocol contract、字段方向和 int64 编码;TS 仅在命令形态、前置校验和输出结构上做了需求文档指定的增强。 + +## 命令与协议映射 + +| TS 命令 | Java 命令 / 方法 | Protocol contract / 算法 | 一致性要点 | +|---|---|---|---| +| `proposal list` | `ListProposals`, `ListProposalsPaginated` | `Proposal` | 相同七个 proto 字段;TS 合并分页并增加本地状态筛选 | +| `proposal show` | `GetProposal` | `Proposal` | `PENDING/DISAPPROVED/APPROVED/CANCELED` 逐项映射 | +| `proposal create` | `createProposal` | `ProposalCreateContract` | `owner_address` 与 `map parameters` 相同;TS 支持参数名并精确编码完整 Java `long` 范围 | +| `proposal approve` | `approveProposal` | `ProposalApproveContract` | 默认 `is_add_approval=true`;`--cancel` 为 `false`,没有“反对票” | +| `proposal delete` | `deleteProposal` | `ProposalDeleteContract` | 相同 `proposal_id`;只允许发起人在窗口内撤销 | +| `witness create` | `CreateWitness` | `WitnessCreateContract` | 业务字段只有 `url`;费用读取 `getAccountUpgradeCost` | +| `witness update` | `updateWitness` | `WitnessUpdateContract` | `update_url` 内容与 Java URL 输入一致 | +| `witness set-brokerage` | `updateBrokerage` | `UpdateBrokerageContract` | 0–100 原值透传,含义均为 SR 留存比例 | +| `contract clear-abi` | `clearContractABI` | `ClearABIContract` | `owner_address` / `contract_address` 相同 | +| `contract set-origin-energy-limit` | `updateEnergyLimit` | `UpdateEnergyLimitContract` | 正整数原值透传;绕开 TronWeb 6.4.0 过时的 10,000,000 本地上限 | +| `contract set-user-resource-percent` | `updateSetting` | `UpdateSettingContract` | 0=部署者承担,100=调用者承担;不反转 | +| `contract create2` | `create2` | 本地 Keccak/Base58Check | deployer 21 字节、salt 低 8 字节、无 `0xff`,逐字节一致 | + +## TS 的安全增强 + +- 写操作在构建前校验 witness、提案状态/所有权、合约 `origin_address`、账户激活状态和注册费余额;Java 多数情况交给节点拒绝。 +- `proposal create` 对参数名、布尔值和已知范围做本地校验。int64 值不经过 JS 浮点数:专用 protobuf 编码器生成 `ProposalCreateContract`,签名前再从 JSON 精确重编码并与 `raw_data_hex` 比对。 +- `set-origin-energy-limit` 按链上规则拒绝 0;Java 旧入口只检查 `< 0`,会让 0 进入节点后再失败。 +- 三类写操作统一支持 `--dry-run`、`--sign-only`、`--build-only`、`--expiration`、`--permission-id` 和 `--wait`。`--build-only` 不解析私钥或硬件 signer,可直接交给后续多签流程。 +- 所有构建结果限制为单一预期 contract type;软件签名与 Ledger 签名前同时校验 `txID = sha256(raw_data_hex)`、protobuf contract type 和 raw-data 重编码一致性。 + +## 核对源 + +- Java 命令层:`../java/src/main/java/org/tron/walletcli/cli/commands/ProposalCommands.java`、`WitnessCommands.java`、`ContractCommands.java` +- Java 旧入口与参数校验:`../java/src/main/java/org/tron/walletcli/Client.java` +- Java protocol 构建:`../java/src/main/java/org/tron/walletserver/WalletApi.java` +- TS 命令层:`src/adapters/inbound/cli/commands/proposal.ts`、`witness.ts`、`contract.ts` +- TS 用例层:`src/application/use-cases/tron/proposal-service.ts`、`witness-service.ts`、`contract-service.ts` +- TS protobuf / RPC:`src/adapters/outbound/chain/tron/proposal-protobuf.ts`、`tron.ts`、`tx-integrity.ts` diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index c7cc8e36d..a0a81ad7e 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -63,6 +63,7 @@ Schema id: `wallet-cli.result.v1`. | `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` | `{offset, limit, total}` | paginated reads only | The window this response returned; `limit`/`total` are nullable — see below | | `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,38 @@ 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. +### Reading `meta.pagination` + +Every paginated read reports its window in **one place — `meta.pagination`** — never inside `data`. That is deliberate: the cursor lives at a fixed path regardless of the payload's shape, so a single pager works for `asset list`, `exchange list`, `backup --records`, `proposal show`, and any list command added later. Its absence means the command is not paginated. + +```json +"meta": { "durationMs": 8, "warnings": [], "pagination": { "offset": 0, "limit": 10, "total": null } } +``` + +| Field | Type | Meaning | +|---|---|---| +| `offset` | number | Index this page started at — echoes `--offset` | +| `limit` | number \| **null** | Page size; `null` = unlimited (no `--limit` given) | +| `total` | number \| **null** | Matching records in total; `null` = **no count exists**, not "we omitted it" | + +All three keys are always present, so `null` is the only "unknown" signal and you never have to distinguish absent from null. + +`total: null` is permanent for the commands backed by TRON's paginated node endpoints (`asset list`, `exchange list`): the endpoint returns no count, and computing one would mean transferring every record — 5,187 assets / 2.7 MB on mainnet. **Page until you get a short page** rather than comparing against a total: + +```bash +# works whether or not a total is knowable +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 +``` + +In **text** mode the same window titles the table (`Assets (limit 50, offset 0)`, `Backup records (showing 3 of 12)`); text output 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,9 +133,12 @@ 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 | +| `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, 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` | Unknown governance parameter name or id | Common codes at exit **1** (execution — runtime failure): @@ -112,8 +148,14 @@ Common codes at exit **1** (execution — runtime failure): | `timeout` | Aborted waiting for network or device (`--timeout` exceeded) | | `auth_required` | Master password required but not supplied | | `auth_failed` | Wrong master password (decryption failed) | +| `wrong_keystore_password` | `import keystore`: the keystore file's own password is wrong (its MAC did not match). Distinct from `auth_failed`, which is the master password | +| `not_exportable` | The account holds no exportable secret (watch-only / Ledger) — `backup` | +| `account_exists` | `import keystore`: an account with this address already exists locally; delete it first (wallet-cli never overwrites it) | | `signing_rejected` / `transaction_rejected` | Signing or broadcast rejected (device or chain) | | `watch_only_no_signer` | The account is watch-only and cannot sign | +| `proposal_not_found` / `proposal_expired` | Proposal lookup or voting-window failure | +| `not_a_witness` / `not_proposal_owner` | Governance identity does not meet the operation's rule | +| `contract_not_found` / `not_contract_deployer` | Contract lookup or deployer authorization failure | | `wrong_device_seed` | Connected Ledger does not match the registered account | | `tx_integrity` / `invalid_transaction` | A presigned transaction failed integrity / validity checks | | `insufficient_balance` / `insufficient_token_balance` | Not enough TRX / token to cover the amount plus fees | 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/src/adapters/inbound/cli/arity/index.ts b/ts/src/adapters/inbound/cli/arity/index.ts index fbb8da372..d7a62918f 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; } 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..acc729a9f --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/asset.ts @@ -0,0 +1,190 @@ +/** + * `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 { 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: z.string().min(1).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/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index 153e7d63a..9adf6f833 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[] { @@ -70,7 +71,7 @@ const sendFields = z.object({ .describe("native TRX attached to the call, in SUN"), feeLimit: Schemas.positiveIntString().default("100000000") .describe("maximum energy fee to burn, in SUN"), - ...txModeFields, + ...governanceTxModeFields, }); export const contractSendSpec: ChainSpec = { @@ -80,6 +81,7 @@ export const contractSendSpec: ChainSpec = { capability: "contract.call", summary: "State-changing call (triggerSmartContract)", baseFields: sendFields, + baseRefine: governanceTxRefine, examples: [{ cmd: `wallet-cli contract send --contract TR7... --method "transfer(address,uint256)" --params '[...]'`, }], @@ -99,7 +101,7 @@ const deployFields = z.object({ 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, + ...governanceTxModeFields, }); export const contractDeploySpec: ChainSpec = { @@ -112,6 +114,7 @@ export const contractDeploySpec: ChainSpec = { // blind-signing enabled; software accounts sign and deploy it fine. requires: ["a software (non-Ledger) account — the Ledger TRON app cannot sign this transaction type"], baseFields: deployFields, + baseRefine: governanceTxRefine, examples: [{ cmd: "wallet-cli contract deploy --abi '[...]' --bytecode 60... --fee-limit 1000000000 --params '[100, \"T...\"]'", }], @@ -151,3 +154,124 @@ 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/exchange.ts b/ts/src/adapters/inbound/cli/commands/exchange.ts new file mode 100644 index 000000000..942cfdbfe --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/exchange.ts @@ -0,0 +1,199 @@ +/** + * `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/proposal.ts b/ts/src/adapters/inbound/cli/commands/proposal.ts new file mode 100644 index 000000000..eec432218 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/proposal.ts @@ -0,0 +1,122 @@ +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 and their chain-parameter changes. 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 parameter changes, approval progress, proposer, and voting-window timestamps.", + 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/shared.ts b/ts/src/adapters/inbound/cli/commands/shared.ts index 368d8c19d..dda526d04 100644 --- a/ts/src/adapters/inbound/cli/commands/shared.ts +++ b/ts/src/adapters/inbound/cli/commands/shared.ts @@ -25,6 +25,29 @@ export const txModeFields = { 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"), + expiration: z.coerce.number().int().positive().max(86_400_000).optional() + .describe("extend transaction expiration in milliseconds (max 86400000); only with --sign-only or --build-only"), + permissionId: z.coerce.number().int().min(0).max(2_147_483_647).default(0) + .describe("TRON permission group used by the transaction (0 = owner)"), +}; + +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 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..41f499d5d 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.backup.test.ts @@ -51,12 +51,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 +70,7 @@ function fixture(opts: { tty: boolean }) { formatter, session, }; - return { shellOpts, keystore, secrets, spyPrime }; + return { shellOpts, keystore, secrets, spyPrime, walletService }; } describe("backup password gating", () => { @@ -99,3 +100,40 @@ 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.keystore.test.ts b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts new file mode 100644 index 000000000..44b5d2067 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/wallet.keystore.test.ts @@ -0,0 +1,276 @@ +/** + * 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..be5fcac6c 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.test.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.test.ts @@ -87,9 +87,12 @@ function buildGlobals(): Globals { function buildServices(ks: Keystore) { const ledger = {} as any; return { - walletService: new WalletService(ks, ledger, { - write: () => ({ out: "unused", fileMode: "0600", bytes: 0 }), - }), + walletService: new WalletService( + ks, + ledger, + { write: () => ({ out: "unused", fileMode: "0600", bytes: 0 }) }, + { append: () => {}, list: () => [] }, + ), ledger, tokenBook: {} as any, priceProvider: {} as any, diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index 7c1667e6a..f04d2e0a8 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -2,11 +2,12 @@ * Wallet root commands — create/import/list/current/use/backup. Not chain-bound; no --network. * Calls WalletService rather than the transaction pipeline. */ +import { existsSync } from "node:fs" import { z } from "zod" import type { CommandDefinition } from "../contracts/index.js" import { Schemas } from "../schemas/index.js" import { CommandRegistry } from "../registry/index.js" -import { accountRef, ciEnum } from "../arity/index.js" +import { accountRef, camelToKebab, ciEnum } from "../arity/index.js" import type { LedgerDevice } from "../../../../application/ports/ledger-device.js" import type { QrEncoder } from "../../../../application/ports/qr-encoder.js" import type { WalletService } from "../../../../application/use-cases/wallet-service.js" @@ -14,6 +15,7 @@ import { resolveLedgerPath, selectLedgerPath } from "../../../../application/ser import { ChainFamily, CHAIN_FAMILIES, FAMILIES } from "../../../../domain/family/index.js" import { UsageError } from "../../../../domain/errors/index.js" import { passwordPolicyErrors } from "../input/prompt/validators.js" +import { readBoundedTextFile } from "./artifact.js" import { TextFormatters } from "../render/index.js" // ── wallet import-ledger contract (module scope so it can be unit-tested) ─────── @@ -41,6 +43,52 @@ export const walletImportLedgerInput = walletImportLedgerFields.superRefine((v, if (locators > 1) c.addIssue({ code: "custom", path: ["index"], message: "--index, --path and --address are mutually exclusive" }) }) +// ── import keystore file reading ─────────────────────────────────────────────── +// A V3 keystore is a small JSON document; the cap only exists to refuse a file that plainly is not +// one before it is read into memory. +const KEYSTORE_MAX_BYTES = 64 * 1024 + +/** the parsed JSON of a keystore file. Distinguishes "no such file" from "not a keystore" so the + * caller learns which of the two mistakes they made. */ +function readKeystoreFile(path: string): unknown { + if (!existsSync(path)) throw new UsageError("keystore_not_found", `no keystore file at ${path}`) + const raw = readBoundedTextFile(path, KEYSTORE_MAX_BYTES, "keystore file") + try { + return JSON.parse(raw) as unknown + } catch { + throw new UsageError("invalid_keystore", `${path} is not valid JSON`) + } +} + +// ── backup --records time bounds ─────────────────────────────────────────────── +// `YYYY-MM-DD` or `YYYY-MM-DD HH:mm:ss`, always read as UTC — the log is written in UTC, and a +// local-time reading would silently shift a boundary by the machine's offset. A bare date means that +// day's 00:00:00 (both bounds are inclusive instants, not day ranges). +const UTC_DATETIME = /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}):(\d{2}))?$/ + +/** the ISO-8601 instant a bound denotes, or undefined when the bound was not given. */ +function utcInstant(value: string | undefined): string | undefined { + if (value === undefined) return undefined + const [, y, mo, d, h = "00", mi = "00", s = "00"] = UTC_DATETIME.exec(value)! + return `${y}-${mo}-${d}T${h}:${mi}:${s}Z` +} + +function utcDateTime(describe: string) { + return z + .string() + .refine((v) => { + const m = UTC_DATETIME.exec(v) + if (!m) return false + // Reject impossible calendar values (2026-02-31, 25:00:00): Date normalises them silently, so + // compare the round-trip instead of trusting the parse. + const iso = utcInstant(v)! + const parsed = new Date(iso) + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().replace(/\.\d{3}Z$/, "Z") === iso + }, "expected YYYY-MM-DD or 'YYYY-MM-DD HH:mm:ss' (UTC)") + .optional() + .describe(`${describe}; format YYYY-MM-DD or 'YYYY-MM-DD HH:mm:ss', parsed as UTC`) +} + export function registerWalletCommands( reg: CommandRegistry, services: { @@ -133,6 +181,49 @@ export function registerWalletCommands( }, } satisfies CommandDefinition) + // ── import keystore ─────────────────────────────────────────────────────── + // Two independent passwords, both hidden-TTY-only: the FILE's own password (to decrypt it) and our + // master password (to re-encrypt it locally). The file is read and structurally validated FIRST, so + // a typo'd path costs no password prompts — hence no `passwordMode`; priming happens inside `run` + // (same reasoning as `backup`). + const importKeystoreFields = z.object({ + path: z.string().min(1).describe("path to the keystore JSON file"), + label: Schemas.label().optional().describe("human-friendly unique account label, 1-64 chars; omit to auto-generate"), + }) + reg.add({ + path: ["import", "keystore"], + network: "none", + wallet: "none", + auth: "required", + interactive: true, + secretsTtyOnly: true, + positionals: [{ field: "path" }], + promptHints: { label: "default-label" }, + requires: ["the keystore file's own password — entered interactively in a TTY"], + summary: "Import an account from a standard Web3 keystore JSON", + description: + "Import a single account from a standard Web3 keystore JSON (as exported by TronLink or\n" + + "'backup --keystore'), stored encrypted under your master password and made active. It carries\n" + + "one private key, so nothing can be derived from it; a same-address account is refused with\n" + + "account_exists (delete it first).\n\n" + + "Interactive-only: the master password and the keystore's own password are entered only via\n" + + "hidden TTY prompts, never stdin/argv — without a TTY it fails with tty_required.", + fields: importKeystoreFields, + input: importKeystoreFields, + examples: [ + { cmd: "wallet-cli import keystore ./tronlink-export.json" }, + { cmd: "wallet-cli import keystore ./tronlink-export.json --label imported" }, + ], + formatText: TextFormatters.walletCreated("Imported", ["The keystore password was read from hidden input and was not printed."]), + run: async (ctx, _net, input) => { + const file = readKeystoreFile(input.path) + const mode = wallets.isInitialized() ? "verify" : "set" + await ctx.secrets.primePassword({ mode, verify: (pw) => wallets.verifyPassword(pw) }) + const keystorePassword = await ctx.prompt.hidden({ label: "Keystore file password (hidden)" }) + return wallets.importKeystore(file, keystorePassword, input.label) + }, + } satisfies CommandDefinition) + // ── import ledger ───────────────────────────────────────────────────────── reg.add({ path: ["import", "ledger"], @@ -341,11 +432,48 @@ export function registerWalletCommands( // answer satisfies instead of telling them the account simply cannot be exported. // --password-stdin remains the non-interactive source. const backupFields = z.object({ - account: accountRef("account or wallet to export, addressed by accountId, label, or address"), + account: accountRef( + "account or wallet to export, addressed by accountId, label, or address; with --records, the account whose exports to list", + { optional: true }, + ), + keystore: z.boolean().default(false) + .describe("export as a standard Web3 keystore JSON (importable by TronLink and others, encrypted with your master password) instead of the native format"), out: z .string() .optional() - .describe("output file path; omit to write /backups/-.json; file is created with mode 0600 and never overwritten"), + .describe("output file path; omit to write ./-.json in the current directory (.keystore.json with --keystore); file is created with mode 0600 and never overwritten"), + records: z.boolean().default(false) + .describe("list past secret exports instead of exporting anything"), + from: utcDateTime("with --records: only records at or after this UTC time"), + to: utcDateTime("with --records: only records at or before this UTC time"), + limit: z.coerce.number().int().positive().optional() + .describe("with --records: maximum records to return; omit for all"), + // Optional, not .default(0): a default makes "not given" indistinguishable from "given as 0" + // in the refine below, which is how --offset alone slipped past the --records guard. The 0 + // lives in backupRecords (query.offset ?? 0), so the emitted pagination is unchanged. + offset: z.coerce.number().int().min(0).optional() + .describe("with --records: pagination offset"), + }) + const RECORD_FILTERS = ["from", "to", "limit", "offset"] as const + const backupInput = backupFields.superRefine((v, c) => { + if (v.records) { + // --keystore/--out describe an export; --records exports nothing, so accepting them would + // silently ignore what the caller asked for. + for (const flag of ["keystore", "out"] as const) { + if (v[flag] !== undefined && v[flag] !== false) { + c.addIssue({ code: "custom", path: [flag], message: `--${camelToKebab(flag)} exports a file; it cannot be combined with --records` }) + } + } + return + } + if (v.account === undefined) { + c.addIssue({ code: "custom", path: ["account"], message: "an account is required unless --records is given" }) + } + for (const flag of RECORD_FILTERS) { + if (v[flag] !== undefined) { + c.addIssue({ code: "custom", path: [flag], message: `--${flag} filters the export log; it needs --records` }) + } + } }) reg.add({ path: ["backup"], @@ -354,15 +482,49 @@ export function registerWalletCommands( auth: "required", interactive: true, positionals: [{ field: "account" }], - summary: "Export an account's secret + metadata to a 0600 file", + summary: "Export an account's secret (native or --keystore); audit exports with --records", + description: + "Export an account's secret to a 0600 file — the native backup format, or a standard Web3\n" + + "keystore JSON with --keystore (importable by TronLink and others, encrypted with your master\n" + + "password). A keystore holds a single private key, so an HD account exports only its current\n" + + "derived key; use the native backup to move a whole seed.\n\n" + + "The secret is written only to the file, never to stdout; watch-only and Ledger accounts have\n" + + "none to export. Files default to the CURRENT DIRECTORY — do not run this in a shared directory\n" + + "or a git repository.\n\n" + + "With --records and no account, nothing is exported: it shows the local audit log of past\n" + + "exports instead — one row per 'backup' and 'backup --keystore', newest first, with the file\n" + + "each secret went to. Imports are not logged. The log keeps the most recent 1000 entries.", fields: backupFields, - input: backupFields, - examples: [{ cmd: "wallet-cli backup main --out ~/main-backup.json --password-stdin" }], + input: backupInput, + // Log filters are never interrogated — a listing is meant to be re-run with a narrower flag, not + // negotiated one prompt at a time. + promptHints: { from: "skip", to: "skip", limit: "skip", offset: "skip" }, + // --records audits: nothing is exported, so there is no account to pick and no file to name. + skipGapFill: (argv) => (argv.records ? ["account", "out"] : []), + commandIdFor: (input) => (input.records ? "backup.records" : "backup"), + examples: [ + { cmd: "wallet-cli backup main --out ~/main-backup.json --password-stdin" }, + { cmd: "wallet-cli backup main --keystore --password-stdin" }, + { cmd: "wallet-cli backup --records --limit 20" }, + { cmd: "wallet-cli backup --records --account main --from 2026-08-01" }, + ], formatText: TextFormatters.walletBackup, run: async (ctx, _net, input) => { - wallets.assertExportable(input.account) + if (input.records) { + return wallets.backupRecords({ + from: utcInstant(input.from), + to: utcInstant(input.to), + limit: input.limit, + offset: input.offset, + account: input.account, + }) + } + const account = input.account! // guaranteed by backupInput's refine + wallets.assertExportable(account) await ctx.secrets.primePassword({ mode: "verify", verify: (pw) => wallets.verifyPassword(pw) }) - return wallets.backup(input.account, input.out) + return input.keystore + ? wallets.backupKeystore(account, input.out, ctx.secrets.read("password")) + : wallets.backup(account, input.out) }, } satisfies CommandDefinition) diff --git a/ts/src/adapters/inbound/cli/commands/witness.ts b/ts/src/adapters/inbound/cli/commands/witness.ts new file mode 100644 index 000000000..0cbe178da --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/witness.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { TronWitnessService } from "../../../../application/use-cases/tron/witness-service.js"; +import { governanceTxModeFields, governanceTxRefine } from "./shared.js"; +import { TextFormatters } from "../render/index.js"; + +const witnessUrl = z.string().trim().min(1) + .refine((value) => Buffer.byteLength(value, "utf8") <= 256, "must not exceed 256 UTF-8 bytes") + .describe("candidate information-page URL (max 256 UTF-8 bytes)"); + +const witnessWriteBase = { + network: "optional" as const, + wallet: "optional" as const, + auth: "required" as const, + broadcasts: true, + capability: "witness.manage", + baseRefine: governanceTxRefine, + formatText: TextFormatters.governanceReceipt, +}; + +export const witnessCreateSpec: ChainSpec = { + path: ["witness", "create"], + ...witnessWriteBase, + summary: "Register as a super representative candidate", + description: + "Register the account as an SR candidate. The chain burns getAccountUpgradeCost\n" + + "from the account balance; the fee is irreversible and registration cannot be undone.", + requires: ["an activated account funded for the on-chain registration burn"], + baseFields: z.object({ url: witnessUrl, ...governanceTxModeFields }), + examples: [{ cmd: "wallet-cli witness create --url https://sr.example --wait" }], +}; + +export const witnessCreateTronBinding = (service: TronWitnessService): FamilyBinding => ({ + run: async (ctx, net, input) => service.create(ctx, net, input), +}); + +export const witnessUpdateSpec: ChainSpec = { + path: ["witness", "update"], + ...witnessWriteBase, + summary: "Update an SR candidate URL", + requires: ["a registered witness account"], + baseFields: z.object({ url: witnessUrl, ...governanceTxModeFields }), + examples: [{ cmd: "wallet-cli witness update --url https://sr.example/v2 --wait" }], +}; + +export const witnessUpdateTronBinding = (service: TronWitnessService): FamilyBinding => ({ + run: async (ctx, net, input) => service.update(ctx, net, input), +}); + +export const witnessSetBrokerageSpec: ChainSpec = { + path: ["witness", "set-brokerage"], + ...witnessWriteBase, + positionals: [{ field: "percent" }], + summary: "Set the SR reward brokerage percentage", + description: + "Set the percentage of block rewards retained by the SR. The remaining percentage is\n" + + "distributed to voters; the value is not reversed from Java wallet-cli brokerage.", + requires: ["a registered witness account"], + baseFields: z.object({ + percent: z.coerce.number().int().min(0).max(100).describe("percentage retained by the SR (0-100)"), + ...governanceTxModeFields, + }), + examples: [{ cmd: "wallet-cli witness set-brokerage 20 --wait" }], +}; + +export const witnessSetBrokerageTronBinding = (service: TronWitnessService): FamilyBinding => ({ + run: async (ctx, net, input) => service.setBrokerage(ctx, net, input), +}); diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index f6078bc29..a3c247eb6 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -70,6 +70,10 @@ interface CommandDefinitionBase { secretsTtyOnly?: boolean; /** gap-fill prompt hints, by field name: "skip" = never prompt this optional field; "default-label" = offer a generated default. */ promptHints?: Record; + /** fields that must not be gap-filled for THIS invocation, from raw argv. Use when a mode flag + * makes a field meaningless (`backup --records` exports nothing, so no account is asked for). + * Unlike `promptHints`, this is per-invocation rather than static. */ + skipGapFill?: (argv: Record) => string[]; capability?: string; /** one-line command listing text (parent group's verb list). Keep it terse — a single line. */ summary?: string; @@ -89,6 +93,10 @@ interface CommandDefinitionBase { examples: Example[]; /** Optional command-specific renderer for text mode. JSON mode always uses the envelope. */ formatText?: TextFormatter; + /** Override the envelope's `command` for a mode-switching command whose modes return different + * `data` shapes (`backup` vs `backup.records`). `command` names the SEMANTIC command, not how it + * was typed, so a reader can branch on it instead of sniffing fields. Absent ⇒ the path. */ + commandIdFor?: (input: I) => string; } /** A neutral (family-less) command — wallet/config/meta operations that never receive a diff --git a/ts/src/adapters/inbound/cli/contracts/envelope.ts b/ts/src/adapters/inbound/cli/contracts/envelope.ts index b2280a14c..87ff42067 100644 --- a/ts/src/adapters/inbound/cli/contracts/envelope.ts +++ b/ts/src/adapters/inbound/cli/contracts/envelope.ts @@ -13,9 +13,21 @@ export interface ChainView { network: string; chainId: string; } +/** The window a paginated read returned. ONE location for every list command, so a caller can page + * any of them without knowing the payload's shape. `limit: null` = unlimited (no --limit given); + * `total: null` = the count is genuinely unknowable, not merely missing — TRON's paginated + * endpoints return no count, and computing one would mean transferring every record. Both keys are + * always present, so `null` is the single "unknown" signal and absence never has to be handled. */ +export interface Pagination { + offset: number; + limit: number | null; + total: number | null; +} export interface Meta { durationMs: number; warnings: WarningItem[]; + /** present on paginated reads only; lifted out of `data` by the json formatter. */ + pagination?: Pagination; } export interface ResultEnvelope { schema: "wallet-cli.result.v1"; diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index b7cd87dec..747aac371 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -101,9 +101,13 @@ export class HelpService { ["account", "Query on-chain account state, activate & name accounts", ""], ["permission", "View and update account multi-sign permissions", "tron"], ["token", "Manage the token address book and query tokens", ""], - ["tx", "Build, send, broadcast, co-sign, and inspect transactions", ""], + ["asset", "Issue and manage TRC10 tokens", "tron"], + ["exchange", "Create and trade Bancor exchange pairs", "tron"], + ["tx", "Build, send, broadcast, and inspect transactions", ""], + ["contract", "Call, deploy, govern, and inspect smart contracts", ""], ["gasfree", "Gas-free token transfers via the GasFree service", "tron"], - ["contract", "Call, send, deploy, and inspect smart contracts", ""], + ["proposal", "Create and vote on governance proposals", "tron"], + ["witness", "Register and operate an SR candidacy", "tron"], ["stake", "Stake / delegate resources & query state", "tron"], ["vote", "Vote for super representatives", "tron"], ["reward", "Query / withdraw voting rewards", "tron"], @@ -176,19 +180,22 @@ export class HelpService { #renderNeutralGroup(head: string): string { const cmds = this.#neutralGroupCommands(head) const rows = cmds.map((c) => [c.path[1] ?? "", c.summary ?? ""] as const) - return this.#renderGroup(head, rows, 1000) + return this.#renderGroup(head, rows) } /** logical resource group (`account --help`): default surface, implementations chosen by --network/defaultNetwork. */ #renderLogicalNs(group: string): string { const commands = this.#chainGroupCommands(group) const rows = commands.map((c) => [c.path[1] ?? "", c.summary ?? ""] as const) - return this.#renderGroup(group, rows, 18) + return this.#renderGroup(group, rows) } /** shared group skeleton (群组层): inline Usage → description → verb list → footer. */ - #renderGroup(group: string, rows: ReadonlyArray, maxWidth: number): string { - const width = Math.min(maxWidth, Math.max(0, ...rows.map(([verb]) => verb.length)) + 2) + #renderGroup(group: string, rows: ReadonlyArray): string { + // Width is the longest verb, uncapped: a cap cannot shorten an over-long verb, it only stops + // padEnd from reaching it — so every summary in the group loses its column the moment one verb + // exceeds the cap (`contract set-user-resource-percent`, 25 chars, did exactly that). + const width = Math.max(0, ...rows.map(([verb]) => verb.length)) + 2 const lines = [`${bold("Usage:")} wallet-cli ${group} COMMAND`, ""] const desc = GROUP_DESCRIPTIONS[group] if (desc) lines.push(desc, "") @@ -477,9 +484,11 @@ const GROUP_DESCRIPTIONS: Record = { import: "Import a wallet from an existing secret or device.", account: "Query on-chain account state, activate accounts, and set on-chain identity fields.", token: "Manage the token address book and query tokens.", - tx: "Build, send, broadcast, co-sign, and inspect transactions.", + tx: "Build, send, broadcast, and inspect transactions.", + contract: "Call, deploy, govern, and inspect smart contracts.", + proposal: "Create, approve, delete, and query on-chain governance proposals.", + witness: "Register and operate a super representative candidacy.", gasfree: "Gas-free token transfers via the GasFree service (open.gasfree.io).\nFees are charged in the transferred token — a per-transfer service fee, plus a one-time\nactivation fee on the first transfer from an inactive GasFree address — so no TRX is needed.\nRequires API credentials (config gasfreeApiKey / gasfreeApiSecret).", - contract: "Call, send, deploy, and inspect smart contracts.", stake: "Stake / delegate resources & query state (TRON Stake 2.0).", vote: "Vote for super representatives (SR).\nVoting accrues rewards — query and claim them with 'wallet-cli reward'.", reward: "Query and withdraw voting/block rewards.", diff --git a/ts/src/adapters/inbound/cli/help/root-help-coverage.test.ts b/ts/src/adapters/inbound/cli/help/root-help-coverage.test.ts new file mode 100644 index 000000000..116732688 --- /dev/null +++ b/ts/src/adapters/inbound/cli/help/root-help-coverage.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { HelpService } from "./index.js"; +import { isChainCommand, type StreamManager } from "../contracts/index.js"; +import { composeCliRuntime } from "../../../../bootstrap/composition.js"; + +/** + * `wallet-cli --help` is where discovery starts, and its three group tables are HAND-WRITTEN + * (help/index.ts #renderRoot) rather than derived from the registry. That list has already fallen + * behind twice — `asset` / `exchange` and the governance groups each shipped registered, working, + * and invisible at the root. For an agent-first CLI a command that cannot be found is a command + * that cannot be used, so this test enumerates the REAL registry and fails when a top-level group + * is missing from the root listing. + * + * It deliberately does not check descriptions or ordering — those are editorial. Only presence. + */ +describe("wallet-cli --help lists every registered top-level command", () => { + let previousHome: string | undefined; + + beforeAll(() => { + previousHome = process.env.WALLET_CLI_HOME; + process.env.WALLET_CLI_HOME = mkdtempSync(join(tmpdir(), "wallet-cli-root-help-")); + }); + + afterAll(() => { + if (previousHome === undefined) delete process.env.WALLET_CLI_HOME; + else process.env.WALLET_CLI_HOME = previousHome; + }); + + function rootHelp(): { text: string; heads: string[] } { + const runtime = composeCliRuntime({ + globals: { output: "text", verbose: false }, + secretPaths: {}, + startedAt: Date.now(), + }); + let text = ""; + const stream = { + result(t: string) { text = t; }, + diagnostic() {}, errorLine() {}, event() {}, readStdinOnce: () => "", warnings: () => [], + } as unknown as StreamManager; + new HelpService(runtime.registry, stream, "0.0.0").handleMeta(["--help"]); + // every command's FIRST path segment — the name a user types to explore further + const heads = [ + ...new Set(runtime.registry.all().map((c) => (isChainCommand(c) ? c.spec.path : c.path)[0]!)), + ].sort(); + return { text, heads }; + } + + it("names every top-level group somewhere in the root listing", () => { + const { text, heads } = rootHelp(); + // match the listing column only, so a name appearing inside prose cannot mask a missing row + const listed = new Set( + text.split("\n") + .map((line) => /^ {2}([a-z][a-z0-9-]*)\s{2,}\S/.exec(line)?.[1]) + .filter((name): name is string => name !== undefined), + ); + expect(heads.filter((head) => !listed.has(head))).toEqual([]); + }); + + it("covers the v4.12.0 additions specifically", () => { + const { text } = rootHelp(); + for (const group of ["asset", "exchange", "proposal", "witness"]) { + expect(text, `${group} missing from wallet-cli --help`).toMatch(new RegExp(`^ {2}${group}\\s{2,}\\S`, "m")); + } + }); +}); diff --git a/ts/src/adapters/inbound/cli/output/envelope.ts b/ts/src/adapters/inbound/cli/output/envelope.ts index e5419f27f..c5c36cec0 100644 --- a/ts/src/adapters/inbound/cli/output/envelope.ts +++ b/ts/src/adapters/inbound/cli/output/envelope.ts @@ -27,8 +27,11 @@ function chainView(net: NetworkDescriptor): ChainView { }; } -function meta(durationMs: number, warnings: WarningItem[]): Meta { - return { durationMs, warnings }; +/** Copy so the caller's object cannot be mutated through the envelope. Takes the whole Meta rather + * than field-by-field arguments: optional members (pagination) are then carried automatically + * instead of being silently dropped each time one is added. */ +function meta(m: Meta): Meta { + return { ...m }; } export const OutputEnvelope = { @@ -36,14 +39,14 @@ export const OutputEnvelope = { command: string, net: NetworkDescriptor | undefined, data: unknown, - m: { durationMs: number; warnings: WarningItem[] }, + m: Meta, ): ResultEnvelope { const env: ResultEnvelope = { schema: SCHEMA_VERSION, success: true, command, data: data ?? {}, - meta: meta(m.durationMs, m.warnings), + meta: meta(m), }; if (net) env.chain = chainView(net); // neutral commands omit chain return env; @@ -53,14 +56,14 @@ export const OutputEnvelope = { command: string, net: NetworkDescriptor | undefined, err: CliErrorEnvelopeShape, - m: { durationMs: number; warnings: WarningItem[] }, + m: Meta, ): ErrorEnvelope { const env: ErrorEnvelope = { schema: SCHEMA_VERSION, success: false, command, error: err, - meta: meta(m.durationMs, m.warnings), + meta: meta(m), }; if (net) env.chain = chainView(net); return env; diff --git a/ts/src/adapters/inbound/cli/output/index.ts b/ts/src/adapters/inbound/cli/output/index.ts index 7b8c41a2f..af32fece2 100644 --- a/ts/src/adapters/inbound/cli/output/index.ts +++ b/ts/src/adapters/inbound/cli/output/index.ts @@ -11,7 +11,7 @@ */ import type { NetworkDescriptor, OutputMode } from "../../../../domain/types/index.js"; import type { ProgressEvent } from "../../../../application/contracts/index.js"; -import type { StreamManager, TextFormatter } from "../contracts/index.js"; +import type { Pagination, StreamManager, TextFormatter } from "../contracts/index.js"; import type { CliError } from "../../../../domain/errors/index.js"; import { OutputEnvelope, toJson } from "./envelope.js"; import { renderGenericText } from "../render/index.js"; @@ -40,7 +40,13 @@ abstract class BaseOutputFormatter { class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter { success(command: string, net: NetworkDescriptor | undefined, data: unknown): string { // JSON mode always uses the envelope; the account label is a text-mode display nicety. - return toJson(OutputEnvelope.success(command, net, data, this.meta())); + const paged = extractPagination(data); + return toJson(OutputEnvelope.success( + command, + net, + paged.data, + { ...this.meta(), ...(paged.pagination ? { pagination: paged.pagination } : {}) }, + )); } error(err: CliError, ctx?: { commandId?: string; net?: NetworkDescriptor }): void { @@ -53,6 +59,38 @@ class JsonOutputFormatter extends BaseOutputFormatter implements OutputFormatter } } +/** + * Pagination is envelope metadata in the public JSON contract — ONE location for every list command, + * so a caller pages any of them without knowing the payload's shape. Text renderers keep reading the + * same value from their view model to title `showing N of total`, which is why only this path moves it. + * + * What identifies a window is `offset` + `limit`; `total` is OPTIONAL and normalised to `null`, + * because for TRON's paginated endpoints no count exists at all (obtaining one would mean + * transferring every record). Requiring it here is what used to strand `asset list` / `exchange list` + * in `data` while `backup --records` / `proposal show` moved to `meta` — the same envelope carrying + * the same concept in two places, decided by whether a total happened to be knowable. + */ +function extractPagination(data: unknown): { data: unknown; pagination?: Pagination } { + if (!data || typeof data !== "object" || Array.isArray(data)) return { data }; + const source = data as Record; + const value = source.pagination; + if (!value || typeof value !== "object" || Array.isArray(value)) return { data }; + const pagination = value as Record; + // Not a window → leave it alone: `pagination` might be an unrelated field on some future payload. + if ( + !Number.isInteger(pagination.offset) || + !(pagination.limit === null || Number.isInteger(pagination.limit)) + ) return { data }; + const normalized: Pagination = { + offset: Number(pagination.offset), + limit: pagination.limit === null ? null : Number(pagination.limit), + total: Number.isInteger(pagination.total) ? Number(pagination.total) : null, + }; + const clean = { ...source }; + delete clean.pagination; + return { data: clean, pagination: normalized }; +} + class HumanOutputFormatter extends BaseOutputFormatter implements OutputFormatter { // Text mode: strip terminal control bytes from every frame so a hostile wallet label or remote // token/RPC metadata value cannot inject ANSI/OSC sequences (CLI-OUT-001). JSON mode stays raw. diff --git a/ts/src/adapters/inbound/cli/output/output.test.ts b/ts/src/adapters/inbound/cli/output/output.test.ts index 76e09f7c0..8f407493d 100644 --- a/ts/src/adapters/inbound/cli/output/output.test.ts +++ b/ts/src/adapters/inbound/cli/output/output.test.ts @@ -47,6 +47,70 @@ describe("createOutputFormatter (json)", () => { const frame = f.event({ type: "awaiting_device", reason: "sign" }); expect(JSON.parse(frame!)).toEqual({ type: "awaiting_device", reason: "sign" }); }); + + it("moves pagination into JSON envelope metadata", () => { + const { sm } = capture("json"); + const f = createOutputFormatter("json", sm, 0); + const env = JSON.parse(f.success("proposal.list", net, { + approvalThreshold: 18, + proposals: [], + pagination: { offset: 10, limit: 5, total: 42 }, + })); + expect(env.data).toEqual({ approvalThreshold: 18, proposals: [] }); + expect(env.meta.pagination).toEqual({ offset: 10, limit: 5, total: 42 }); + }); + + // The commands whose endpoint reports no count (asset list / exchange list) must land in the SAME + // place as those that do — otherwise one envelope carries one concept in two locations, decided by + // whether a total happens to be knowable. + it("moves pagination into metadata even when no total is knowable, as total: null", () => { + const { sm } = capture("json"); + const f = createOutputFormatter("json", sm, 0); + const env = JSON.parse(f.success("asset.list", net, { + assets: [{ assetId: "1000001" }], + pagination: { offset: 0, limit: 10 }, + })); + expect(env.data).toEqual({ assets: [{ assetId: "1000001" }] }); + expect(env.meta.pagination).toEqual({ offset: 0, limit: 10, total: null }); + }); + + it("keeps both window keys present so null is the only 'unknown' signal", () => { + const { sm } = capture("json"); + const f = createOutputFormatter("json", sm, 0); + const env = JSON.parse(f.success("backup.records", undefined, { + records: [], + pagination: { offset: 0, limit: null, total: 0 }, + })); + expect(Object.keys(env.meta.pagination).sort()).toEqual(["limit", "offset", "total"]); + expect(env.meta.pagination).toEqual({ offset: 0, limit: null, total: 0 }); + }); + + it("leaves a `pagination` field that is not a window untouched in data", () => { + const { sm } = capture("json"); + const f = createOutputFormatter("json", sm, 0); + const env = JSON.parse(f.success("some.command", net, { pagination: { mode: "cursor" } })); + expect(env.data).toEqual({ pagination: { mode: "cursor" } }); + expect(env.meta.pagination).toBeUndefined(); + }); + + it("carries no pagination key at all for an unpaginated command", () => { + const { sm } = capture("json"); + const f = createOutputFormatter("json", sm, 0); + const env = JSON.parse(f.success("account.info", net, { address: "T..." })); + expect(env.meta).not.toHaveProperty("pagination"); + }); + + // Text mode titles read the window from the view model, so it must NOT be stripped there. + it("leaves pagination in the view model for text renderers", () => { + const { sm } = capture("text"); + const f = createOutputFormatter("text", sm, 0); + const seen: unknown[] = []; + f.success("asset.list", net, { assets: [], pagination: { offset: 0, limit: 10 } }, (data) => { + seen.push((data as { pagination?: unknown }).pagination); + return "rendered"; + }); + expect(seen).toEqual([{ offset: 0, limit: 10 }]); + }); }); describe("createOutputFormatter (text)", () => { diff --git a/ts/src/adapters/inbound/cli/render/asset.ts b/ts/src/adapters/inbound/cli/render/asset.ts new file mode 100644 index 000000000..3d2401ece --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/asset.ts @@ -0,0 +1,62 @@ +import type { TextFormatter } from "../contracts/index.js" +import { fromBaseUnits } from "../../../../domain/amounts/index.js" +import { formatDecimal, formatInt, formatUtc, num } from "./scalars.js" +import { type Obj, type Pair, asObj, kv, table, titled } from "./layout.js" + +/** whole tokens from minimal units — TRC10 quantities are always stored scaled by precision. */ +function whole(raw: unknown, precision: unknown): string { + return raw === undefined || raw === null ? "" : formatDecimal(fromBaseUnits(String(raw), num(precision, 0))) +} + +function price(d: Obj): string { + const [trx, tokens] = String(d.price ?? "").split(":") + if (!trx || !tokens) return "" + return `${formatInt(trx)} TRX = ${formatInt(tokens)} ${String(d.name ?? "tokens")}` +} + +export const AssetFormatters = { + assetInfo: ((data) => { + const d = asObj(data) + const precision = d.precision + const rows: Pair[] = [ + ["Issuer", String(d.issuerAddress ?? "")], + ["Total supply", whole(d.totalSupply, precision)], + ["Precision", formatInt(precision ?? 0)], + ["Price", price(d)], + ["ICO start time", formatUtc(d.startTime)], + ["ICO end time", formatUtc(d.endTime)], + ["Url", String(d.url ?? "")], + ["Description", String(d.description ?? "")], + ["Free net/account", formatInt(d.freeAssetNetLimit ?? 0)], + ["Public free net", formatInt(d.publicFreeAssetNetLimit ?? 0)], + ] + const body = titled(`Asset ${d.name ?? ""} (id ${d.assetId ?? ""})`, rows) + // An empty collection is omitted entirely rather than printed as "Frozen (0)". + const tranches = Array.isArray(d.frozenSupply) ? d.frozenSupply as Obj[] : [] + if (tranches.length === 0) return body + const frozen = kv( + tranches.map((t): Pair => [whole(t.amount, precision), `until ${formatUtc(t.expireTime)}`]), + " ", + ) + return `${body}\n Frozen (${tranches.length})\n${frozen}` + }) satisfies TextFormatter, + + // Reserves/supply are whole tokens here at no cost: an asset record carries its own precision. + assetList: ((data) => { + const d = asObj(data) + const assets = Array.isArray(d.assets) ? d.assets as Obj[] : [] + const page = asObj(d.pagination) + const header = `Assets (limit ${formatInt(page.limit ?? 0)}, offset ${formatInt(page.offset ?? 0)})` + if (assets.length === 0) return `${header}\n (none)` + return `${header}\n${table( + ["ID", "Name", "Total supply", "Precision", "Issuer"], + assets.map((a) => [ + String(a.assetId ?? ""), + String(a.name ?? ""), + whole(a.totalSupply, a.precision), + formatInt(a.precision ?? 0), + String(a.issuerAddress ?? ""), + ]), + )}` + }) satisfies TextFormatter, +} diff --git a/ts/src/adapters/inbound/cli/render/exchange.ts b/ts/src/adapters/inbound/cli/render/exchange.ts new file mode 100644 index 000000000..e8463ffaf --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/exchange.ts @@ -0,0 +1,53 @@ +import type { TextFormatter } from "../contracts/index.js" +import { fromBaseUnits } from "../../../../domain/amounts/index.js" +import { formatDecimal, formatInt, formatUtc, num } from "./scalars.js" +import { type Obj, type Pair, asObj, kv, table, titled } from "./layout.js" + +function whole(raw: unknown, decimals: unknown): string { + return raw === undefined || raw === null ? "" : formatDecimal(fromBaseUnits(String(raw), num(decimals, 0))) +} + +/** `MyToken (id 1000123)`, or plain `TRX` for the native side. */ +function sideLabel(tokenId: unknown, label: unknown): string { + const id = String(tokenId ?? "") + if (id === "_") return "TRX" + return label ? `${String(label)} (id ${id})` : `id ${id}` +} + +export const ExchangeFormatters = { + exchangeShow: ((data) => { + const d = asObj(data) + const head = titled(`Exchange id ${formatInt(d.exchangeId ?? 0)}`, [ + ["Creator", String(d.creatorAddress ?? "")], + ["Created time", formatUtc(d.createTime)], + ]) + const reserves = kv([ + [sideLabel(d.firstTokenId, d.firstTokenLabel), whole(d.firstTokenBalance, d.firstTokenDecimals)], + [sideLabel(d.secondTokenId, d.secondTokenLabel), whole(d.secondTokenBalance, d.secondTokenDecimals)], + ] as Pair[], " ") + // No price is derived: the reserve ratio is a quoted rate, not what a trade returns. Price a + // specific amount with `exchange trade --dry-run`. + return `${head}\n Reserves\n${reserves}` + }) satisfies TextFormatter, + + /** + * One RPC, so no token names or precisions are available (docs/adr/0005) — ids and minimal units, + * with the column labelled so the numbers cannot be mistaken for whole tokens. + */ + exchangeList: ((data) => { + const d = asObj(data) + const rows = Array.isArray(d.exchanges) ? d.exchanges as Obj[] : [] + const page = asObj(d.pagination) + const header = `Exchanges (limit ${formatInt(page.limit ?? 0)}, offset ${formatInt(page.offset ?? 0)})` + if (rows.length === 0) return `${header}\n (none)` + return `${header}\n${table( + ["ID", "Pair", "Reserves (minimal units)", "Creator"], + rows.map((e) => [ + formatInt(e.exchangeId ?? 0), + String(e.pair ?? ""), + `${formatDecimal(e.firstTokenBalance)} / ${formatDecimal(e.secondTokenBalance)}`, + String(e.creatorAddress ?? ""), + ]), + )}` + }) satisfies TextFormatter, +} diff --git a/ts/src/adapters/inbound/cli/render/governance.ts b/ts/src/adapters/inbound/cli/render/governance.ts new file mode 100644 index 000000000..10e8513f2 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/governance.ts @@ -0,0 +1,217 @@ +import type { TextFormatter, TextRenderContext } from "../contracts/index.js"; +import { asObj, ok, pending, receipt, titled } from "./layout.js"; +import { formatInt, formatSun } from "./scalars.js"; + +type Obj = Record; + +export const GovernanceFormatters = { + proposalList: ((data) => renderProposalList(asObj(data))) satisfies TextFormatter, + proposalShow: ((data) => renderProposalShow(asObj(data))) satisfies TextFormatter, + governanceReceipt: ((data, ctx) => renderGovernanceReceipt(asObj(data), ctx)) satisfies TextFormatter, + contractCreate2: ((data) => { + const d = asObj(data); + return titled("Contract address (CREATE2)", [ + ["Deployer", String(d.deployerAddress ?? "")], + ["Salt", `${String(d.salt ?? "")} (${compactHex(String(d.saltHex ?? ""))})`], + ["Code hash", String(d.codeHash ?? "")], + ["Address", String(d.address ?? "")], + ]); + }) satisfies TextFormatter, +}; + +function renderProposalList(data: Obj): string { + const proposals = Array.isArray(data.proposals) ? data.proposals.map(asObj) : []; + const pagination = asObj(data.pagination); + const total = Number(pagination.total ?? proposals.length); + const paged = pagination.limit !== null || Number(pagination.offset ?? 0) > 0; + const title = paged + ? `Proposals (showing ${proposals.length} of ${total})` + : `Proposals (${proposals.length})`; + const headers = ["ID", "State", "Approvals", "Expiry (UTC)", "Parameter change"]; + const rows: string[][] = []; + for (const proposal of proposals) { + const changes = Array.isArray(proposal.changes) ? proposal.changes.map(asObj) : []; + const base = [ + String(proposal.id ?? ""), + String(proposal.state ?? ""), + `${formatInt(proposal.approvals)} / ${formatInt(data.approvalThreshold)}`, + utcMinute(proposal.expirationTime), + ]; + if (changes.length === 0) rows.push([...base, ""]); + for (const [index, change] of changes.entries()) { + rows.push([ + ...(index === 0 ? base : ["", "", "", ""]), + `${String(change.name ?? "")}: ${changeValue(change.currentValue)} → ${changeValue(change.proposedValue)}`, + ]); + } + } + const widths = headers.map((header, index) => Math.max( + header.length, + ...rows.map((row) => String(row[index] ?? "").length), + )); + const line = (cells: string[]) => ` ${cells.map((cell, index) => String(cell).padEnd(widths[index] ?? 0)).join(" ").trimEnd()}`; + return [title, line(headers), ...rows.map(line)].join("\n"); +} + +function renderProposalShow(data: Obj): string { + const changes = Array.isArray(data.changes) ? data.changes.map(asObj) : []; + const body = titled(`Proposal #${String(data.id ?? "")}`, [ + ["State", String(data.state ?? "")], + ["Proposer", String(data.proposerAddress ?? "")], + ["Created time", `${utcMinute(data.createTime)} UTC`], + ["Expiry time", `${utcMinute(data.expirationTime)} UTC`], + ["Approvals", `${formatInt(data.approvals)} / ${formatInt(data.approvalThreshold)}`], + ["Parameter changes", `(${changes.length})`], + ]); + return [ + body, + ...changes.map((change) => + ` ${String(change.name ?? "")} ${changeValue(change.currentValue)} → ${changeValue(change.proposedValue)}${change.unit ? ` ${String(change.unit)}` : ""}`, + ), + ].join("\n"); +} + +function renderGovernanceReceipt(data: Obj, ctx: TextRenderContext): string { + const kind = String(data.kind ?? ""); + const mode = String(data.mode ?? ""); + const label = actionLabel(kind, Boolean(data.addApproval)); + const fields = governanceRows(data, ctx); + if (mode === "dry-run") { + fields.push(["Fee", estimateFee(data)]); + return appendChanges(receipt(pending(), `Dry run ${label}`, fields), data); + } + if (mode === "build-only") { + fields.push(["Unsigned hex", String(data.unsignedHex ?? "")]); + return appendChanges(receipt(ok(), `Built unsigned ${label}`, fields), data); + } + if (mode === "sign-only") { + fields.push(["TxID", String(data.txId ?? "")]); + fields.push(["Signed", signedSummary(data.signed)]); + return appendChanges(receipt(ok(), `Signed ${label}`, fields), data); + } + + fields.push(["TxID", String(data.txId ?? data.hash ?? "")]); + const stage = String(data.stage ?? "submitted"); + if (stage === "confirmed" || stage === "failed") { + fields.push(["Block", data.blockNumber === undefined ? "" : formatInt(data.blockNumber)]); + fields.push(["Fee", confirmedFee(data)]); + fields.push(["Status", stage === "failed" ? "failed" : "success"]); + return appendChanges(receipt(stage === "failed" ? "❌" : ok(), pastLabel(kind, Boolean(data.addApproval)), fields), data); + } + fields.push(["Status", "submitted — pending confirmation"]); + return appendChanges(receipt(pending(), pastLabel(kind, Boolean(data.addApproval)), fields), data); +} + +function governanceRows(data: Obj, ctx: TextRenderContext): Array<[string, string]> { + const address = (value: unknown) => value ? `${String(value)}${ctx.accountLabel ? ` (${ctx.accountLabel})` : ""}` : ""; + switch (String(data.kind ?? "")) { + case "proposal-create": + return [ + ["Proposal", data.proposalId === undefined ? "" : `#${String(data.proposalId)}`], + ["Proposer", address(data.proposerAddress)], + ]; + case "proposal-approve": + return [ + ["Proposal", `#${String(data.proposalId ?? "")}`], + ["Voter", address(data.voterAddress)], + ["Approvals", `${formatInt(data.approvals)} / ${formatInt(data.approvalThreshold)}`], + ]; + case "proposal-delete": + return [["Proposal", `#${String(data.proposalId ?? "")}`], ["Proposer", address(data.proposerAddress)]]; + case "witness-create": + case "witness-update": + return [["Witness", address(data.witnessAddress)], ["Url", String(data.url ?? "")]]; + case "witness-set-brokerage": + return [["Witness", address(data.witnessAddress)], ["Brokerage", `${String(data.brokerage ?? "")}%`]]; + case "contract-clear-abi": + return [["Contract", String(data.contractAddress ?? "")], ["Deployer", address(data.deployerAddress)]]; + case "contract-set-origin-energy-limit": + return [ + ["Contract", String(data.contractAddress ?? "")], + ["Deployer", address(data.deployerAddress)], + ["Energy limit", formatInt(data.originEnergyLimit)], + ]; + case "contract-set-user-resource-percent": + return [ + ["Contract", String(data.contractAddress ?? "")], + ["Deployer", address(data.deployerAddress)], + ["User pays", `${String(data.consumeUserResourcePercent ?? "")}%`], + ]; + default: + return []; + } +} + +function appendChanges(rendered: string, data: Obj): string { + const changes = Array.isArray(data.changes) ? data.changes.map(asObj) : []; + if (changes.length === 0) return rendered; + return [ + rendered, + ` Parameter changes (${changes.length})`, + ...changes.map((change) => + ` ${String(change.name ?? "")} ${changeValue(change.currentValue)} → ${changeValue(change.proposedValue)}${change.unit ? ` ${String(change.unit)}` : ""}`, + ), + ].join("\n"); +} + +function actionLabel(kind: string, addApproval: boolean): string { + return { + "proposal-create": "proposal create", + "proposal-approve": addApproval ? "proposal approval" : "approval cancellation", + "proposal-delete": "proposal delete", + "witness-create": "witness registration", + "witness-update": "witness update", + "witness-set-brokerage": "brokerage update", + "contract-clear-abi": "ABI clear", + "contract-set-origin-energy-limit": "origin energy limit update", + "contract-set-user-resource-percent": "user resource ratio update", + }[kind] ?? kind; +} + +function pastLabel(kind: string, addApproval: boolean): string { + return { + "proposal-create": "Proposal created", + "proposal-approve": addApproval ? "Proposal approved" : "Approval canceled", + "proposal-delete": "Proposal deleted", + "witness-create": "Witness registered", + "witness-update": "Witness updated", + "witness-set-brokerage": "Brokerage set", + "contract-clear-abi": "ABI cleared", + "contract-set-origin-energy-limit": "Origin energy limit set", + "contract-set-user-resource-percent": "User pay ratio set", + }[kind] ?? kind; +} + +function estimateFee(data: Obj): string { + const fee = asObj(data.fee); + if (fee.feeSun !== undefined) return `${formatSun(fee.feeSun)} TRX`; + return String(fee.note ?? "bandwidth only"); +} + +function confirmedFee(data: Obj): string { + const resource = asObj(data.resource); + const bandwidth = resource.netUsage === undefined ? "" : ` (${formatInt(resource.netUsage)} bandwidth)`; + const feeSun = data.feeSun ?? (data.kind === "witness-create" ? data.registrationFeeSun : 0); + return `${formatSun(feeSun)} TRX${bandwidth}`; +} + +function signedSummary(value: unknown): string { + if (!value || typeof value !== "object") return String(value ?? ""); + const signatures = (value as { signature?: unknown }).signature; + return Array.isArray(signatures) ? signatures.map(String).join(", ") : JSON.stringify(value); +} + +function changeValue(value: unknown): string { + return value === null || value === undefined ? "unknown" : String(value); +} + +function utcMinute(value: unknown): string { + const epoch = Number(value); + return Number.isFinite(epoch) && epoch > 0 + ? new Date(epoch).toISOString().replace("T", " ").slice(0, 16) + : "unknown"; +} + +function compactHex(value: string): string { + return value.length > 18 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value; +} diff --git a/ts/src/adapters/inbound/cli/render/index.ts b/ts/src/adapters/inbound/cli/render/index.ts index cc5dc0028..0d76b5d14 100644 --- a/ts/src/adapters/inbound/cli/render/index.ts +++ b/ts/src/adapters/inbound/cli/render/index.ts @@ -16,12 +16,15 @@ import { formatScalar } from "./scalars.js" import { type Obj, ok } from "./layout.js" import { WalletFormatters } from "./wallet.js" import { AccountFormatters } from "./account.js" +import { AssetFormatters } from "./asset.js" +import { ExchangeFormatters } from "./exchange.js" import { TxFormatters } from "./tx.js" import { StakeFormatters } from "./stake.js" import { VoteFormatters } from "./vote.js" import { RewardFormatters } from "./reward.js" import { ChainFormatters } from "./chain.js" import { MiscFormatters } from "./misc.js" +import { GovernanceFormatters } from "./governance.js" import { PermissionFormatters } from "./permission.js" import { MultisigFormatters } from "./multisig.js" import { GasFreeFormatters } from "./gasfree.js" @@ -33,12 +36,15 @@ export { FAMILY_RENDER, renderFamily } from "./family.js" export const TextFormatters = { ...WalletFormatters, ...AccountFormatters, + ...AssetFormatters, + ...ExchangeFormatters, ...TxFormatters, ...StakeFormatters, ...VoteFormatters, ...RewardFormatters, ...ChainFormatters, ...MiscFormatters, + ...GovernanceFormatters, ...PermissionFormatters, ...MultisigFormatters, ...GasFreeFormatters, diff --git a/ts/src/adapters/inbound/cli/render/scalars.ts b/ts/src/adapters/inbound/cli/render/scalars.ts index 229e1c2d3..5cc32d9a8 100644 --- a/ts/src/adapters/inbound/cli/render/scalars.ts +++ b/ts/src/adapters/inbound/cli/render/scalars.ts @@ -13,6 +13,9 @@ export function formatScalar(v: unknown): string { } export function formatInt(v: unknown): string { + if (typeof v === "string" && /^-?\d+$/.test(v)) { + return formatDecimal(v); + } const n = Number(v); return Number.isFinite(n) ? Math.trunc(n).toLocaleString("en-US") : String(v ?? ""); } diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index d793baa7f..6d3b8de3d 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -4,7 +4,7 @@ import { ChainFamily } from "../../../../domain/family/index.js" import { fromBaseUnits } from "../../../../domain/amounts/index.js" import type { TxApprovalView } from "../../../../domain/types/index.js" import { renderApproval } from "./approval.js" -import { formatScalar, formatInt, formatSun, num, shorten, methodName } from "./scalars.js" +import { formatScalar, formatDecimal, formatInt, formatSun, formatUtc, num, shorten, methodName } from "./scalars.js" import { type Pair, asObj, query, receipt, ok, fail, pending, unknown } from "./layout.js" import { FAMILY_RENDER, renderFamily } from "./family.js" @@ -132,6 +132,15 @@ function receiptSummary(r: TxReceiptView, family: ChainFamily): string { return `Called ${methodName(String(r.method ?? ""))}` case "contract-deploy": return "Contract deployed" + case "proposal-create": return "Proposal created" + case "proposal-approve": return "Proposal approval submitted" + case "proposal-delete": return "Proposal deleted" + case "witness-create": return "Witness registered" + case "witness-update": return "Witness updated" + case "witness-set-brokerage": return "Brokerage set" + case "contract-clear-abi": return "ABI cleared" + case "contract-set-origin-energy-limit": return "Origin energy limit set" + case "contract-set-user-resource-percent": return "User resource ratio set" case "vote-cast": { const count = Array.isArray(r.votes) ? r.votes.length : 0 const across = `across ${formatInt(count)} witness${count === 1 ? "" : "es"}` @@ -155,16 +164,140 @@ function receiptSummary(r: TxReceiptView, family: ChainFamily): string { return "Account activated" case "account-set": return `On-chain ${r.field ?? "account field"} set` + case "asset-issue": + return "Asset issued" + case "asset-update": + return "Asset updated" + case "asset-participate": + return "Participated in ICO" + case "asset-unfreeze": + return "Frozen supply released" + case "exchange-create": + return "Exchange created" + case "exchange-inject": + return "Liquidity injected" + case "exchange-withdraw": + return "Liquidity withdrawn" + case "exchange-trade": + return "Trade completed" } } +/** ` :` 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/governance/chain-parameters.test.ts b/ts/src/domain/governance/chain-parameters.test.ts new file mode 100644 index 000000000..7a0eb9b69 --- /dev/null +++ b/ts/src/domain/governance/chain-parameters.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { parseChainParameterAssignments, proposalParameterChanges } 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(proposalParameterChanges({ "999": "9223372036854775807" }, current)).toEqual([ + { + id: 999, + name: "parameter-999", + currentValue: null, + proposedValue: "9223372036854775807", + unit: "", + }, + ]); + }); + + 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..6cc1f9200 --- /dev/null +++ b/ts/src/domain/governance/chain-parameters.ts @@ -0,0 +1,226 @@ +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; +} + +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 proposalParameterChanges( + parameters: Readonly>, + current: ReadonlyArray<{ key: string; value?: number | string }>, +): ChainParameterChange[] { + const currentByName = new Map(current.map((entry) => [entry.key.toLowerCase(), entry.value])); + return Object.entries(parameters) + .map(([rawId, rawValue]) => { + const id = Number(rawId); + const definition = byId.get(id); + const name = definition?.name ?? `parameter-${id}`; + const value = /^-?\d+$/.test(rawValue) && BigInt(rawValue) <= MAX_SAFE + ? Number(rawValue) + : rawValue; + return { + id, + name, + currentValue: currentByName.get(name.toLowerCase()) ?? null, + proposedValue: 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..ca44e8800 --- /dev/null +++ b/ts/src/domain/governance/create2.test.ts @@ -0,0 +1,30 @@ +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..6426bca2a --- /dev/null +++ b/ts/src/domain/governance/create2.ts @@ -0,0 +1,62 @@ +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..40bbb8179 --- /dev/null +++ b/ts/src/domain/keystore/index.ts @@ -0,0 +1,155 @@ +/** + * 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. + */ +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); + + if (bytesToHex(Web3Crypto.mac(dk, ciphertext)) !== c.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"); + 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; +} + +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..352b03dbb --- /dev/null +++ b/ts/src/domain/keystore/keystore-v3.test.ts @@ -0,0 +1,137 @@ +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)); + }); +}); diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index 6950900fe..1420827a8 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -88,8 +88,13 @@ export type TxReceiptKind = | "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"; + | "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 +137,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/test/contract-deploy.test.ts b/ts/test/contract-deploy.test.ts index cc385e6d4..7a83560dd 100644 --- a/ts/test/contract-deploy.test.ts +++ b/ts/test/contract-deploy.test.ts @@ -20,7 +20,6 @@ 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"; @@ -62,7 +61,7 @@ function deploy( ]; 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" }, diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 03870d284..e8812d89a 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -8,7 +8,6 @@ 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" @@ -33,7 +32,11 @@ function run(args: string[], opts: { input?: string; password?: string | null } } // 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 }) + // `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) @@ -589,3 +592,61 @@ describe("golden CLI — fixes regression", () => { 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") + }) +})